Monorepos: The Architecture That Changes How Teams Ship Software


TL;DR
- Polyrepos fragment your codebase: version drift, impossible atomic commits, duplicated CI configs, and onboarding that takes days instead of hours
- npm/pnpm/yarn workspaces solve dependency sharing (local packages are symlinked, no publish cycle needed) but they don't cache builds or detect what changed
- Turborepo adds a task graph, content-based fingerprinting, and remote caching: teams report 85% CI time reduction and 23x speedups through parallelization
- Monorepos give you atomic commits across packages, one shared config that governs everything, and IDE navigation that just works across package boundaries
- The migration isn't free: non-deterministic tasks break caching, git history grows, and you need boundary rules to prevent "everything depends on everything"
Twelve repos. Five teams. One shared authentication library.
A mid-size engineering org: 40 engineers, shipping to roughly 120k daily active users across three products. On paper it made sense: each service owned its own repository, deployments were independent, and teams could ship without stepping on each other. In practice, every time the auth library needed updating, someone filed four PRs across four different repos, pinged four different Slack channels, and hoped the timing worked out so nothing went out of sync in production.
One week, two services deployed with different versions of the same library. The token format had changed between versions. Sessions started failing silently for a subset of users. It took three hours to trace back to a version mismatch that was invisible in any single repo's diff.
That's the problem with polyrepos that nobody talks about until it bites you.
How Polyrepos Quietly Destroy Velocity
The case for polyrepos is intuitive. Independent teams, independent repos, independent deployments. Until you actually work in one at scale.
Version drift is the first thing that goes wrong. When @company/ui lives in its own repo and gets published to a private npm registry, every consuming app decides when to update. App A is on v2.1, App B stayed at v1.8 because upgrading required some migration work nobody had time for. Now the component library diverges in two directions simultaneously. Bugfixes need to land in two versions. Design system changes land partially. QA has to test against both.
Cross-repo changes are coordination overhead, not engineering work. You cannot refactor an API and update all consumers in a single reviewable commit. You open one PR for the API change, wait for it to merge, bump the version in the library, publish, then open separate PRs in each consumer to pull the update. If the consuming teams have different review cycles, you end up with a period where some consumers are on the old contract and some are on the new one, which is fine until someone expects consistency.
Every repo duplicates its own operational overhead. Each of your 12 repos has its own Dockerfile, its own GitHub Actions workflow, its own ESLint config, its own TypeScript config. They all differ slightly. The Node version in one CI workflow is 18, another pinned to 16. ESLint rules in the auth service allow any, the frontend bans it. New engineers need to learn which conventions apply where. The drift in config is invisible until it causes a production issue.
Onboarding takes days, not hours. "Clone these repos, run npm install in each, set up these environment variables per repo, and here's the diagram explaining which services talk to which." The mental model for how the codebase fits together has to be reconstructed from documentation that's always slightly stale.
Workspaces: The Right First Step
The first time you consolidate repos into a monorepo, you reach for workspace support, which is baked into npm, pnpm, and Yarn. The idea is simple: declare which directories are packages, and the package manager handles linking them together.
Here's the root package.json:
{
"name": "my-monorepo",
"private": true,
"workspaces": ["apps/*", "packages/*"]
}
Or with pnpm, a pnpm-workspace.yaml:
packages:
- 'apps/*'
- 'packages/*'
Your repo structure looks like this:
my-monorepo/
├── apps/
│ ├── web/ # Next.js frontend
│ └── api/ # Express backend
├── packages/
│ ├── ui/ # Shared component library
│ ├── config/ # Shared ESLint/TS configs
│ └── utils/ # Shared utilities
├── package.json
└── pnpm-workspace.yaml
When apps/web declares "@company/ui": "workspace:*" in its dependencies, pnpm creates a symlink directly to packages/ui in your local file system. There's no publish step. Change a component in packages/ui, and it's immediately reflected in apps/web. One lockfile governs the entire repo. No more version drift across apps.
Why pnpm over npm or Yarn workspaces? npm and Yarn (classic) use hoisting, which flattens dependencies into a root node_modules directory. This causes phantom dependencies: a package can import something it never declared in its own package.json, as long as a sibling package pulled it in and it got hoisted. Works locally, breaks when you publish that package standalone. pnpm uses a content-addressable store instead. Each package has its own isolated node_modules with symlinks into ~/.pnpm-store/, which is shared globally across all your projects. No phantom dependencies, 2-3x faster installs on large repos, and consistent behavior between development and production.
Workspaces solve the dependency problem and the code-sharing problem. That's real. But they stop exactly there.
The Ceiling Workspaces Hit
After consolidating into a workspace-based monorepo, the build times don't get better. They get worse.
Running pnpm run build in the root now builds all 15 packages in sequence. Every commit to any file in the repo triggers a full rebuild of everything. Your CI pipeline, which used to build each repo independently in parallel, now builds the entire workspace, and it does so whether you changed 50 files or fixed a typo in a README.
The other problems that workspaces leave unsolved:
No task orchestration. Workspaces don't know that apps/web#build should run only after packages/ui#build completes. You have to script this manually, or run everything in sequence and accept the slowness.
No caching. There's no mechanism to say "this package was already built with these inputs, skip it." Every pnpm run build starts from scratch.
No change detection. CI cannot ask "which packages changed in this commit?" Workspaces have no concept of a dependency graph at the task level, only at the package level.
The ceiling appears fast. With 18 packages in a workspace, a CI run that should test a change to one utility library ends up rebuilding and retesting the entire codebase. Builds that used to take 8 minutes per repo now take 45 minutes for the whole thing. Workspace scripts run sequentially by default, and even with some manual parallelization, shared install overhead and no task skipping makes the total worse than the sum of the parts.
Why Not Nx, Lerna, or Bazel?
Three tools come up in every monorepo evaluation before landing on Turborepo.
Nx is the obvious first candidate. It has a larger ecosystem, first-class support for Angular and React Native, and its own cloud caching product. But it's also opinionated about project structure: generators, executors, project.json files. Migrating 12 existing repos into an Nx workspace means rewriting how every build and test script is registered. Turborepo wraps existing package.json scripts without touching them, which means zero migration cost on the tooling side.
Lerna is the first instinct for anyone who's been in the JavaScript ecosystem for a few years, where it was the standard for monorepos for a long time. It's now effectively deprecated as a standalone tool. The current Lerna uses Nx for task execution under the hood, which means you get Nx's scheduling either way, plus a versioning and publishing layer on top.
Bazel has real appeal. Its hermetic builds are the gold standard for build correctness. But hermetic means rewriting every build rule from scratch. There's no pointing Bazel at a Next.js app and having it work out of the box. The setup cost is justified at Google-scale (hundreds of engineers, millions of files). At 18 packages, it's the wrong tool.
Turborepo wins on migration cost. Running npx create-turbo@latest and pointing it at an existing workspace gets task graph execution and local caching working within a few hours. No rewriting of scripts, no new config format to learn.
Turborepo: Caching the Dependency Graph
Turborepo sits on top of your workspace package manager and adds exactly the layer workspaces are missing: a task graph, content-based fingerprinting, and a cache.
Install it and add a turbo.json at your repo root:
{
"$schema": "https://turborepo.dev/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"test": {
"dependsOn": ["build"]
},
"lint": {},
"dev": {
"cache": false,
"persistent": true
}
}
}
"dependsOn": ["^build"] is the key piece. The ^ prefix means: before building this package, build all packages in its dependency graph first. Turborepo reads your package.json dependencies, constructs the full dependency graph, and executes tasks in the right order, running independent tasks in parallel and waiting only when one package genuinely depends on another.
How the cache works. When you run turbo run build, Turborepo computes a hash for each task. That hash includes: the contents of every source file in the package, the task's configuration, environment variables you've declared as inputs, and the hashes of all dependent packages. If the hash matches something in .turbo/cache, it replays the cached output and skips execution entirely. Change one file in packages/utils, and only utils and packages that depend on utils get rebuilt. Everything else hits cache.
Remote caching is where CI gets fast. Local cache only helps you, since each CI agent starts cold. Remote caching connects all your machines (and all your CI agents) to a shared cache. CI agent 1 runs turbo run build, pushes results to the remote cache. CI agent 2 runs the same build for a different PR, pulls from cache, and skips every package that hasn't changed. Vercel provides a managed remote cache with zero configuration (just add TURBO_TOKEN and TURBO_TEAM). You can also self-host any HTTP server that implements Turborepo's caching API.
# .github/workflows/ci.yml
- name: Build and test
run: turbo run build test lint
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
Turborepo's own benchmarks show 23x speedup through parallelization alone on large task graphs. Mercari reported an 85% reduction in total CI time and a 50% reduction in individual Turbo task duration after enabling remote caching. Those 45-minute workspace builds become 4 minutes for the common case of touching one package.
| Metric | Polyrepo baseline | Monorepo + Turborepo |
|---|---|---|
| Full CI build time | ~45 min | ~4 min |
| CI time reduction | N/A | 85% |
| Parallelization speedup | 1x | up to 23x |
| Turbo task duration | baseline | -50% |
Everything Else You Get From a Monorepo
Build speed is the headline, but it's not actually the most important thing the monorepo changes.
Atomic commits across packages. This is the one that changes how engineers think about their work. When you update a function signature in packages/utils, you update every caller in apps/web and apps/api in the same commit. The PR shows the full change: the interface that changed and every piece of code that had to adapt. Reviewers see the whole picture. There's no window where the API is live but consumers haven't updated yet. No cross-repo coordination, no version bump PR that someone forgets to chase.
One shared config governs everything. A packages/config directory holds your base TypeScript config, ESLint config, and Prettier config. Every app and package extends from it:
// packages/config/tsconfig.base.json
{
"compilerOptions": {
"strict": true,
"moduleResolution": "bundler",
"target": "ES2022"
}
}
// apps/web/tsconfig.json
{
"extends": "@company/config/tsconfig.base.json",
"include": ["src/**/*"]
}
When the org decides to turn on a new TypeScript strict flag or update ESLint rules, one PR updates one file, and every package inherits the change. No spreadsheet of "which repos have we updated yet."
IDE cross-package navigation just works. Cmd+Click on an import from @company/ui takes you to the actual source file, not the compiled output in node_modules. Renaming a function with your IDE's rename tool updates every reference in every package in the same operation. This sounds minor until you've spent twenty minutes manually tracking down usages across twelve repositories.
Code discovery replaces documentation. Engineers browsing the codebase see how packages depend on each other directly. If you're wondering whether packages/utils is safe to refactor, TypeScript will tell you exactly what breaks.
CI/CD: Deploying Only What Changed
The build cache solves build times. The harder problem in CI is deploying only the services affected by a change. Fast builds don't help much if every Docker image rebuild reinstalls all your dependencies from scratch because the lockfile changed.
Turborepo detects affected packages by comparing file changes to the dependency graph. If you change packages/ui, only the packages that depend on packages/ui are affected. You can query this with turbo run build --filter=...[HEAD^1], which runs build for all packages that have changed since the previous commit and all packages that depend on them.
Docker deployments introduce a subtler problem. When you copy the entire monorepo into a Docker build, any change to any package (even one completely unrelated to the service you're deploying) changes the lockfile. Docker sees a changed lockfile, invalidates its cache, and reinstalls all dependencies from scratch. In a 20-package monorepo, a one-line change to packages/ui forces a full pnpm install in your apps/api Dockerfile even though api doesn't use ui at all.
turbo prune fixes this. It reads your dependency graph and produces a minimal copy of the monorepo containing only the packages your target service actually needs, along with a lockfile that only includes those packages' dependencies.
# Stage 1: Generate a pruned copy of the monorepo for 'api'
FROM node:20-alpine AS pruner
WORKDIR /app
RUN npm install -g turbo
COPY . .
RUN turbo prune api --docker
# Output: /app/out/json/ → just the package.json files needed
# /app/out/full/ → just the source files needed
# Stage 2: Install dependencies using the pruned lockfile
FROM node:20-alpine AS installer
WORKDIR /app
COPY --from=pruner /app/out/json/ .
RUN pnpm install --frozen-lockfile
# Stage 3: Build and run
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=installer /app .
COPY --from=pruner /app/out/full/ .
CMD ["node", "apps/api/dist/index.js"]
Now Docker's layer cache behaves correctly. The pnpm install layer only invalidates when api's actual dependencies change, not when someone updates an unrelated package on the other side of the monorepo.
What Goes Wrong in Practice
Migrations like this take longer than expected. Moving 12 repos into a single monorepo while keeping production deployments running is not a weekend project. Done package by package, it typically runs six weeks or more, and the intermediate state (some packages migrated, some still in separate repos) creates its own coordination problems.
Non-deterministic tasks silently break caching. A build script that embeds a timestamp in the output produces a different hash every run from identical source files: 0% cache hit rate, invisibly. Turborepo's --summarize flag surfaces this, but it's easy to run with broken caching for weeks without noticing. The cache only works if the same inputs always produce the same outputs.
The git repo gets heavy. Everything in one repository means git history carries every package's history. Clone times go from under a minute to over three minutes on a cold machine. Shallow clones help for local development; for CI, it's worth the extra configuration.
Governance needs explicit rules. Workspaces don't enforce package boundaries. Without constraints, apps/web can import directly from apps/api's internal modules, or packages/ui can start depending on packages/business-logic in ways that were never intended. ESLint boundary rules (eslint-plugin-boundaries) enforce which packages are allowed to depend on which. Treat violations as CI failures from day one.
Not every team needs this. Two engineers building a side project with one frontend and one backend probably don't need Turborepo remote caching. Polyrepos are genuinely fine when products share no code, when teams are truly independent, or when the stacks are incompatible enough that a monorepo wouldn't help anyway. The question is whether the coordination overhead of the current setup costs more than the migration would.
Open-Source Monorepos Worth Studying
The fastest way to build intuition for monorepo structure is reading production codebases that ship with one.
cal.com — One of the cleanest public examples of Turborepo + pnpm workspaces in production. Worth looking at: how they split apps/ (web, api) from packages/ (ui, config, prisma), their turbo.json task graph, and how shared TypeScript configs are extended per package. The repo has grown to dozens of packages without the structure falling apart, which is the real test.
dub.sh — A tighter, smaller monorepo that's easier to read end-to-end. Worth looking at: their shared packages/utils and how it's consumed across apps, their CI workflow using turbo run build test lint in a single step, and how they handle environment variables across packages without leaking them between contexts.
Both repos are actively maintained and close enough to a real production setup that you can trace the decisions back to the problems they were solving.
When to Use a Monorepo
The architecture doesn't fix coordination problems that come from team or product design. What it does do is remove the artificial friction that polyrepos add when teams and codebases that genuinely need to work together are forced apart by repo boundaries.
Use a monorepo when that friction is real:
- Multiple apps share code that changes together (component libraries, API clients, utilities)
- You frequently need to refactor across package boundaries
- You want consistent tooling without manually syncing configs across repos
- Your team is large enough that duplicated CI configs and dependency drift are real operational costs
- You're running multiple services that need atomic deploys
Polyrepos are still the right call when teams and products are genuinely independent:
- Your products share no code
- Teams are fully autonomous with no cross-team dependencies
- You're a small team (1-3 engineers) where the migration cost outweighs the benefit
- Your stacks are different enough that a shared monorepo doesn't make sense (Go backend, iOS app, ML pipeline)
The auth library incident that took three hours to trace? In the monorepo, it doesn't happen: the API change and the consumer update ship in one commit, reviewed together, with no window where versions can diverge. Faster builds are a side effect. Fewer classes of production problems is the point.





