Guide

Monorepo architecture explained

A monorepo stores many related projects — apps, libraries, services, and tooling — in a single version-controlled repository. Google, Meta, and Microsoft run some of the largest codebases on earth this way. The appeal is straightforward: atomic cross-package refactors, shared standards, one place to search, and dependency versions that cannot drift apart silently. The cost is equally real: without deliberate structure, build times balloon, ownership blurs, and a junior engineer's typo in a shared utility can break twelve deployables at once. Monorepo architecture is not "put everything in one Git folder." It is a set of conventions — workspace layout, dependency graphs, build orchestration, CI policies, and team boundaries — that let many teams ship from one trunk without stepping on each other. This guide covers when monorepos win over polyrepos, how tooling like Nx, Turborepo, and Bazel keeps CI fast, versioning strategies, and how monorepos relate to (but are not the same as) microservices architecture and clean architecture inside each package.

Monorepo vs polyrepo vs monolith

Three terms get conflated constantly. Separating them prevents expensive mistakes.

  • Monolith (deployment) — one deployable artifact (one binary, one server process). Can live in a monorepo or a polyrepo.
  • Monorepo (source control) — one Git repository containing multiple packages or services. Deployables may still be independent.
  • Polyrepo (source control) — each service or library in its own repository, often with separate CI and release cadences.

A team can run five microservices from one monorepo (five deployables, one repo) or five microservices from five polyrepos. The monorepo decision is about how you organize source and coordinate change, not about whether you split runtime boundaries. Many successful products stay a modular monolith in a monorepo for years before any service extraction.

Polyrepos shine when teams are truly autonomous, legal boundaries require separate access control, or open-source packages need independent release cycles. Monorepos shine when shared types, design systems, and platform libraries change weekly and cross-repo version bumps would grind velocity to a halt.

When a monorepo makes sense

Monorepos are a coordination technology. They pay off when coordination cost exceeds tooling cost.

Strong signals for a monorepo

  • Shared libraries dominate churn — UI kits, API clients, auth middleware, and protobuf definitions change together with consuming apps.
  • Atomic refactors are frequent — renaming a field in a shared type should update every consumer in one commit, not twelve pull requests across repos.
  • Consistent tooling matters — one ESLint config, one TypeScript version, one test runner; drift across repos creates subtle production bugs.
  • Small platform team supports many product teams — central visibility into who depends on what prevents breaking changes.

Signals to stay polyrepo or split out

  • Teams rarely touch shared code and want independent release trains.
  • Regulatory or contractual isolation requires separate repo access.
  • CI cannot be made fast even with caching (very large heterogeneous stacks with poor graph tooling).
  • Open-source libraries need community contribution workflows separate from proprietary apps.

Workspace layout and package boundaries

A typical JavaScript/TypeScript monorepo uses workspace protocols (pnpm-workspace.yaml, npm workspaces, or Yarn Berry) with packages under apps/ and packages/:

  • apps/web, apps/mobile, apps/api — deployable surfaces
  • packages/ui, packages/utils, packages/config-eslint — shared libraries and tooling
  • tools/ — internal CLIs, codegen scripts, migration helpers

Boundaries are enforced with package manager rules (no reaching into another package's src/), TypeScript project references, and lint rules banning deep imports. Treat each package like a mini bounded context from domain-driven design: public API via index.ts exports, internals hidden, explicit dependency direction (apps depend on packages; packages do not depend on apps).

Anti-pattern: a "utils" package that grows into a junk drawer every team dumps into. Split by domain (packages/billing, packages/auth) or by layer (packages/domain, packages/infra) before the graph becomes unmaintainable.

Build orchestration and the dependency graph

Naive monorepos run npm test in every package on every commit. That does not scale. Modern orchestrators model a dependency graph — which packages import which — and schedule builds topologically with caching.

Popular tools

  • Turborepo — lightweight task runner with remote caching; excellent for JS/TS monorepos; minimal config.
  • Nx — graph visualization, generators, affected commands, plugins for many frameworks; stronger opinions and enterprise features.
  • Bazel — hermetic, language-agnostic builds; steep learning curve; used at Google scale; overkill for small teams.
  • Lerna — historically versioning-focused; often paired with Nx or Turborepo today for publishing.
  • Rush + pnpm — Microsoft's opinionated monorepo manager for very large JS estates.

All share the same core idea: declare tasks (build, test, lint) per package, declare dependencies between tasks, cache outputs by input hash, and skip work that already succeeded locally or on a shared remote cache.

Affected builds and tests

On a pull request that touches packages/ui, CI should build and test packages/ui plus every downstream package that depends on it — not unrelated apps/billing if the graph says there is no path. Tools expose this as nx affected or turbo run test --filter=...[origin/main]. Getting affected detection right is the difference between five-minute and ninety-minute CI.

Versioning and publishing strategies

Internal packages that never leave the org can use source versioning — always workspace:* links, no semver bumps, ship together from main. This is the default for most product monorepos.

Libraries published to npm need an explicit strategy:

  • Fixed / unified versioning — one version number for all packages (Lerna fixed mode). Simple changelog; couples unrelated releases.
  • Independent versioning — each package semver-bumps on its own. Flexible; consumers track many versions.
  • Changesets — developers add changeset files describing bump intent; CI aggregates into version PRs. Popular middle ground.

For apps deployed continuously, app versions often equal Git SHAs, not semver. Shared libraries inside the repo may never be published at all — they are consumed via workspace protocol. Do not add publishing ceremony until an external consumer actually exists.

CI/CD patterns in monorepos

Monorepo CI must be graph-aware. Patterns that work:

  • Path-filtered workflows — trigger jobs only when relevant directories change (GitHub paths filters).
  • Remote build cache — Turborepo Vercel cache, Nx Cloud, or self-hosted S3 cache so CI agents reuse local dev machine outputs.
  • Merge queue / batched main — test PR branches against a merge commit of main to catch integration conflicts before land.
  • Deploy per app — a change in apps/web deploys web only; API unchanged. Tag or label-driven deploy pipelines map directories to environments.
  • Required checks per ownership — CODEOWNERS routes review; required status checks ensure affected packages pass before merge.

Tie this into broader CI/CD pipeline design: trunk-based development with short-lived branches works well because the monorepo already centralizes integration pain — long-lived feature branches recreate polyrepo-style drift inside one repo.

Code ownership, permissions, and trunk hygiene

"Everyone can edit everything" does not scale past twenty engineers. CODEOWNERS files assign review responsibility per path. Platform teams own packages/* shared infra; product squads own apps/checkout. Some orgs add CI rules: changes to packages/auth require security team approval.

Trunk hygiene matters more in monorepos because broken main blocks everyone:

  • Keep main green — revert fast, fix forward for trivial issues.
  • Feature flags for incomplete work instead of long-lived branches.
  • Lockfile changes reviewed carefully — one bad resolution breaks all packages.
  • Document upgrade playbooks (TypeScript major bumps, framework migrations) in docs/ so teams do not rediscover the same steps.

Good Git fundamentals — small commits, descriptive messages, rebase vs merge policy — are multiplied in impact when fifty teams share one history.

Migrating to a monorepo

Common migration paths:

  1. Strangler merge — import one polyrepo at a time with history preserved (git filter-repo or subtree merge). Start with the most-shared library.
  2. Greenfield workspace — new monorepo; copy packages in; switch CI; deprecate old repos read-only.
  3. Monolith first — extract packages/ from an existing single app without splitting deployables yet.

Migration success metrics: CI time under an agreed budget (e.g. 15 minutes for affected PR checks), zero manual version bump PRs for internal libs, and a dependency graph diagram every new hire can read in ten minutes.

Common pitfalls

  • Implicit dependencies — importing via relative paths across package boundaries bypasses the graph; affected detection misses consumers. Enforce package boundaries in lint.
  • Shared global state — singleton env loaders or DB pools in a "common" package create hidden coupling.
  • Skipping remote cache — local caches help devs; CI without remote cache still runs full builds on every agent.
  • One giant Docker build — build images per app from the same repo using multi-stage Dockerfiles scoped to each app's graph slice.
  • Confusing monorepo with microservices — splitting repos does not fix bad domain boundaries; joining repos does not fix a tangled monolith deployable.
  • Over-centralized platform dictatorship — shared configs are good; every product decision flowing through one team is not.

Decision table: monorepo vs polyrepo

Scenario Prefer monorepo Prefer polyrepo
Shared UI kit + 4 web apps, same release train Yes
Acquired company with separate compliance boundary Yes
10 engineers, heavy cross-package refactors weekly Yes
Open-source SDK + closed SaaS with different communities Yes (or monorepo with clear publish boundary)
200 engineers, mature graph tooling budget Yes
Teams never share code; org chart is fully independent Yes

Production checklist

  • Document workspace layout — what lives in apps/ vs packages/ and dependency rules.
  • Enforce package boundaries — lint rules, no deep imports, public export surfaces only.
  • Configure affected CI — PR checks run graph-sliced build/test, not full workspace every time.
  • Enable remote caching — CI agents share build artifacts with dev machines.
  • CODEOWNERS per critical path — shared libs and infra require domain owner review.
  • Per-app deploy pipelines — directory-to-environment mapping; unchanged apps do not redeploy.
  • Pin toolchain versions — Node, package manager, and compiler versions committed in repo root.
  • Publish strategy documented — workspace-only vs npm publishing with changesets.
  • Dependency graph visible — Nx graph or generated diagram in docs for onboarding.
  • Revert-first culture on main breakage — trunk stays green for all teams.

Key takeaways

  • Monorepo is a source-control strategy, not a deployment model — you can run microservices, monoliths, or both from one repo.
  • Tooling and graph-aware CI are non-optional — without affected builds and caching, monorepos become slow and hated.
  • Package boundaries need enforcement — otherwise you recreate a big ball of mud with extra Git complexity.
  • Versioning strategy follows consumers — internal-only packages rarely need semver ceremony.
  • Ownership and trunk hygiene scale people — CODEOWNERS and fast reverts matter more as headcount grows.

Related reading