๐๏ธ Building an Org Monorepo
๐ก An org monorepo is not โone giant Git tree.โ It is a versioned dependency graph with enforceable import edges, a single toolchain contract, reusable CI/CD templates, and IaC that ships next to the code it provisions. Below is the blueprint we use.
๐ตโ๐ซ Why polyrepos rot under shared code
Most platform teams rediscover the same failure modes once shared Python code shows up across services:
- ๐ Library updates require sequential PRs across producer + consumer repos, plus an internal package index bump โ merge the lib, wait for the wheel to publish, then bump the consumer.
- ๐งช You cannot cheaply prove a lib change is safe against the full dependent closure; nobody runs โevery serviceโs testsโ before merging.
- ๐ Engineers stop extracting libraries and start copy-pasting, because sharing is too expensive.
- ๐ Accidental cycles and fuzzy ownership appear the moment โshared helpersโ leak between services inside a junk-drawer repo.
- ๐ Cross-repo search and IDE navigation are worse than a single workspace checkout.
- ๐งญ Per-repo lint/format/type configs diverge until cross-repo contribution becomes a tax.
Our monorepo collapses publish-then-consume into
path / editable installs via
uv workspaces
[2]. CI expands the affected closure
from the workspace graph / uv.lock. The invariant:
internal packages are linked, not version-chased. External
consumers outside the monorepo can still get versioned wheels from
an internal index if needed โ but inside the repo, path deps are
law.
๐ Target topology
Keep top-level directories semantic and mutually exclusive. Every
leaf package owns a pyproject.toml and
a CI entrypoint: .gitlab-ci.yml on GitLab, or the
GitHub equivalent (a thin leaf workflow / config that
uses: a reusable workflow โ details below). Nothing
cross-cuts via relative imports into another leafโs
src/.
๐ VCS exclusivity: an org standardizes on
either GitHub or GitLab โ never both as first-class
homes for the same monorepo. Keep exactly one of
.github/ or .gitlab/ for shared templates,
child-pipeline / reusable-workflow entrypoints, and ownership
metadata.
org-monorepo/
โโโ .github/ # XOR: workflows/, actions/, CODEOWNERS
โ # โ or โ
โโโ .gitlab/ # XOR: ci/ templates, includes, CODEOWNERS
โโโ apps/ # user-facing / operator-facing applications
โโโ libs/ # shared abstractions (org-namespaced packages)
โโโ projects/ # domain-bounded workspaces (ETL, ML, batch, research)
โโโ services/ # independently deployable microservices
โโโ dockers/ # shared Dockerfiles, base images, compose stacks
โโโ cookiecutters/ # templates for poe new-* scaffolds (apps, libs, โฆ)
โโโ iac/ # Terraform / Pulumi / cloud foundation modules
โโโ docs/ # docs portal / landing โ aggregates leaf docs via CD
โโโ pyproject.toml # uv workspace root + shared tool config
โโโ uv.lock # deterministic resolver output
โโโ README.md
๐ Directory contracts
- ๐
apps/โ deployable application binaries / UIs / CLIs with a release artifact. May depend onlibs/*. Must not importservices/*or sibling apps. - ๐ฆ
libs/โ the only approved sharing layer. Logging, auth clients, protobuf stubs, feature flags, schema helpers, ML feature transforms โ anything with a stable public API and tests. Naming is org-namespaced (see below). - ๐งฌ
projects/โ domain-centric workspaces (e.g.projects/risk-scoring,projects/claims-etl). These are not microservices; they are bounded contexts that may produce jobs, notebooks (exported), or training pipelines. Depend onlibs/*, never onapps/*. - ๐ฐ๏ธ
services/โ network-facing microservices (HTTP/gRPC/workers) with their own image + deploy pipeline. Import only fromlibs/*. Prefer building FROM shared bases indockers/rather than copy-pasting Dockerfiles. - ๐ณ
dockers/โ shared container assets: org base images, common Dockerfiles, multi-stage templates, and localcomposestacks (deps, mesh stubs). Leaves reference these; they do not reinventFROM python:โฆin every app/service. Image build/push CD still runs from the leaf that owns the final artifact, usingdockers/as build context /FROMsource. - ๐ช
cookiecutters/โ golden-path templates for new leaves (python-lib,python-service,js-app, โฆ). Downstream engineers scaffold via task-runner CLIs (poe new-python-lib,poe new-js-app) โ they should not need to learn the nuts and bolts of workspace wiring, CI stubs, or naming maps. See the Cookiecutters section. - โ๏ธ
iac/โ Terraform (or equivalent) modules and live stacks. App/service code does not import IaC; CI wires plan/apply via path filters. - ๐
docs/โ the docs portal landing page and shared chrome (nav, style, ADRs, org-wide guides). Downstream leaves own their owndocs/nodes; CD builds them on the fly and publishes one site. See the Docs section. - ๐งฉ
.github/or.gitlab/โ shared templates + the parent pipelines that fan out into leaf jobs (child pipelines / reusable workflows). See CI section.
๐งพ What every leaf owns
A leaf is not โjust source.โ It ships its package manifest and its CI contract:
# GitLab leaf
libs/foo-bar-baz-qux/
โโโ pyproject.toml
โโโ .gitlab-ci.yml # child-pipeline entry (thin; includes shared template)
โโโ docs/ # leaf docs node (API + how-to) โ aggregated by CD
โโโ src/foo/bar/baz/qux/
โโโ tests/
# GitHub leaf โ Actions workflows must live under repo-root .github/workflows/
# so the leaf owns a thin config; the stub workflow lives next to shared templates
libs/foo-bar-baz-qux/
โโโ pyproject.toml
โโโ ci.yml # leaf values: image, matrix, deploy target, โฆ
โโโ docs/ # leaf docs node โ aggregated by CD
โโโ src/foo/bar/baz/qux/
โโโ tests/
.github/workflows/
โโโ _python-leaf.yml # reusable workflow (workflow_call) โ the real jobs
โโโ libs-foo-bar-baz-qux.yml # stub: path filters + uses: _python-leaf.yml
GitHubโs equivalent of a per-directory
.gitlab-ci.yml is not a nested workflow file inside
libs/โฆ (Actions only loads workflows from
.github/workflows/ at the repo root). The practical
equivalent is: leaf ci.yml (values) + root
stub workflow that uses: a reusable workflow.
Same idea as GitLab โ leaf declares intent; shared template owns
mechanics.
๐ท๏ธ Lib naming: hyphenated distro โ nested import path
Library distribution names are org-based namespaces
written with hyphens. Hyphens map 1:1 to nested packages on disk
and in site-packages after install:
# distribution / directory name โ import / site-packages path
foo-bar-baz-qux โ foo/bar/baz/qux
acme-ml-features-transforms โ acme/ml/features/transforms
acme-platform-logging โ acme/platform/logging
Source layout inside the leaf (CI files shown above):
libs/foo-bar-baz-qux/
โโโ pyproject.toml # name = "foo-bar-baz-qux"
โโโ src/
โ โโโ foo/
โ โโโ bar/
โ โโโ baz/
โ โโโ qux/
โ โโโ __init__.py
โ โโโ ...
โโโ tests/
After uv sync / pip install, the package
lands as nested modules under site-packages:
site-packages/
โโโ foo/
โโโ bar/
โโโ baz/
โโโ qux/
โโโ __init__.py
โโโ ...
# consumers import the namespace path, not the hyphenated distro name
from foo.bar.baz.qux import SomeType
Why this matters:
- ๐งญ Collision resistance โ org prefixes
(
acme-โฆ,foo-โฆ) keep internal libs from clobbering top-level PyPI names insite-packages. - ๐ Discoverability โ related libs cluster under
the same tree (
acme/ml/โฆ,acme/platform/โฆ) in both the repo and the installed environment. - ๐ Stable mental model โ
libs/<distro>โsrc/<slash-path>โfrom <dot-path> import โฆis mechanical: replace-with/(or.for imports).
Hard rules: distribution name is always hyphenated kebab-case;
Python packages never use hyphens; do not publish a flat
foobarbazqux module that ignores the namespace map.
flowchart TB
subgraph consumers["Consumers"]
A["๐ apps/*"]
P["๐งฌ projects/*"]
S["๐ฐ๏ธ services/*"]
end
L["๐ฆ libs/*\norg-namespaced"]
Dk["๐ณ dockers/*\nbase images / compose"]
Cc["๐ช cookiecutters/*\npoe new-* templates"]
D["๐ docs/*"]
I["โ๏ธ iac/*\nTerraform / cloud"]
CI["๐งฉ .github XOR .gitlab\nCI + CODEOWNERS"]
A --> L
P --> L
S --> L
A -->|FROM / build context| Dk
S -->|FROM / build context| Dk
Cc -.->|scaffolds| A
Cc -.->|scaffolds| L
Cc -.->|scaffolds| S
Cc -.->|scaffolds| P
A -.->|path filters / deploy| CI
S -.->|path filters / deploy| CI
Dk -.->|rebuild bases| CI
I -.->|plan/apply| CI
D -.->|docs-only path filter| CI
A -.->|forbidden| S
S -.->|forbidden| A
P -.->|forbidden| A
L -.->|forbidden| A
๐งฑ Import DAG rules (non-negotiable)
- โ
apps|projects|services โ libs - โ
libs โ libsonly via an acyclic internal graph (enforce with import-linter / custom ruff plugin / CI graph check). - ๐ซ
libs โ apps|projects|services - ๐ซ
apps โ services(talk over APIs / events, not Python imports) - ๐ซ deep imports into another packageโs private modules โ public entry points only
Hard organizational rule: apps/*,
projects/*, and services/* never import
each other. If two leaves need the same code, it graduates into
libs/. Encode that in tooling (import-linter / CI graph
check), not wiki pages.
flowchart LR
PR["PR diff"] --> Diff["changed paths"]
Diff --> Lib["libs/foo-bar-baz-qux\nchanged?"]
Lib -->|yes| Closure["resolve dependents\nvia uv.lock / workspace graph"]
Closure --> T1["test libs/foo-bar-other"]
Closure --> T2["test services/billing"]
Closure --> T3["test apps/console"]
Lib -->|no| Local["test only touched leaves"]
๐งฐ Base toolchain (Python)
Standardize the developer loop on a fast resolver, one task
runner, strict static analysis, and multi-env tox. Every tool below
should be pinned in the workspace root and invoked through
poe so humans and CI share identical entrypoints.
โก uv [2]
Package/project manager + workspace linker. Use
uv sync --locked at the root, path deps for
libs/*, and uv.lock as the source of truth
for affected-package computation. Constraint solving + lockfiles
keep local, CI, and prod on the same graph โ with materially better
cold-install performance than older Poetry-era stacks.
๐งน Ruff [3]
Single-binary lint + format. Configure once at the workspace root
([tool.ruff]); leaf packages may only tighten, never
invent incompatible dialects. Wire it as
poe format / ruff check inside
poe check.
๐ง Type checking โ mypy [4] or Pyright [5]
Pick one org-wide and enforce
disallow_untyped_defs (mypy) or
typeCheckingMode = "strict" (Pyright) on
libs/* first. Apps and services can ratchet up
afterward. Expose the chosen checker as poe mypy (or
poe pyright); Pyright is the better fit if you want
language-server parity with VS Code / Pylance.
๐ญ Poe the Poet [6]
Task runner declared in pyproject.toml. This is the
control plane: format, check,
test, tox, build, codegen,
etc. Engineers learn poe check && poe test; CI
calls the same tasks via [tool.poe.tasks].
๐งช tox + tox-uv [7] [8]
Matrix execution across Python versions / factor envs without
hand-rolled shell. With tox-uv, envs resolve through
uv so local and CI stay aligned. Expose as
poe tox.
flowchart LR
Dev["engineer"] --> Poe["poe check / test / tox"]
Poe --> UV["uv sync --locked"]
Poe --> Ruff["ruff format + check"]
Poe --> Types["mypy | pyright"]
Poe --> PyTest["pytest + coverage"]
Poe --> Tox["tox ยท multi-Py"]
CI["GitHub Actions XOR GitLab CI"] --> Poe
# root pyproject.toml โ illustrative shape
[tool.uv.workspace]
members = ["apps/*", "libs/*", "projects/*", "services/*"]
[tool.poe.tasks.format]
cmd = "ruff format ."
[tool.poe.tasks.check]
sequence = [
{ cmd = "poe format --check" },
{ cmd = "ruff check ." },
{ cmd = "poe mypy" },
]
[tool.poe.tasks.test]
cmd = "pytest -q"
[tool.poe.tasks.tox]
cmd = "tox"
๐งฉ CI/CD โ parent templates, child nodes where jobs start
Pick one forge and commit to it. The root
.github/ or .gitlab/ tree is the
parent: shared job definitions, images, rules, and
deploy primitives. Leaf CI files are child nodes โ
thin entrypoints that start the real jobs by including / calling
those templates. Do not copy-paste a full pipeline into every
leaf.
๐ฆ GitLab โ .gitlab/ + per-leaf
.gitlab-ci.yml
.gitlab/
โโโ ci/
โ โโโ python-leaf.yml # shared jobs: lint, typecheck, test, build
โ โโโ docker-deploy.yml # shared deploy jobs for apps/services
โ โโโ iac-plan-apply.yml
โโโ CODEOWNERS
# root .gitlab-ci.yml โ parent pipeline / dispatcher
include:
- local: .gitlab/ci/python-leaf.yml # optional defaults
stages: [detect, test, build, deploy]
# fan-out: trigger a child pipeline per affected leaf
trigger-libs-foo-bar-baz-qux:
stage: test
trigger:
include:
- local: libs/foo-bar-baz-qux/.gitlab-ci.yml
strategy: depend
rules:
- changes:
- libs/foo-bar-baz-qux/**/*
- libs/foo-bar-*/**/* # or compute dependents from uv.lock
Each leafโs .gitlab-ci.yml stays tiny โ it includes
.gitlab/ci/python-leaf.yml and only overrides
variables (image, pytest path, whether to publish a wheel).
Jobs start in the child pipeline for that leaf,
not as a 500-line monolith at the repo root.
๐ GitHub โ .github/workflows/ reusable workflows
GitHub Actions does not discover workflow YAML inside
apps/ or libs/. Workflows live under
.github/workflows/. The equivalent of GitLab child
pipelines is reusable workflows
(on: workflow_call) plus per-leaf stub workflows (or
one dispatcher workflow) that pass leaf inputs:
.github/
โโโ workflows/
โ โโโ _python-leaf.yml # reusable: the jobs (lint/test/build)
โ โโโ _docker-deploy.yml # reusable: build/push/deploy
โ โโโ libs-foo-bar-baz-qux.yml # child node / stub โ where this leaf starts
โ โโโ monorepo-dispatch.yml # optional: path-filter โ matrix of stubs
โโโ actions/ # optional composite actions
โโโ CODEOWNERS
# libs-foo-bar-baz-qux.yml (stub โ child node)
name: libs/foo-bar-baz-qux
on:
pull_request:
paths: ["libs/foo-bar-baz-qux/**", "libs/**/pyproject.toml"]
push:
paths: ["libs/foo-bar-baz-qux/**"]
jobs:
python:
uses: ./.github/workflows/_python-leaf.yml
with:
working-directory: libs/foo-bar-baz-qux
# or read libs/foo-bar-baz-qux/ci.yml inside the reusable workflow
So: reusable workflow = shared job body;
stub workflow (or dispatcher matrix entry) = child node
where that leafโs jobs start. Leaf-local
ci.yml holds values only โ image, matrix, deploy
target, Slack channel, โneeds DB?โ, etc.
flowchart TB
PR["PR / push"] --> Parent["Parent dispatcher\n.github XOR .gitlab"]
Parent --> Detect["detect affected leaves\npaths + uv.lock graph"]
Detect --> C1["Child: libs/foo-bar-baz-qux\n.gitlab-ci.yml / stub workflow"]
Detect --> C2["Child: services/billing\n.gitlab-ci.yml / stub workflow"]
Detect --> C3["Child: iac/stacks/...\nplan jobs"]
C1 --> Jobs1["lint ยท types ยท test"]
C2 --> Jobs2["lint ยท types ยท test ยท image ยท deploy"]
C3 --> Jobs3["terraform plan / apply"]
T["Shared templates\n.gitlab/ci/* or\n.github/workflows/_*.yml"] -.-> C1
T -.-> C2
T -.-> C3
Always parameterize the same four concerns:
- ๐ฏ path filters โ which leafโs child node wakes up
- ๐ธ๏ธ workspace graph โ which dependents must re-test
- ๐ณ image build + push for
apps/*/services/*on leaf changes; rebuild shared bases underdockers/when those Dockerfiles change, then rebuild dependents - โ๏ธ
iac/*โterraform planon PR, apply on protected branches
๐ Repo-Health โ if you donโt measure it, you canโt improve it
A monorepo without measurable quality is just a bigger place to
hide regressions. Unit tests, integration tests, version/arch
matrices, and repo-wide coverage are how you
prove that a change to libs/* is safe for
every downstream apps/, projects/, and
services/ consumer โ not hope.
๐งช Unit + integration tests with pytest
Standardize on pytest [29] in every Python leaf:
- ๐ฌ Unit tests โ fast, no network, live next to
the package (
tests/unit/). Required on every PR child pipeline. - ๐ Integration tests โ
tests/integration/against realistic boundaries (Testcontainers, compose fromdockers/compose/, LocalStack, ephemeral DBs). Mark with@pytest.mark.integration; run on PR for touched leaves, and more broadly on a schedule. - ๐ฆ Libs especially โ a libraryโs unit suite is
necessary but not sufficient. Prefer contract / consumer-style
tests that exercise the public API the way
apps/*andservices/*will call it.
# every Python leaf
libs/foo-bar-baz-qux/
โโโ src/foo/bar/baz/qux/
โโโ tests/
โ โโโ unit/
โ โโโ integration/
โโโ pyproject.toml # pytest + pytest-cov config
๐ tox matrices โ guarantee downstream runtimes
Downstream leaves will not all pin the same Python patch or CPU
arch. Use
tox
(+ tox-uv) so each
libs/* (and critical services) proves itself across
the matrix your org actually runs:
# libs/foo-bar-baz-qux/tox.ini (illustrative)
[tox]
env_list = py310, py311, py312, py313
[testenv]
runner = uv-venv-lock-runner
commands =
pytest tests/unit -q --cov=foo.bar.baz.qux --cov-report=xml
# optional factors โ OS / arch via CI matrix, not only tox envs
# e.g. GitHub: strategy.matrix = { python: [...], os: [ubuntu, macos] }
- ๐งฉ PR path โ affected leaf runs
poe test(current default Python) + a lean tox subset if the leaf is a shared lib. - ๐ Full matrix โ
poe toxon sharedlibs/*for every supported CPython, and CI runners for arch/OS (amd64 / arm64, linux / macos) when you ship wheels or multi-arch images. - ๐ก๏ธ Why this matters โ when
services/billingis on 3.11-arm64 andapps/consoleis on 3.12-amd64, a green single-env lib PR is a lie. The matrix is how you make downstream use guaranteed, not assumed.
๐ Nightly: crawl the monorepo, accumulate coverage
PR CI proves the diff. Nightly proves the
estate. Schedule a parent pipeline (GitHub
schedule / GitLab pipeline schedule) that:
- ๐ท๏ธ Crawls every testable leaf under
apps/*,libs/*,projects/*,services/*(discoverpyproject.toml/tox.ini/package.json, etc.). - ๐งช Runs unit (+ optional integration) suites per leaf โ
Python via
poe test/tox, other languages via their leaf task. - ๐ Emits per-leaf coverage artifacts
(
coverage.xml, lcov, โฆ). - ๐งฎ Accumulates into a monorepo rollup
(e.g. merge coverage.py data with
coverage combine, or upload all partials to Codecov/Coveralls/Sonar with a monorepo flag). - ๐ Publishes a trend: total %, per-top-level-dir %, worst leaves, and a diff vs. last night โ fail the nightly (alert; donโt block product merges) if coverage drops beyond a threshold.
# conceptual nightly (forge-agnostic)
discover leaves
for leaf in leaves:
(cd $leaf && poe test --cov) # or poe tox on libs
upload partial coverage artifacts
coverage combine ./coverage-partials/* # or vendor uploader
publish report โ docs portal / coverage dashboard
route anomalies โ CODEOWNERS for failing leaves
flowchart TB
PR["PR / MR"] --> Aff["affected leaves\n+ dependents"]
Aff --> Fast["pytest unit\n+ lean tox"]
Night["Nightly schedule"] --> Crawl["crawl all leaves"]
Crawl --> Full["full suites\n+ tox matrices"]
Full --> Partials["per-leaf coverage.xml"]
Partials --> Rollup["combine / upload\nmonorepo total"]
Rollup --> Detect["anomaly detection"]
Detect --> Owners["alert CODEOWNERS\nof failing leaves"]
Detect --> Dash["dashboard / report\ncauses for debug"]
Reference stack: pytest +
coverage.py
[30]
locally; Codecov / Coveralls / Sonar (or a simple HTML report
published next to the docs portal) for the accumulated nightly
view. Same rule as everything else: one
poe coverage-nightly (or equivalent) that CI calls โ
no snowflake nightly scripts per leaf.
๐จ Alerting CODEOWNERS on nightly anomalies
When the nightly crawler finds something wrong, do not spam
#engineering. Route to the CODEOWNERS of the
failing leaf (and platform only for rollup / infra
symptoms). Ground the policy in Rob Ewaschukโs
My Philosophy on
Alerting
[31]
(widely cited in Google SRE practice): pages should be urgent,
important, actionable, and real โ prefer
symptoms over raw causes, and keep cause detail
on dashboards / in the alert body for debugging.
Map that to Repo-Health nightlies:
- ๐ฉบ Alert on symptoms โ e.g. โleaf
libs/foo-bar-baz-quxnightly is red,โ โmonorepo coverage dropped > N% WoW,โ โtox py312 factor failed for 3 shared libs,โ โintegration suite timed out forservices/billing.โ These are user/platform pain signals for that pathโs owners. - ๐ Attach causes in the payload / dashboard โ failed test names, flake retries, Python version, runner arch, coverage ฮ, last green SHA โ so owners can debug without a second page for every proximate cause.
- ๐ค Route by CODEOWNERS โ resolve
/libs/foo-bar-baz-qux/โ@org/foo-bar-owners(Slack/Teams/PagerDuty/ GitHub Issues). Platform owns rollup symptoms (dockers/, CI templates, crawler itself). - ๐ Donโt page on every cause โ a single flaky test failure is a ticket/dashboard item; a leaf that was green for 14 days and is now red is a page. Err toward removing noisy alerts.
Example anomaly classes the nightly should detect and fan out:
- ๐ฅ Suite regression โ previously green leaf now failing unit / integration / tox env
- ๐ Coverage cliff โ leaf or monorepo total drops beyond threshold
- ๐ Matrix break โ lib fails on a Python/OS/arch still required by downstream apps/services
- โฑ๏ธ Health of the crawler โ nightly itself didnโt finish / couldnโt discover leaves (symptom: โRepo-Health blindโ โ page platform)
- ๐ Flake storms โ same test fails intermittently above a rate (ticket the owners; page only if a release path is blocked)
# conceptual alert routing
for anomaly in nightly_anomalies:
paths = anomaly.affected_paths # e.g. libs/foo-bar-baz-qux/**
owners = resolve_codeowners(paths) # from CODEOWNERS
notify(
owners,
symptom=anomaly.symptom, # "nightly red" / "coverage cliff"
causes=anomaly.debug_bundle, # logs, SHA, matrix cell, โฆ
runbook=docs_url("repo-health"),
)
Observability here is not just dashboards โ it is closed-loop ownership: measure the estate, detect symptoms, page the people who can fix that path, and keep causes visible without turning every log line into a pager event [31].
๐ฅ Blaze / Bazel and Buck โ why we did not start there
Two hyperscale lineages dominate this design space:
- ๐ Google โ Blaze โ Bazel [13] โ hermetic actions, fine-grained target graph, remote caching / remote execution, Starlark rules. Bazel is the open-source Blaze.
- ๐ต Meta โ Buck โ Buck2 [22] โ Metaโs monorepo build system (Buck1 open-sourced earlier; Buck2 is the from-scratch Rust rewrite). Same class of problem: polyglot targets, hermeticity, remote execution (REAPI-compatible), rules in Starlark decoupled from the core [23].
For a hyperscale polyglot monorepo, Bazel or Buck2 is often the correct endgame. Both are the wrong first move for most orgs that just need shared libs + services in one Git repo.
โ Pros (Bazel / Buck2 class)
- ๐งฎ Hermetic, reproducible builds โ inputs hash to outputs; โworks on my machineโ shrinks dramatically.
- โก Incremental + remote cache โ unchanged targets are fetched, not rebuilt; CI and laptops can share a cache.
- ๐ธ๏ธ Precise affected graph โ test/build only what the target graph says changed, across languages.
- ๐ Polyglot-native โ one system for Python, Go, Rust, Java/C++, protos, container images, etc.
- ๐ Buck2-specific upside โ dynamic dependencies (graph can grow mid-build from tool output), rules fully outside the Rust core, strong parallelism story vs Buck1 / vs phase-heavy designs.
โ Cons (same tax either way)
- ๐ง Steep learning curve โ
BUILD/BUCKfiles, Starlark rules, cells/workspaces, and rule ecosystems become a specialization. - ๐งฐ Toolchain takeover โ everyday language UX
(
uv,pytest,cargo test, IDE runners) often needs wrappers; onboarding tax is real. - ๐ฅ Platform bottleneck โ one or two engineers become the only people who can land build changes.
- ๐ธ Ops cost โ remote cache / RBE, rules upgrades, and language-rules churn are ongoing platform work, not a weekend migration.
- ๐ฅ Overkill early โ for dozens of leaves, Blaze/Buck-class hermeticity is a sledgehammer.
- ๐งช Ecosystem maturity tradeoffs โ Bazel has the broader open-source rules surface today; Buck2 is battle-tested at Meta but the public ecosystem / release cadence is younger โ picking either is a multi-year platform bet.
๐ซ Why we did not use Bazel or Buck first
We wanted a monorepo that engineers can extend on day two without
learning a second build language. The uv workspace + poe tasks +
forge-native child pipelines give us path deps, affected CI, and
shared templates with tools people already know. Blaze/Bazel and
Buck/Buck2 stay on the roadmap for when polyglot scale or CI
minutes force hermetic remote execution โ not as the admission
price to have libs/ and services/ in one
Git repo.
Rule of thumb: earn Bazel or Buck2. Start with the graph + CI contracts in this post; graduate to hermetic monorepo builders when the complexity tax is clearly cheaper than the CI bill โ then choose based on your language mix, rules ecosystem, and whether you already have REAPI infrastructure.
๐ณ Shared container assets โ dockers/
Centralize image plumbing so every leaf does not ship a private snowflake Dockerfile:
dockers/
โโโ bases/
โ โโโ python-runtime/Dockerfile # org Python base
โ โโโ go-runtime/Dockerfile
โโโ templates/
โ โโโ python-service/Dockerfile # multi-stage template leaves COPY from
โโโ compose/
โ โโโ local-deps.yml # postgres, redis, localstack, โฆ
โ โโโ mesh-stub.yml
โโโ README.md
Leaves keep a thin Dockerfile (or build args) that
FROM the org base / template. CD rebuilds
dockers/bases/* on change, then rebuilds dependent
apps/* / services/* images.
๐ช Cookiecutters โ scaffold, donโt teach the bolts
The monorepoโs internal contracts (workspaces, CI stubs, naming
maps, docs nodes, CODEOWNERS rows) are platform knowledge.
Downstream users should not need to learn them. Keep golden-path
generators under cookiecutters/ (name is historical โ
the engine can be any of the tools below) and expose them
only through task-runner entrypoints
(poe new-*).
๐ ๏ธ Scaffolding engines โ pick one org-wide
- ๐ช Cookiecutter [26] โ classic Jinja templates; fire-and-forget generation. Simple, ubiquitous, polyglot. Weak at updating already-scaffolded leaves when the template evolves.
- ๐ Copier [27] โ Cookiecutterโs smarter cousin: Jinja templates + answers file + template updates with 3-way merges. Strong default when you want a golden path that can evolve without rewriting every leaf by hand.
- ๐งฌ Projen
[28]
โ project config as code (synthesize
package.json, CI, lint, โฆ from a typed.projenrc). Generated files are managed, not hand-edited โ best when you want โcattle, not petsโ consistency and a central push of config upgrades (popular in TS/CDK worlds; works beyond JS via jsii). - ๐ฆ Other options in the same niche โ Yeoman, Crane, language
CLIs (
cargo new,npm create) wrapped bypoeso the user surface stayspoe new-*regardless of engine.
Recommendation: Copier for polyglot monorepos
that need template upgrades; Cookiecutter if you only need
create-once scaffolding; Projen when most leaves are TS/Node (or
CDK) and you want synthesized, non-editable project config. Do not
mix three engines without a very good reason โ the directory can
still be called cookiecutters/ even if the binary is
copier.
cookiecutters/
โโโ python-lib/ # Copier/Cookiecutter template โ or
โโโ python-service/ # projen project type definitions
โโโ python-project/
โโโ js-app/
โโโ go-service/
โโโ README.md # which poe task โ which template/engine
# root pyproject.toml โ illustrative (swap cookiecutter โ copier as needed)
[tool.poe.tasks.new-python-lib]
help = "Scaffold a namespaced Python lib under libs/"
cmd = "copier copy cookiecutters/python-lib libs"
[tool.poe.tasks.new-python-service]
cmd = "copier copy cookiecutters/python-service services"
[tool.poe.tasks.new-js-app]
cmd = "copier copy cookiecutters/js-app apps"
# if using projen instead:
# [tool.poe.tasks.new-js-app]
# cmd = "projen new --from ./cookiecutters/js-app โฆ"
Each template / project type should emit a complete leaf in one shot:
- ๐ฆ correct directory + org-namespaced package layout
- ๐งพ
pyproject.toml/ language manifest wired into the workspace - ๐งฉ CI child stub (
.gitlab-ci.ymlor GitHub stub +ci.yml) - ๐ starter
docs/node - ๐ค CODEOWNERS snippet / team placeholder
- ๐ณ thin Dockerfile that uses
FROMwithdockers/bases when relevant
UX goal: poe new-python-lib โ answer three prompts โ
open a PR. No wiki spelunking required. Platform owns the
templates; product teams consume the CLIs.
๐ก๏ธ CODEOWNERS, ACL, and merge gates
A monorepo without path-level ownership is a shared writable disk. Every downstream leaf needs an explicit owner set, and the forge must refuse to ship without their approval.
# .github/CODEOWNERS (or .gitlab/CODEOWNERS)
# Default โ platform team
* @org/platform
# Leaf ownership โ required reviewers for that path
/libs/foo-bar-baz-qux/ @org/ml-platform @org/foo-bar-owners
/services/billing/ @org/billing
/apps/console/ @org/console
/dockers/ @org/platform
/cookiecutters/ @org/platform
/iac/ @org/platform @org/sre
/docs/ @org/platform
- ๐ Branch protection / MR approval rules โ require CODEOWNERS review on the default branch; dismiss stale approvals on new pushes; block merge if checks fail.
- ๐ฅ Leaf teams own leaves โ product groups are
listed on their paths; platform owns
dockers/,cookiecutters/, CI templates, and sharedlibs/*that many consumers depend on (or a dedicated libs review group). - ๐ซ No self-merge of critical paths โ especially
iac/, deploy templates, and base images. - ๐ชช ACL beyond Git โ registry push, cloud deploy roles, and secrets access mapped to the same owner teams (OIDC / environment protection rules on GitHub; protected environments on GitLab). CODEOWNERS stops bad merges; cloud ACL stops bad deploys.
- ๐ Document the contract in
docs//CONTRIBUTING.mdโ who owns what, how to request ownership changes, and that unapproved path changes do not ship.
๐ค How AI tools help maintain the repo
Treat AI the same way you treat CI: encoded policy, not vibes. The monorepo should teach agents (and humans) the contracts so scaffolds, refactors, docs, and reviews stay on the golden path. Below is a practical operating model.
flowchart LR
Rules[".cursor/rules\nCLAUDE.md\ndocs/ai/*"]
Author["Authoring agents\nCursor / Claude / โฆ"]
MCP["MCP servers\nforge ยท docs ยท registry"]
PR["PR / MR opened"]
Review["Review agent\npolicy check"]
Humans["CODEOWNERS\nhuman approve"]
Merge["Merge + CD"]
Rules --> Author
MCP --> Author
Author --> PR
PR --> Review
Rules --> Review
MCP --> Review
Review --> Humans
Humans --> Merge
๐ 1. Encode the contracts where agents look first
- ๐
.cursor/rules/โ short Cursor rules for topology, naming (foo-barโfoo/bar), import DAG bans, โusepoe new-*โ donโt hand-roll leaves,โ docs-node requirements, and PR style. - ๐
CLAUDE.md/ Claude project rules โ same invariants for Claude Code / Claude in the IDE so you are not maintaining two contradictory policy worlds. - ๐
docs/+CONTRIBUTING.md+ ADRs โ source of truth; rules should link here, not duplicate novels. - ๐งช
docs/ai/โ versioned prompts and checklists (pr-review.md,scaffold-leaf.md) that both humans and bots run.
๐ ๏ธ 2. Day-to-day maintainer jobs AI is good at
- ๐ช Scaffold leaves โ โadd
libs/acme-billing-clientโ โ agent runspoe new-python-lib(Copier/Cookiecutter/Projen), fills prompts, opens a branch. No free-form file inventing. - ๐ Keep docs honest โ when a public API
changes, agent updates the leaf
docs/node and flags portal nav gaps before merge. - ๐ Propagate template upgrades โ after
cookiecutters/or CI templates change, agent opens MRs that re-sync leaves (especially strong with Copier update / Projen synth). - ๐งน Repo hygiene โ find import-DAG violations,
missing CODEOWNERS rows, orphaned Dockerfiles, stale path
filters, docs-only leaves without
poe docscoverage. - ๐งพ Changelog / migration notes โ draft ADR stubs and contributor-facing migration steps when a shared lib breaks consumers.
- ๐ CI archaeology โ read failing job logs via MCP, propose the minimal fix, re-run the leaf child pipeline.
๐ 3. MCP โ give agents tools, not pastebins
- ๐งฐ Wire MCP servers for the forge (GitHub/GitLab), issue tracker, docs search, and package registry metadata so agents can open MRs, inspect checks, and query ownership without brittle shell one-offs.
- ๐ Scope credentials: read-heavy for review bots; write only for a bot identity that obeys the same ACL as humans.
- ๐งญ Prefer MCP + in-repo rules over stuffing the entire monorepo into a single prompt context window.
๐ต๏ธ 4. Review agents on every PR / MR
- ๐ค On
pull_request/merge_requestopen/sync, run a review agent (Actions / GitLab job, or hosted bots such as Copilot / CodeRabbit-class reviewers) againstdocs/ai/pr-review.md. - Checklist the agent should enforce:
- import DAG + no leafโleaf Python imports
- org namespace naming on new libs
- CI stub +
docs/node present for new leaves - no secrets; Dockerfiles
FROMorg bases - CODEOWNERS path touched has owners listed
- โ Make the result a required check (pass / fail) or at least a blocking โchanges requestedโ comment style โ not a silent Slack side-channel.
- ๐ค Agents never bypass CODEOWNERS โ humans still approve; the bot only fails the check or leaves review comments.
โ ๏ธ 5. Guardrails โ what AI must not do
- ๐ซ Invent a new top-level directory or skip
poe new-*for โspeed.โ - ๐ซ Self-approve or merge using admin tokens.
- ๐ซ Edit
iac/, deploy templates, ordockers/baseswithout the owning team in the loop. - ๐ซ Treat generated Projen/Copier-managed files as free-edit surfaces when the engine owns them.
Bottom line: AI maintains the monorepo by executing and enforcing written contracts โ Cursor/Claude rules for authors, MCP for tool access, PR/MR agents for review โ while ACL + CODEOWNERS remain the hard gate that prevents unapproved changes from shipping.
โ๏ธ IaC next to the graph
Keep Terraform (or Pulumi) under iac/ with the usual
module vs. stack split:
iac/
โโโ modules/ # reusable primitives (network, k8s, bucket, db)
โโโ stacks/
โ โโโ platform/ # shared cluster / mesh / observability
โ โโโ services/ # per-service wiring (IAM, DNS, secrets refs)
โโโ README.md
Hard rule: ๐ no secrets in Git โ remote state + vault / cloud secret manager only. Service deploy pipelines consume outputs; they do not embed credentials.
Reference: Terraform docs [9].
๐ Docs portal โ landing page + aggregate from leaves
Top-level docs/ is not a junk drawer of markdown. It
is the documentation portal: the landing page,
global nav, org ADRs, onboarding, and the glue that mounts every
downstream leafโs docs into one published site.
docs/ # portal root (landing + chrome)
โโโ index.md # landing page
โโโ getting-started.md
โโโ adr/ # architecture decision records
โโโ architecture/ # C4 / Mermaid / D2
โโโ mkdocs.yml # or conf.py โ portal config
โโโ overrides/ # theme chrome
libs/foo-bar-baz-qux/docs/ # downstream docs node
โโโ index.md
โโโ api/ # or rely on autodoc / mkdocstrings
services/billing/docs/
โโโ index.md
โโโ runbooks/
โโโ diagrams/
projects/claims-etl/docs/
โโโ index.md
๐ข CD builds docs on the fly and publishes
Do not ask humans to copy leaf markdown into
docs/. A docs CD child node (same parent/child pattern
as CI) should:
- ๐ Discover docs nodes under
apps/*/docs,libs/*/docs,projects/*/docs,services/*/docs. - ๐งฉ Generate / assemble the portal nav (mount each leaf under
e.g.
/libs/foo-bar-baz-qux/,/services/billing/). - โ๏ธ Build API reference from docstrings where configured (autodoc / mkdocstrings).
- ๐ฆ Emit a static site artifact.
- ๐ Publish on merge to the default branch (GitHub Pages, GitLab Pages, S3+CloudFront, or Read the Docs).
Path filters: a leaf-only docs change rebuilds that leafโs
section (or a cheap full static rebuild โ usually fine). A portal
chrome change under top-level docs/ rebuilds the
whole site. Wire this as poe docs locally and the
same task in the docs CD child.
flowchart LR
subgraph leaves["Downstream docs nodes"]
L1["libs/*/docs"]
L2["services/*/docs"]
L3["apps/*/docs"]
L4["projects/*/docs"]
end
Portal["docs/\nlanding + nav + ADRs"]
CD["Docs CD child\npoe docs โ build"]
Site["Published site\nPages / S3 / RTD"]
L1 --> CD
L2 --> CD
L3 --> CD
L4 --> CD
Portal --> CD
CD --> Site
๐ Python docs tooling โ options
Pick one stack org-wide. Mixing Sphinx in half the leaves and MkDocs in the other half is how portals die.
- ๐ MkDocs
+ Material for
MkDocs
+ mkdocstrings
[14]
[15]
[16]
โ markdown-first portal, excellent UX, easy multi-section nav.
mkdocstringspulls Python API docs from docstrings. Best default for a monorepo landing page that aggregates many leaves. - ๐ Sphinx + autodoc + MyST + Furo [17] [18] [24] โ deepest Python API / cross-ref story; steeper config. Prefer when you need rich intersphinx, complex API trees, or already live in Sphinx. Live example: docs.slickml.com [25] (Sphinx + Furo).
- ๐ pdoc [19] โ low-ceremony API sites from docstrings. Great per-lib artifact; weaker as the org-wide portal unless you wrap it.
- ๐ Jupyter Book
[20]
โ best when
projects/*are notebook-heavy; usually a section inside the portal, not the whole portal. - โ๏ธ Hosting โ GitHub Pages / GitLab Pages for forge-native CD; Read the Docs [21] if you want versioned docs / PR previews without running your own Pages bucket.
Recommendation: MkDocs Material at
docs/ as the portal, mkdocstrings pointed at each
libs/* package path, leaf narrative docs living under
each leafโs docs/, assembled by
poe docs in CD. Use Sphinx only if API cross-refs
become the product. Keep Jupyter Book as an opt-in section for
research-shaped projects/*.
๐จ Diagrams as code (inside the portal)
Architecture that only exists in slide decks drifts. Prefer text-native diagram tools in portal + leaf docs:
- ๐งโโ๏ธ Mermaid [10] โ flowcharts, C4-ish graphs, sequences; renders in GitHub/GitLab markdown and MkDocs plugins.
- โ๏ธ D2 [11] โ declarative layouts with stronger aesthetic defaults for system diagrams.
- ๐๏ธ Structurizr / C4 [12] โ when you need formal C4 model โ views.
Minimum bar: every new services/* leaf ships a Mermaid
(or D2) context diagram in its docs/ before the first
deploy template lands.
๐บ๏ธ Rollout sequence
- ๐งพ Freeze the directory contracts + import DAG in an ADR.
- ๐งฑ Stand up root
uvworkspace + shared ruff/mypy/poe/tox config. - ๐ช Land
cookiecutters/+poe new-*tasks so new leaves are never hand-rolled. - ๐ค Turn on CODEOWNERS + branch protection / MR approval rules before the repo has many writers.
- ๐ฆ Extract 2โ3 already-shared modules into
libs/using hyphenated org namespaces (acme-platform-loggingโacme/platform/logging). - ๐ Stand up the
docs/portal landing page +poe docsCD publish; require adocs/node on each new leaf. - ๐ค Add
.cursor/rules/ Claude rules + a PR/MR review agent check wired toCONTRIBUTING.md. - ๐ Require pytest on every leaf; tox matrices on shared libs; schedule a nightly coverage crawl for the whole monorepo.
- ๐ฐ๏ธ Migrate one microservice into
services/with path deps + a template pipeline. - ๐งฌ Add one domain
projects/leaf that consumes the same libs. - โ๏ธ Land baseline
iac/modules+ one stack wired to that service. - ๐ Only then mass-migrate โ with ownership, scaffolds, and affected-test CI already green.
๐ช My 2 cents
The monorepo pays rent only if the graph is real. Folder names alone are cosplay.
Make these contracts non-optional:
- ๐ Path dependencies + an enforceable import DAG
- ๐งฉ Leaf CI child nodes under shared parent templates
- ๐ท๏ธ Org-namespaced libs (
foo-barโfoo/bar) - ๐ช
poe new-*scaffolds (Copier / Cookiecutter / Projen) so teams never hand-roll leaves - ๐ First-class
docs/portal aggregated from every leaf - ๐ก๏ธ CODEOWNERS + ACL โ unapproved path changes do not ship
- ๐ณ Shared
dockers/bases, not snowflake Dockerfiles - ๐ Repo-Health โ pytest, tox matrices, nightly coverage crawl, symptom-based alerts to CODEOWNERS
Tooling posture:
- โก Start with
uv+poe+ forge-native CI - ๐ฅ Earn Blaze/Bazel or Buck/Buck2 later โ donโt pay that tax on day one
- ๐ค Encode the same contracts for AI (Cursor/Claude rules, MCP, PR/MR review agents)
๐ฑ Treat the repository itself as something you design โ not something that accumulates.
๐ References
- Dan Hipschman, โOur Python Monorepo,โ Opendoor / Open House โ inspiration for this design, medium.com/opendoor-labs/our-python-monorepo-d34028f2b6fa
- Astral, uv documentation, docs.astral.sh/uv
- Astral, Ruff documentation, docs.astral.sh/ruff
- mypy, mypy โ static typing for Python, mypy-lang.org
- Microsoft, Pyright, github.com/microsoft/pyright
- Nat Knight et al., Poe the Poet, poethepoet.natn.io
- tox, tox documentation, tox.wiki
- tox-dev, tox-uv, github.com/tox-dev/tox-uv
- HashiCorp, Terraform documentation, developer.hashicorp.com/terraform/docs
- Mermaid, documentation, mermaid.js.org
- Terrastruct, D2, d2lang.com
- Structurizr, C4 model tooling, structurizr.com
- Bazel (open-source Blaze), bazel.build
- MkDocs, www.mkdocs.org
- Material for MkDocs, squidfunk.github.io/mkdocs-material
- mkdocstrings, mkdocstrings.github.io
- Sphinx, sphinx-doc.org
- MyST Parser, myst-parser.readthedocs.io
- pdoc, pdoc.dev
- Jupyter Book, jupyterbook.org
- Read the Docs, readthedocs.org
- Buck2 (Meta), buck2.build
- Meta Engineering, โBuild faster with Buck2: Our open source build system,โ engineering.fb.com/โฆ/buck2-open-source-large-scale-build-system
- Furo (Sphinx theme), pradyunsg.me/furo
- SlickML documentation (Sphinx + Furo example), docs.slickml.com
- Cookiecutter, github.com/cookiecutter/cookiecutter
- Copier, github.com/copier-org/copier / copier.readthedocs.io
- Projen, github.com/projen/projen / projen.io
- pytest, docs.pytest.org
- Coverage.py, coverage.readthedocs.io
- Rob Ewaschuk, โMy Philosophy on Alerting,โ docs.google.com/document/d/199PqyG3UsyXlwieHaqbGiWVa8eMWi8zzAn0YfcApr8Q