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.yaml lists packages, and pnpm install links them. Use it unless the repo is already on Yarn.
  • npm and Yarn workspaces: work, with the same workspaces field in package.json.
  • Cargo: a root Cargo.toml with [workspace] and members = [...].
  • Go: go.work lists 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 status slow. Enable core.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.