Overview
A monorepo holds several shippable artifacts in one git repository, so one PR can change a shared library and every consumer together. Done badly, it becomes a CI bottleneck where every change rebuilds everything. Adopt one for a concrete reason, start with workspaces, and add a build graph only when CI hurts. The single-repo default is in project-structure; the head-to-head is monorepo-vs-polyrepo.
Adopt a monorepo for a concrete reason
Default to separate repos. Choose a monorepo when at least one holds:
- Two or more projects share a library that changes in lockstep with them, and coordinated cross-repo releases cost more than shared CI.
- Atomic refactors across services are routine, such as renaming a shared type or changing a wire format.
- The team is small enough that one PR can land everywhere without several blocking reviewers.
“A large company uses one” is not a reason; those companies run dedicated build infrastructure.
Start with workspaces
Native workspaces are the cheapest on-ramp. Add a build tool only when they stop being enough.
- pnpm:
pnpm-workspace.yamllists packages, andpnpm installlinks them. Use it unless the repo is already on Yarn. - npm and Yarn workspaces: work, with the same
workspacesfield inpackage.json. - Cargo: a root
Cargo.tomlwith[workspace]andmembers = [...]. - Go:
go.worklists modules for local multi-module work. Go’s documentation advises against committing it in general, since it describes one developer’s combination of modules; commit it only when the modules are developed exclusively with each other.
Add a build graph when CI hurts
When CI time grows, add a task runner that understands the dependency graph and caches outputs.
- Turborepo:
turbo.json, remote caching through Vercel or self-hosted. Good balance for Node repos (turborepo-vs-nx). - Nx: generators, code mods, a project graph UI, and support for non-JavaScript projects.
- Bazel, Buck2, Pants: polyglot and hermetic, slow to adopt. Worth it with a dedicated build engineer.
Adopt in order: workspaces, then Turborepo or Nx, then Bazel. Jumping to Bazel for a handful of packages is a footgun.
Build only what changed
A large monorepo cannot rebuild everything on each push.
- Limit work to touched packages:
turbo run build --filter="...[origin/main]"or Nx’s affected commands. - Cache build outputs by input hash, so a no-op rebuild is a cache hit.
- Run tests across parallel CI jobs with a matrix (github-actions).
- Tag slow integration tests so PRs run the fast path and a scheduled job runs the rest.
Restore team boundaries with CODEOWNERS
A monorepo erases natural team boundaries; CODEOWNERS puts them back, per package directory.
/packages/billing/ @org/payments-team
/apps/admin-dashboard/ @org/admin-team
Without it, anyone reviews anything and quality drifts package by package. The broader pattern is in large-codebase.
Budget for the costs
- CI complexity. Sharding, caching, and matrices become a codebase you maintain.
- Single-team gravity. The repo ends up owned by whoever fixes its build first.
- Tool lock-in. A bug in the build tool blocks everyone; check its maintenance health first.
- Slow git. A large repo makes
git statusslow. Enablecore.fsmonitor, and use partial clone or sparse checkout. - IDE load. TypeScript project references help (typescript-tsconfig).
Decide with one question
Ask whether a typical change touches one package or many. One package: separate repos win on independent CI, ownership, and releases. Many packages: a monorepo wins on atomic refactors. If the answer is “depends,” start with separate repos and merge once shared-library pain becomes daily pain.