Monorepo vs Polyrepo Architecture

Choose the right repository strategy. Covers monorepo tooling (Nx, Turborepo, Bazel), polyrepo coordination, code ownership, dependency management, and CI/CD for each approach.

Monorepos and polyrepos represent two fundamental approaches to organizing code across multiple services, libraries, and applications within a single engineering organization. This page details the trade-offs, workflows, failure modes, and day-to-day practices for each architecture, focusing on real-world decisions engineers face when choosing or evolving their codebase structure.

When to Choose Monorepo vs Polyrepo

The decision between a monorepo and a polyrepo is not about scale, but about *coordination cost*. When teams need to frequently coordinate across components—such as when a library change impacts multiple services, or when a frontend update requires a backend API change—monorepos reduce friction. When teams operate independently with low coupling, polyrepos reduce noise and allow divergent development rhythms. The choice is most impactful when changes span more than one repository, especially when those changes are synchronized across boundaries.

Managing Dependencies and Versioning

In a polyrepo architecture, each repository manages its own dependencies and versioning. A service auth-service might depend on [email protected], declared in package.json:

{
  "dependencies": {
    "user-lib": "1.2.0"
  }
}

When user-lib is updated, it must be published to a registry (e.g., npm, Docker Hub, PyPI) and the new version declared in the consuming service. The version is now a *contract*: auth-service assumes user-lib's API at 1.2.0 and must be tested against that version.

In a monorepo, dependencies are *local* and *synchronized*. All packages live in a single repository, often under packages/. A lerna.json or turbo.json file defines the workspace:

{
  "name": "my-monorepo",
  "packages": [
    "packages/*"
  ],
  "version": "1.0.0"
}

With turbo as the build system, a turbo.json configures parallel execution:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"]
    }
  }
}

Running turbo build triggers a build for every package that has changed or depends on a changed package. The build command automatically detects changes via git diff --name-only HEAD^.

Coordinating Changes Across Boundaries

In a polyrepo, a change to user-lib requires a separate pull request, CI run, and release. The auth-service team must manually update its package.json, bump the version, and deploy. This leads to a *version drift* problem: auth-service may run [email protected], but order-service uses [email protected], and notifications-service uses [email protected]. Synchronizing versions across services becomes a manual chore.

In a monorepo, changes to user-lib are part of a single pull request that includes updates to auth-service, order-service, and notifications-service. The PR can include the updated package.json, dist/ output, and a changelog entry.

A common failure mode is the *uncommitted dependency change*. Developers modify user-lib, run npm run build, but forget to commit the dist/ folder before pushing. The CI pipeline runs build and test, but the built artifacts are not versioned. When the PR is merged, auth-service picks up the user-lib source but not the built output—leading to a mismatch between source and compiled code.

To avoid this, a pre-commit hook ensures dist/ is committed:

#!/bin/sh


echo "Building all packages..."
turbo build

echo "Staging built artifacts..."
git add packages/user-lib/dist

# Check if any new files are staged
if ! git diff --cached --name-only | grep -q "dist"; then
  echo "No dist files added. Run build manually."
  exit 1
fi

Synchronizing Development and Release Cycles

In polyrepos, services evolve independently. Each has its own CI pipeline, release schedule, and deployment strategy. A service may release weekly, another monthly. This leads to *release chaos*: a new feature in auth-service might be deployed before user-lib’s latest version is available, causing runtime errors.

In monorepos, the entire system releases together. A single release pipeline runs across all packages. A release command in turbo can:

turbo run release --scope=user-lib --tag=v2.0.0

This command:

The release is atomic: all components are versioned and deployed together. A failed release rolls back all packages.

A common failure is *partial release*. The release command publishes user-lib to npm but forgets to update auth-service’s package.json. The new user-lib is available, but auth-service still depends on [email protected]. This results in an inconsistency: user-lib has v2.0.0 API, but auth-service uses v1.9.0.

Handling Conflicts and Merge Overhead

In a polyrepo, PRs are isolated. A team can merge auth-service changes without waiting for user-lib. But when user-lib must change to support a new auth-service feature, a separate PR must be opened. The user-lib team may not be aware of the auth-service requirements until the PR is reviewed.

In a monorepo, all changes are visible in a single place. A developer can open a PR that modifies user-lib and auth-service in the same branch. The CI pipeline runs build, test, and lint for all packages, and reports a single status.

However, monorepos introduce *merge conflicts across boundaries*. A change to user-lib might break auth-service, and a change to auth-service might break user-lib. When two teams work on the same monorepo, they often introduce conflicting changes.

A typical failure is the *unresolved merge conflict in package.json*. Two teams modify the dependencies section of package.json in different branches. When the merge is done, Git resolves the file as:

{
  "dependencies": {
    "user-lib": "1.2.0",
    "auth-service": "0.5.0"
  },
  "devDependencies": {
    "jest": "^27.0.0"
  }
}

But the user-lib team updated user-lib and auth-service to depend on [email protected], while the auth-service team updated auth-service to depend on [email protected]. The merge results in a package.json with conflicting entries:

{
  "dependencies": {
    "user-lib": "1.3.0",
    "auth-service": "0.5.0"
  },
  "devDependencies": {
    "jest": "^27.0.0",
    "user-lib": "1.4.0"
  }
}

The user-lib dependency appears twice, and the auth-service team must resolve the conflict manually. This leads to *duplicate dependencies* and *incorrect versioning*.

Scalability and Performance

Polyrepos scale naturally: each service can grow independently. A large order-service may have its own database, deployment strategy, and CI/CD pipeline. A monorepo, however, requires careful engineering to avoid performance bottlenecks.

In a monorepo, the git clone command downloads the entire codebase. A full checkout might take 10–20 minutes on a slow network. A git status in the root directory can take several seconds due to scanning all subdirectories.

To improve performance, engineers use *submodules* or *worktrees*. A worktree allows checking out the same repository at different locations:

git worktree add ../auth-service worktree/auth-service

This creates a separate working directory for auth-service, sharing the .git directory. A developer can edit auth-service and user-lib simultaneously without switching between repositories.

For large monorepos, turbo uses a *local cache* to avoid redundant builds. The cache is stored under .turbo/cache and indexed by hash of input files. When a build is repeated, turbo checks if the inputs (files, dependencies, environment) match a previous build. If so, it copies the output from the cache instead of rerunning the task.

A common failure is *cache invalidation*. When a user-lib change affects auth-service, but the auth-service build cache is not invalidated, the auth-service build runs with outdated user-lib artifacts. The turbo CLI reports:

build:auth-service
  cache miss: [email protected]
  cache hit: [email protected]

The build command runs even though the inputs have not changed. A missing --cache flag or incorrect cache configuration leads to wasted CI time.

Handling Dependencies and Publishing

In polyrepos, dependencies are *external*. When auth-service depends on user-lib, it pulls the package from a registry. The package.json specifies the version, and the node_modules directory is built during npm install.

In monorepos, dependencies are *internal*. auth-service can depend on user-lib via a symlink or a build artifact. The package.json in auth-service might reference user-lib as a local path:

{
  "dependencies": {
    "user-lib": "file:../user-lib"
  }
}

But this creates a *development-time dependency*. When the monorepo is published, all packages are built and published, and the auth-service package depends on the published user-lib package.

To bridge this, a build step must be run before publishing. The turbo pipeline ensures this:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "publish": {
      "dependsOn": ["build"],
      "inputs": ["package.json", "dist/**"],
      "outputs": ["dist/**"]
    }
  }
}

A failure occurs when publish runs before build. The dist/ folder is missing, and npm publish uploads an empty dist/ directory. The user-lib package has no code, and consumers receive a package.json but no index.js.

Summary of Key Decisions and Failures

The choice between monorepo and polyrepo is not about technology, but about *coordination*. It shapes how teams communicate, how they release, and how they handle change. The monorepo reduces friction but introduces complexity; the polyrepo scales but increases overhead. The right choice depends on the rhythm of change and the cost of coordination.

This page was rewritten on 15 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.