Monorepo vs Polyrepo - Workspaces, Package Boundaries, Versioning & CI Fan-out
Monorepo vs polyrepo trade-offs; workspaces as the base layer and task runners vs build systems on top; boundaries (tags, visibility, exports, CODEOWNERS) with a runnable boundary + CI fan-out + owners demo; fixed vs independent versioning with changesets (runnable); CI fan-out by scale from Turborepo-size repos to Google-scale.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
Monorepo vs monolith?
Answer
A monorepo is where code lives. A monolith is how it deploys.
L2
What do workspaces give you?
Answer
One install and lockfile, local linking between packages, and running scripts across packages.
L3
What does a monorepo need to stay fast?
Answer
A dependency graph for affected runs, caching, parallel execution and boundaries.
L4
How do you enforce architecture in a monorepo?
Answer
Tag rules or visibility, public entry points, and code owners.
L5
Why does a change to a shared package cost the most?
Answer
It fans out to every dependent, 5 of 5 packages and 15 CI jobs in the demo.
L6
Fixed vs independent versioning?
Answer
Fixed bumps everything together. Independent bumps only changed packages and their dependents.
L7
When would you pick a polyrepo?
Answer
When projects rarely change together or need separate access, stacks, organizations or cadences.
Failure modes
A boundary violation slips into a domain package
Without tag or visibility rules, admin-web can import billing-domain and the graph tangles.
Root changes invalidate everything
Lockfile and shared config edits mark every package affected and drain the cache.
Broad code owners create a review bottleneck
Owners on very wide paths are pulled into every PR.
Misconceptions
A monorepo means one deployable.
A monorepo can hold hundreds of independently deployed services.
A monorepo needs Bazel.
Most teams run workspaces plus Turborepo or Nx on standard git.
Fixed versioning is always simpler for consumers.
For unrelated libraries it produces no-change releases and erodes trust in version numbers.
Interviewer traps
Moving to a monorepo without graph-aware CI.
CI time grows with every package until people skip tests.
Picking a polyrepo for tightly coupled services.
Every API change becomes a multi-repo dance with version-compatibility bugs at integration time.
Design scenario
Same prompt for every reader.
Requirements
Atomic cross-service changes, CI under ten minutes on most PRs, and clear ownership of shared code.
Failure assumptions
- Some services import internal helpers from another team.
- Shared libraries change weekly.
- One team needs separate read access for compliance.
Constraints
- Keep standard git hosting.
- Published libraries keep semver for outside consumers.
Prompt
Five teams own twelve TypeScript services and four shared libraries spread across sixteen repos. Decide whether to consolidate and how.
API
Which entry points does each shared package export?
Data
Fixed or independent versions for the four libraries, and why?
Architecture
Which layer gives affected runs and caching, and where do boundaries and code owners live?
Overview
A monorepo is a source-control decision: many projects live in one repository and change together in atomic commits. It is not the same as a monolith (one deployable), and it does not require one build tool. What it does require is everything earlier pages describe: a dependency graph so CI can test only what changed, caching so rebuilding shared code is cheap, and boundaries so "everything can import everything" does not turn into a tangle. Polyrepos make the opposite trade: strong isolation and simple tooling per repo, paid for with cross-repo version coordination. Neither is universally better; the right answer depends on how often your projects change together.
Monorepo vs polyrepo
| Concern | Monorepo | Polyrepo |
|---|---|---|
| Cross-project change | One atomic commit and one review | Coordinated PRs in several repos, released in order |
| Sharing code | Import directly from a sibling package | Publish a package, then bump consumers |
| Dependency versions | Can enforce one version per dependency (one-version rule) | Each repo drifts independently |
| CI | Needs affected detection and caching or it gets slow | Each repo's CI is small and simple |
| Access control | Path-based (code owners, visibility rules); fine-grained read restriction is harder | Per-repo permissions are natural |
| Tooling | Workspaces plus a task runner or build system | Standard per-language tooling |
| Blast radius | A bad change to a shared package can break many consumers at once (but CI sees it) | Breakage appears later, when consumers upgrade |
| Git scale | Large histories need sparse checkout, partial clone or a virtual filesystem | Each clone is small |
Well-known large examples exist at both extremes. Google's Potvin and Levenberg (CACM, 2016) describe a single repository serving most of the company, with trunk-based development and custom tooling built around it; Meta has written about a similar approach. Most teams using Turborepo or Nx operate at a very different scale: a handful to a few hundred packages on standard git, where the problems are mostly CI time and dependency hygiene, not version-control scaling.
Decisions
- 1
Step 1: developer opens a PR touching billing-domain
- nextStep 2: boundary check on the package graph
- 2
Step 2: boundary check on the package graph
- nextStep 3: any import breaks a tag or visibility rule?
- ?
Step 3: any import breaks a tag or visibility rule?
- nextFailure path: lint fails, PR blocked until the dependency is removed or the rule changed
- nextStep 4: compute affected packages from the diff
- 4
Failure path: lint fails, PR blocked until the dependency is removed or the rule changed
- 5
Step 4: compute affected packages from the diff
- nextStep 5: fan out CI jobs only for affected packages, reusing cached results
- 6
Step 5: fan out CI jobs only for affected packages, reusing cached results
- nextStep 6: request reviews from code owners of touched paths
- 7
Step 6: request reviews from code owners of touched paths
- nextStep 7: all affected jobs green and owners approve?
- ?
Step 7: all affected jobs green and owners approve?
- nextFailure path: fix and push, unchanged packages stay cached
- nextStep 8: merge atomically, then release changed packages per versioning policy
- 9
Failure path: fix and push, unchanged packages stay cached
- 10
Step 8: merge atomically, then release changed packages per versioning policy
Lesson map
Monorepo vs Polyrepo - Workspaces, Package Boundaries, Versioning & CI Fan-out
Monorepo vs polyrepo trade-offs; workspaces as the base layer and task runners vs build systems on top; boundaries (tags, visibility, exports, CODEOWNERS) with a runnable boundary + CI fan-out + owners demo; fixed vs independent versioning with changesets (runnable); CI fan-out by scale from Turborepo-size repos to Google-scale.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB a["Step 1: developer opens a PR touching billing-domain"] b["Step 2: boundary check on the package graph"] c["Step 3: any import breaks a tag or visibility rule?"] x["Failure path: lint fails, PR blocked until the dependency is removed or the rule changed"] d["Step 4: compute affected packages from the diff"] e["Step 5: fan out CI jobs only for affected packages, reusing cached results"] f["Step 6: request reviews from code owners of touched paths"] g["Step 7: all affected jobs green and owners approve?"] y["Failure path: fix and push, unchanged packages stay cached"] h["Step 8: merge atomically, then release changed packages per versioning policy"] a -->|continues| b b -->|continues| c c -->|continues| x c -->|continues| d d -->|continues| e e -->|continues| f f -->|continues| g g -->|continues| y g -->|continues| h
Decision chart: one repo or many?
Decisions
- 1
A
- nextPolyrepo or separate repos per product
- nextStep 2: do they need separate read access or separate owners outside your org?
- 2
Polyrepo or separate repos per product
- ?
Step 2: do they need separate read access or separate owners outside your org?
- nextPolyrepo or separate repos per product
- nextStep 3: mostly one language ecosystem with package managers you already use?
- ?
Step 3: mostly one language ecosystem with package managers you already use?
- nextMonorepo on workspaces plus a task runner (Turborepo or Nx style)
- nextStep 4: thousands of targets or heavy native builds?
- 5
Monorepo on workspaces plus a task runner (Turborepo or Nx style)
- ?
Step 4: thousands of targets or heavy native builds?
- nextMonorepo with a build system and remote execution (Bazel, Buck2 or Pants style)
- nextMonorepo with a polyglot task runner, revisit as it grows
- 7
Monorepo with a build system and remote execution (Bazel, Buck2 or Pants style)
- 8
Monorepo with a polyglot task runner, revisit as it grows
One repository or many?
Prefer
Monorepo with a graph, cache and boundaries
When projects change together, one atomic commit replaces a multi-repo release dance.
- Affected runs keep CI proportional to the change.
- Tag rules and visibility keep the graph shallow.
- Code owners route reviews for shared code.
Alternative
Polyrepo per project
Strong isolation and simple per-repo tooling, paid for with cross-repo coordination.
- Shared code ships as published packages.
- Each repo's dependencies drift independently.
- Breakage appears later, when consumers upgrade.
One PR through a healthy monorepo
Diagram 1 condensed. The runnable demo models the boundary check, fan-out and owners.
- 1
Check boundaries
An import that breaks a tag or visibility rule fails the PR. - 2
Compute affected packages
Only packages downstream of the diff get CI jobs. - 3
Request code owners
Owners of touched paths must approve. - 4
Merge atomically and release
Changed packages are versioned by the chosen policy.
Workspaces: the common base layer
Package-manager workspaces (npm, Yarn, pnpm, Bun; Cargo workspaces; Go workspaces; Gradle multi-project builds) are the base layer most monorepos sit on. They share three ideas:
- One install, one lockfile for the whole repo, so every package resolves dependencies consistently.
- Local linking: a package that depends on a sibling gets a link to its source instead of a registry download (pnpm and Yarn express this with the
workspace:protocol). - Running scripts across packages, usually without understanding task dependencies or caching.
| Layer | Examples | Adds |
|---|---|---|
| Workspaces | npm, Yarn, pnpm, Bun, Cargo, Go workspaces | Shared install, local links, one lockfile |
| Task runner on top | Turborepo, Nx, Lage, moon | Task graph, caching, affected runs, parallelism |
| Build system instead | Bazel, Buck2, Pants, Please | Action-level graph, sandboxing, remote execution, polyglot builds |
Turborepo deliberately stays a thin layer over the package manager's workspaces. Nx can work the same way and also offers plugins that infer tasks and a project graph from tool configs. Bazel and Buck2 replace much of the language tooling with their own rules.
Boundaries: keeping the graph healthy
A monorepo makes every package importable, so boundaries must be enforced by tooling rather than by repo walls.
| Mechanism | How it works | Example tools |
|---|---|---|
| Tag constraints | Projects carry tags like scope:billing or type:ui; a lint rule allows only certain tag-to-tag edges | Nx @nx/enforce-module-boundaries |
| Visibility | Each target declares who may depend on it | Bazel and Buck2 visibility |
| Public entry points | Only an index or exports map is importable; deep imports fail | package.json exports, TypeScript project references |
| Ownership | Paths map to owning teams for mandatory review | GitHub and GitLab CODEOWNERS |
| Dependency direction | Layering rules: apps depend on domain, domain on utils, never the reverse | Tag rules, architecture lint tools |
Versioning: fixed vs independent
Publishing many packages from one repo raises the question of version numbers.
| Fixed (lockstep) | Independent | |
|---|---|---|
| Rule | All packages share one version; any change bumps all | Each package has its own version |
| Pros | Simple to communicate ("use 3.2 of everything"); compatibility is obvious | Consumers only see bumps for packages that changed |
| Cons | Noise releases of unchanged packages | Compatibility matrix gets harder to reason about |
| Typical users | Framework families shipped together | Libraries with independent consumers |
| Tooling | Changesets fixed groups, Lerna fixed mode, Nx Release | Changesets default, Lerna independent mode, Nx Release |
Changesets popularized intent files: each PR adds a small markdown file stating which packages need which bump and why; at release time the tool aggregates them into version bumps and changelogs, and bumps internal dependents whose ranges need updating.
// Fixed vs independent versioning, driven by changeset-style intent files.
// A changeset says "package X needs a patch/minor/major bump because ...".
// Independent mode also bumps internal dependents (a patch) so their published ranges move.
type Bump = "patch" | "minor" | "major";
const order: Bump[] = ["patch", "minor", "major"];
const max = (a: Bump, b: Bump): Bump => (order.indexOf(a) >= order.indexOf(b) ? a : b);
const versions: Record<string, string> = { core: "1.4.2", react: "1.4.2", cli: "1.4.2", docs: "1.4.2" };
const dependents: Record<string, string[]> = { core: ["react", "cli"], react: [], cli: [], docs: [] };
const changesets: { pkg: string; bump: Bump; note: string }[] = [
{ pkg: "core", bump: "minor", note: "add retry option" },
{ pkg: "cli", bump: "patch", note: "fix --help text" },
];
function inc(v: string, b: Bump): string {
const [ma, mi, pa] = v.split(".").map(Number);
return b === "major" ? `${ma + 1}.0.0` : b === "minor" ? `${ma}.${mi + 1}.0` : `${ma}.${mi}.${pa + 1}`;
}
// Fixed (lockstep): one version for the whole group, bumped by the largest change.
const groupBump = changesets.map((c) => c.bump).reduce(max);
const fixed = Object.fromEntries(Object.keys(versions).map((p) => [p, inc(versions[p], groupBump)]));
// Independent: each package bumps by its own changesets; dependents of a bumped package get a patch.
const bumps = new Map<string, Bump>();
for (const c of changesets) bumps.set(c.pkg, bumps.has(c.pkg) ? max(bumps.get(c.pkg)!, c.bump) : c.bump);
for (const [p] of [...bumps]) for (const d of dependents[p]) if (!bumps.has(d)) bumps.set(d, "patch");
const independent = Object.fromEntries(
Object.keys(versions).map((p) => [p, bumps.has(p) ? inc(versions[p], bumps.get(p)!) : versions[p]]),
);
console.log("package before fixed independent");
for (const p of Object.keys(versions)) {
console.log(`${p.padEnd(8)} ${versions[p].padEnd(8)} ${fixed[p].padEnd(8)} ${independent[p]}`);
}
console.log("fixed: docs moved with no change; independent: docs untouched, react got a dependent patch");Output:
package before fixed independent
core 1.4.2 1.5.0 1.5.0
react 1.4.2 1.5.0 1.4.3
cli 1.4.2 1.5.0 1.4.3
docs 1.4.2 1.5.0 1.4.2
fixed: docs moved with no change; independent: docs untouched, react got a dependent patchExpectedpackage before fixed independent core 1.4.2 1.5.0 1.5.0 react 1.4.2 1.5.0 1.4.3 cli 1.4.2 1.5.0 1.4.3 docs 1.4.2 1.5.0 1.4.2 fixed: docs moved with no change; independent: docs untouched, react got a dependent patch
Press Run. Snippets must be self-contained — no network, files, or native modules.
Apps that are deployed rather than published often need no semver at all: the commit SHA or image digest is the version.
CI fan-out at different scales
| Scale | Typical approach | Bottleneck |
|---|---|---|
| A few packages | Run everything; maybe cache dependencies | None worth optimizing |
| Tens to hundreds of packages | Workspaces + Turborepo or Nx: affected runs, remote cache, parallel jobs or distributed task execution | Cache hit rate and shared-package churn |
| Thousands of targets, many languages | Bazel, Buck2 or Pants with remote execution | Graph analysis time, remote execution capacity |
| Company-wide single repo | Custom VCS and build infrastructure, trunk-based development, presubmit at scale | Version control itself, test selection |
Runnable: boundary checks, CI fan-out and owners
The script models tag-based dependency rules (in the style of Nx depConstraints or Bazel visibility), then computes which packages and jobs a change triggers and who must review it.
"""Monorepo guard rails: boundary rules plus CI fan-out by the affected graph.
Boundary rules are modelled on tag constraints (Nx depConstraints) and allow-lists
(Bazel visibility): a dependency edge is legal only if the target's tags are allowed
for the source. Fan-out runs CI jobs only for affected packages and lists owners to review.
"""
packages = {
# name: (tags, deps, owners)
"shared-utils": ({"scope:shared", "type:util"}, [], ["@platform"]),
"shared-ui": ({"scope:shared", "type:ui"}, ["shared-utils"], ["@design-sys"]),
"billing-domain": ({"scope:billing", "type:domain"}, ["shared-utils"], ["@billing"]),
"billing-web": ({"scope:billing", "type:app"}, ["billing-domain", "shared-ui"], ["@billing"]),
"admin-web": ({"scope:admin", "type:app"}, ["shared-ui", "billing-domain"], ["@admin"]),
}
# source tag -> tags its dependencies may carry
rules = {
"scope:shared": {"scope:shared"},
"scope:billing": {"scope:billing", "scope:shared"},
"scope:admin": {"scope:admin", "scope:shared"},
"type:app": {"type:domain", "type:ui", "type:util"},
"type:domain": {"type:util"},
"type:ui": {"type:util"},
"type:util": {"type:util"},
}
def violations():
out = []
for src, (tags, deps, _) in packages.items():
for dep in deps:
dtags = packages[dep][0]
for t in tags:
allowed = rules.get(t, set())
prefix = t.split(":")[0]
if not any(d in allowed for d in dtags if d.startswith(prefix)):
out.append(f"{src} -> {dep} breaks rule for {t}")
return out
print("boundary check:")
for v in violations() or ["ok"]:
print(" ", v)
def affected(changed_pkgs):
seen = set(changed_pkgs); grew = True
while grew:
grew = False
for p, (_, deps, _) in packages.items():
if p not in seen and any(d in seen for d in deps):
seen.add(p); grew = True
return sorted(seen)
for change in (["billing-domain"], ["shared-utils"]):
aff = affected(change)
jobs = [f"{p}:{t}" for p in aff for t in ("lint", "test", "build")]
owners = sorted({o for p in change for o in packages[p][2]})
print(f"change in {change[0]}: {len(aff)}/{len(packages)} packages affected -> {len(jobs)} CI jobs; required reviewers {owners}")
print(" affected:", ", ".join(aff))Output:
boundary check:
admin-web -> billing-domain breaks rule for scope:admin
change in billing-domain: 3/5 packages affected -> 9 CI jobs; required reviewers ['@billing']
affected: admin-web, billing-domain, billing-web
change in shared-utils: 5/5 packages affected -> 15 CI jobs; required reviewers ['@platform']
affected: admin-web, billing-domain, billing-web, shared-ui, shared-utilsThe two fan-out lines show the core monorepo economics: a leaf-ish domain change touches 3 of 5 packages, while a change to the most shared package touches everything. That is why shared foundations deserve the strictest review and the most stable APIs.
What happens if you choose otherwise
- Monorepo without a graph-aware tool: CI time grows with every package until people start skipping tests.
- Monorepo without boundaries: the dependency graph turns into a hairball, every change affects everything, and caching stops helping.
- Polyrepo for tightly coupled services: every API change needs a multi-repo dance and version-compatibility bugs appear at integration time.
- Fixed versioning for unrelated libraries: consumers get frequent "no changes" releases and lose trust in version numbers.
Pitfalls
- Root-level changes (lockfile, shared config) invalidate everything; batch dependency upgrades deliberately.
- One-version policies are healthy but force coordinated upgrades; plan them like migrations.
- Git performance matters at scale: use partial clone, sparse checkout and shallow CI fetches before reaching for a different VCS.
- Code owners on very broad paths turn every PR into a review bottleneck.
Interview Q&A
Monorepo vs monolith?
Answer
A monorepo is about where code lives (one repo); a monolith is about how it is deployed (one unit). A monorepo can contain hundreds of independently deployed services.
What does a monorepo need to stay fast?
Answer
A dependency graph for affected detection, caching (ideally remote), parallel or distributed task execution, and boundaries that keep the graph shallow.
How do you enforce architecture in a monorepo?
Answer
Tag-based lint rules or build-system visibility for dependency direction, public entry points for packages, and code owners for review of shared code.
Fixed vs independent versioning?
Answer
Fixed bumps all packages together, simple but noisy. Independent bumps only changed packages and their dependents, precise but harder to reason about as a set.
When would you pick a polyrepo?
Answer
When projects rarely change together, need separate access control, use unrelated stacks, or belong to separate organizations or release cadences.
What do package-manager workspaces give you, and what do they not?
Answer
One install and lockfile, local links between packages, and scripts across packages. They usually do not understand task dependencies or caching.
Why do shared foundation packages deserve the strictest review?
Answer
A change there fans out to every dependent. In the demo a shared-utils change touches 5 of 5 packages and 15 CI jobs.
How do changesets work?
Answer
Each PR adds a small file stating which packages need which bump and why. At release time the tool aggregates them into version bumps and changelogs and bumps dependents whose ranges need it.
Check yourself
List the packages in a repo you know and tag each with a scope and type. Write two dependency rules, then find one existing import that would break them.
Elsewhere in the library
These pages stay as they are. This lesson only points at them: Git — Branching, PR Hygiene, Bisect, Worktrees & Hooks, Git — Everyday Commands, Rebase vs Merge & Safe History, Pipeline Anatomy — Stages, Gates, Environments & Promotion, Terraform — Modules, Composition & Versioning, TypeScript — Modules, Emit, Strictness, Declaration Files & Tooling.