All entries

The Monorepo Decision

Monorepos · Engineering Leadership · Developer Experience · System Design · Turborepo

The argument usually starts wrong. Someone says “we should move to a monorepo.” Someone else says “monorepos don’t scale.” Both sides cite Google. Neither side is talking about the same thing.

The real question isn’t monorepo or polyrepo. The real question is: how often does your code change together, and what does it cost you when it doesn’t?

That’s it. Everything else is a consequence.

Monorepo Is Not a Monolith

Before anything else, this distinction needs to be settled, because conflating them is the source of most bad takes on this topic.

A monorepo is a version control strategy. One repository, multiple projects. That’s all. It says nothing about how services deploy, how teams are structured, or what the runtime architecture looks like. You can have a monorepo with a hundred independent microservices, each deploying on its own schedule to different clusters.

A monolith is an architectural pattern. One deployable unit. You can have a monolith in a polyrepo — and many teams do. You can have microservices in a monorepo — and many companies do.

⚠️

Saying “monorepos create monoliths” is like saying “open floor plans cause teams to stop talking.” The layout doesn’t determine the behavior. Your processes do. Keep the two concepts separate or you’ll make the wrong decision for the wrong reason.

Keeping this distinction clear matters because the failure modes of each are completely different. A monolith fails when deployment becomes risky and slow. A monorepo fails when tooling can’t handle the scale. Solving for the wrong one is expensive.

Who Actually Uses This

The scale argument gets brought up constantly and usually incorrectly. Before getting into the data, this video from Fireship covers the landscape well — Turborepo vs Nx, who uses monorepos, and why:

The data is worth looking at directly.

2B lines of code Google's monorepo (2016)
86TB repository size Google Engineering
3.5M files in one repo Microsoft Windows (2017)

Google, Meta, Airbnb, Twitter, Stripe, Shopify — all run or have run monorepos at significant scale. Microsoft moved Windows to a monorepo in 2017 with 3.5 million files and 300GB of source code, and built VFS for Git specifically to handle it.

The takeaway is not “Google does it, so it scales.” The takeaway is that the tooling problem is solved. You don’t have to pioneer anything. The hard parts have already been figured out, and the open-source ecosystem has caught up.

The inverse is also true: plenty of well-run engineering organizations use polyrepos. Amazon famously does. Netflix does. The pattern works when teams and services are genuinely independent. The problem is that most teams reach for polyrepo because of inertia, not because their situation matches Amazon’s.

The Real Tradeoffs

Monorepo vs Polyrepo

Monorepo

  • · Single source of truth for tooling config
  • · Atomic commits across multiple packages
  • · Shared TypeScript types by default
  • · Cross-package refactoring in one PR
  • · Unified CI/CD pipeline
  • · Dependency version consistency enforced
  • · Easier to discover and reuse existing code
  • · One PR, one review, one merge

Polyrepo

  • · Clear ownership boundaries per repository
  • · Fully independent deployment pipelines
  • · Smaller surface area and blast radius
  • · Teams can move at genuinely different speeds
  • · Language and tooling diversity is natural
  • · Simpler git history per project
  • · Lower risk from tooling changes
  • · Easier to open-source individual pieces

Neither column wins. The weight of each point depends entirely on your specific situation. A team with five engineers and three tightly coupled services reads this table differently than a team with two hundred engineers and fifty independent products.

When a Monorepo Makes You Faster

Your packages change together. This is the primary driver. If updating a shared library requires updating five consumers, doing that across five repositories means five PRs, five reviews, five merges, and five deployment windows — each with the risk of version mismatch during the transition. A monorepo collapses that into one atomic commit. The partial update state is impossible because you can’t merge without updating everything simultaneously.

You’re doing cross-cutting changes. Security patches, observability upgrades, API contract changes — anything that needs to land consistently across the codebase. In a polyrepo, these drift. One service gets updated, three don’t. Six months later, there are services running old versions of your auth library with a known vulnerability. A monorepo makes partial application structurally impossible.

💡

The rule that made this click for me: if you find yourself opening issues in other repos saying “please upgrade to v2.3 of this library,” you are paying the polyrepo coordination tax. That overhead is invisible until you add it up across a quarter. When you do, it’s usually significant.

You have a TypeScript codebase with shared types. Type safety across package boundaries in a polyrepo means publishing, versioning, and consuming type definitions separately. Package A defines UserSchema. Package B imports it. When A’s schema changes, B gets a type error — but only after you publish A’s new version, update B’s package.json, and reinstall. In a monorepo, the type error surfaces in the same PR, immediately. The compiler catches the breakage before it lands.

Your team is small to medium. Coordination overhead scales with team size. Below roughly twenty engineers, the transaction cost of repository boundaries rarely pays for itself. The mental overhead of “which repo does this belong to?” and “which version is deployed where?” compounds daily. A monorepo removes those questions entirely.

When a Polyrepo Makes You Faster

Your teams own genuinely independent slices. “Independent” has a specific meaning here: different release schedules, different on-call rotations, different technology choices, and minimal shared code. If two teams haven’t needed to make a shared change in six months, they probably don’t need to share a repository. The coordination cost is already low.

You have language diversity. A Python ML service, a Go API, and a TypeScript frontend can technically coexist in a monorepo. But the build systems, linters, and test runners are completely separate ecosystems. Forcing them into one workspace adds overhead without meaningful benefit. Language boundaries naturally reflect team and service boundaries. Polyrepo matches that structure cleanly.

Independent deployment is a hard requirement. When services need to move at genuinely different speeds — one deploys fifty times a day, another deploys monthly after heavy review — a shared pipeline creates friction. You can configure around it, but you’re fighting the grain of the tool.

⚠️

The warning sign: if you’re building elaborate CI filters like if: contains(github.event.head_commit.modified, 'apps/service-a/') to only deploy when a specific directory changes, you have added the tooling complexity of a monorepo without its benefits. At that point, evaluate whether polyrepo is actually a better fit for your situation.

If You Go Monorepo: Turborepo

The JavaScript monorepo ecosystem has three real options: Turborepo, Nx, and raw workspace scripts. For most teams, the answer is Turborepo.

Nx has more features — project graph visualization, code generators, affected project detection, first-party framework integrations. It’s excellent for large organizations that want a full-featured build platform with strong conventions. The tradeoff is meaningful configuration surface area and a learning curve that front-loads the complexity.

Turborepo does fewer things and does them exceptionally well: task scheduling based on a dependency graph, and aggressive caching. Local cache means tasks that haven’t changed restore from disk instead of running. Remote cache means the first engineer to run a build on main warms the cache for every subsequent run — in CI and on every developer’s machine.

Install it in an existing workspace:

npm install turbo --save-dev
npx turbo init
pnpm add turbo --save-dev -w
pnpm turbo init
bun add turbo --dev
bunx turbo init

The core of Turborepo is turbo.json. It defines your task graph: which tasks exist, what they depend on, what outputs to cache, and what inputs invalidate the cache:

{
  "$schema": "https://turbo.build/schema.json",
  "ui": "tui",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["$TURBO_DEFAULT$", ".env*"],
      "outputs": ["dist/**", ".next/**", ".astro/**"]
    },
    "typecheck": {
      "dependsOn": ["^build"],
      "outputs": []
    },
    "lint": {
      "outputs": []
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"],
      "cache": false
    },
    "dev": {
      "persistent": true,
      "cache": false
    }
  }
}

The ^build pattern is the key insight. It means “run build in all upstream dependencies first.” If your api package depends on @repo/shared, Turborepo automatically runs shared’s build before api’s — in the correct order, with maximum parallelism across the graph. You don’t manage the order manually. It’s inferred from your package.json dependencies.

Remote Cache

Local caching saves you on repeated local builds. Remote caching is what changes the economics at team scale.

# Link your repo to Vercel's remote cache
npx turbo login
npx turbo link

After linking, every build artifact is pushed to Vercel’s cache. CI hits cache on clean environments. Every engineer shares the same cache. The first build of the day warms it for everyone else.

# Run ducktape or any compatible cache server
docker run -p 3000:3000 ghcr.io/ducktapeengineering/ducktape:latest

# Set these in your CI environment
TURBO_API=http://your-cache-server:3000
TURBO_TOKEN=your-secret-token
TURBO_TEAM=your-team-slug

Any server that implements the Turborepo Remote Cache API works. Ducktape, Turborepo Remote Cache, or a custom implementation.

turbo build --filter=api
• Packages in scope: api, @repo/shared, @repo/database
• Running build in 3 packages

@repo/shared:build: cache hit, replaying logs (output: dist/**)
@repo/database:build: cache hit, replaying logs (output: dist/**)
api:build: cache miss, executing

api:build: > tsc --project tsconfig.build.json
api:build: Done in 3.2s

Tasks:    3 successful, 3 total
Cached:   2 cached, 3 total
Time:     3.4s >>> FULL TURBO

Two packages restored from cache in milliseconds. One rebuilt. That’s what “only rebuild what changed” looks like in practice. In a team of ten engineers, this compounds daily.

Structure It Right

A monorepo’s layout should reflect the dependency relationships in your codebase. The most common mistake is dumping everything flat in packages/ and treating it as a dumping ground.

Recommended monorepo structure
monorepo/
├── apps/                          # Deployable applications
│   ├── web/                       # Next.js frontend
│   ├── api/                       # Node.js API server
│   └── worker/                    # Background job processor
├── packages/                      # Internal shared packages
│   ├── ui/                        # Component library
│   ├── database/                  # Prisma schema + generated client
│   ├── observability/             # Logs, traces, metrics setup
│   ├── config/                    # Shared ESLint, TSConfig bases
│   └── types/                     # Shared TypeScript interfaces
├── infra/                         # Infrastructure as code
│   ├── docker-compose.yml
│   └── k8s/
├── turbo.json
├── package.json                   # Workspace root
└── pnpm-workspace.yaml

The split between apps/ and packages/ is not cosmetic. Apps are things you deploy. Packages are things you import. An app can depend on packages, but packages should never depend on apps. This creates a directed acyclic graph with a clear dependency flow: apps → packages → packages.

{
  "name": "@repo/database",
  "version": "0.0.0",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "scripts": {
    "build": "tsc",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@prisma/client": "^5.0.0"
  }
}
{
  "name": "api",
  "dependencies": {
    "@repo/database": "workspace:*",
    "@repo/observability": "workspace:*",
    "@repo/types": "workspace:*"
  }
}

The workspace:* protocol tells the package manager this dependency lives inside the monorepo. Turborepo reads these references to build its task graph. When api’s build runs, Turborepo knows it must build @repo/database, @repo/observability, and @repo/types first — and it does, automatically.

The TypeScript config follows the same pattern. A root tsconfig.json defines the base, each package extends it:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": true,
    "composite": true
  }
}
{
  "extends": "@repo/config/tsconfig/base.json",
  "include": ["src"],
  "exclude": ["dist", "node_modules"]
}

One TypeScript config to update. Every package inherits it. When you upgrade the target or add a compiler option, it applies everywhere simultaneously.

The Migration Path

Going from polyrepo to monorepo is straightforward mechanically and uncomfortable in practice. The code move is the easy part. The painful part is tooling consolidation: converging on one ESLint config, one TypeScript base, one CI pipeline, one set of dependency versions.

Start by mapping what imports what, before moving anything:

Finding cross-repo dependencies
$ grep -r "@your-org/" */package.json | grep -v node_modules

api/package.json:      "@your-org/shared-types": "^1.2.0",
api/package.json:      "@your-org/auth": "^3.1.0",
worker/package.json:   "@your-org/shared-types": "^1.2.0",
frontend/package.json: "@your-org/ui": "^2.0.0",
frontend/package.json: "@your-org/shared-types": "^1.1.0"   ← version drift

That version drift on shared-types is the problem a monorepo solves. The frontend is still on 1.1.0 while everything else is on 1.2.0. In a monorepo, that divergence is structurally impossible — there’s one version in the workspace, period.

The migration itself:

# 1. Create the monorepo scaffold
npx create-turbo@latest my-monorepo
cd my-monorepo

# 2. Move each repo with full git history preserved
git subtree add --prefix=apps/api ../api-repo main --squash
git subtree add --prefix=apps/worker ../worker-repo main --squash
git subtree add --prefix=packages/shared-types ../shared-types-repo main --squash

# 3. Update dependency references to workspace protocol
# "@your-org/shared-types": "^1.2.0"  →  "@repo/shared-types": "workspace:*"

# 4. Install and verify Turborepo understands the graph
pnpm install
npx turbo build --dry-run

# 5. Consolidate tooling
# Merge tsconfig.json files into packages/config/
# Merge .eslintrc files into a shared config package
# Update CI to run: turbo build test lint

git subtree add preserves the full commit history of each repository inside the monorepo. Engineers can still run git log apps/api and see the complete history from before the migration. git blame still works file by file. This matters more than people expect — it’s the difference between a migration and a rewrite.

The hardest part of migration is dependency version consolidation. Every repo has been upgrading packages independently. You’ll find conflicting major versions, pinned ranges that can’t resolve together, and peer dependency mismatches. Budget time for this. It’s not fun, but it only happens once.

The Decision Framework

Before committing, answer these five questions honestly:

1. How often do you make changes that touch multiple services simultaneously? If the answer is “frequently” or “nearly every feature requires it,” a monorepo pays for itself quickly. If the answer is “rarely,” the tooling overhead may not be worth it.

2. Do you have internal packages that multiple services consume? A shared auth library, a shared database client, shared TypeScript types — any of these that need to stay in sync across consumers are strong signals for a monorepo.

3. Are your teams truly independent or just organized that way? Be honest. Teams that look independent on an org chart but routinely coordinate changes in code aren’t actually independent. If the code needs to move together, it should live together.

4. What is your current coordination tax? Count the PRs per month that exist solely to propagate a change from one repository to another. Multiply by the review and CI overhead of each. That’s the polyrepo coordination tax you’re already paying. Compare it to a two-day investment in monorepo tooling setup.

5. Do you have the tooling investment appetite? A monorepo requires more upfront setup. CI pipelines need to understand affected packages. Developers need to learn Turborepo. Code ownership needs to be configured via CODEOWNERS. If your team can’t invest two to three days in that setup, the migration will stall halfway and leave you worse off than either starting point.

💡

A heuristic that has served me well: if you’re not sure, start with a monorepo for new projects. Splitting a monorepo into separate repositories is always possible and usually clean — you already have the boundaries defined in your apps/ and packages/ directories. Merging polyrepos into a monorepo is possible but painful. When in doubt, optimize for the easier future exit.

What I Would Actually Do

If you’re starting a new product with a TypeScript stack, use a monorepo with Turborepo. The setup is an afternoon. The ongoing benefit — shared types, atomic changes, unified tooling, remote cache — compounds from the first week.

If you’re inheriting a polyrepo where services rarely share code and teams operate genuinely independently, don’t migrate out of principle. Identify the specific coordination pain you’re experiencing first. If it’s high and recurring, migrate. If it’s low, the status quo is fine. Migrations are real work for uncertain benefit when the problem isn’t clear.

If you’re in a large organization, the decision depends on team topology more than anything technical. Monorepos work when the build infrastructure investment is justified by the coordination overhead saved. That calculation changes at different scales, and there is no universal answer.

The monorepo is not a best practice. It’s a tradeoff. Like every architectural decision, the right choice depends on understanding what problem you’re actually solving — not what a company with ten times your scale does, and not what the current consensus is on tech Twitter.

Know what problem you’re solving. Then pick the structure that solves it.

Share