Monorepo vs Multi-repo
A decision about where the code lives: in a single Git repository that houses multiple projects and packages (monorepo), or in separate repositories, each with its own independent lifecycle (multi-repo). These are strategies orthogonal to the software's architecture — and confusing this with monolith is the most common pitfall.
Intent
Define the Git repository organization strategy for an organization's multiple projects, packages or services, balancing visibility and code sharing (monorepo) with isolation and lifecycle autonomy (multi-repo).
The choice isn't about system architecture — it's about where the code is versioned. Companies like Google, Meta and Microsoft keep virtually all of their code in a single giant repository. Others prefer one repository per service or product. Both approaches work at scale; the trade-offs are different.
Problem
As a team grows and the number of projects or packages increases, tensions arise that any repository strategy needs to solve:
- Code sharing: how do you reuse a utility or an internal library without duplicating it or publishing an external package on every change?
- Change coordination: when a change in a shared dependency needs to be reflected across multiple projects at the same time, how do you guarantee consistency?
- Team autonomy: how do you let different teams evolve their projects at different paces without blocking each other?
- Visibility: how do you discover what already exists so you don't reinvent the wheel?
Monorepo and multi-repo are different answers to these tensions, and each resolves them better in different contexts.
Structure
Multi-repo
Each project, service or library lives in a separate Git repository. It's the standard model of open-source GitHub: each project has its own release cycle, CI/CD, permissions and history.
GitHub / GitLab / Bitbucket
│
├── org/api-gateway ← independent repository
│ ├── src/
│ ├── package.json (version: "2.1.0")
│ └── .github/workflows/
│
├── org/user-service ← independent repository
│ ├── src/
│ ├── package.json (version: "1.5.3")
│ └── .github/workflows/
│
├── org/shared-utils ← library published to npm/registry
│ ├── src/
│ └── package.json (version: "0.9.0", name: "@org/shared-utils")
│
└── org/frontend-app ← independent repository
├── src/
│ └── package.json (depends on "@org/shared-utils": "^0.9.0")
└── .github/workflows/
Monorepo
All projects and packages coexist in a single Git repository. History is shared; changes that affect multiple packages show up in a single commit or PR.
org/monorepo ← single Git repository
│
├── apps/
│ ├── api-gateway/ ← project: Node/Express app
│ │ └── src/
│ └── frontend/ ← project: React/Vue app
│ └── src/
│
├── packages/
│ ├── shared-utils/ ← shared internal package
│ │ └── src/
│ └── ui-components/ ← component library
│ └── src/
│
├── package.json ← workspace root (pnpm/yarn/npm workspaces)
└── turbo.json ← build pipeline (Turborepo)
Monorepo variants
Three main variants differ by their level of tooling:
- Pure monorepo (Google/Meta style): a single repository for literally all of the company's code. Requires proprietary tooling (Bazel, Pants, Buck) so that CI only rebuilds and retests what changed. This scale requires heavy investment in tooling.
- Monorepo with workspaces: the most common model outside of big tech. Tools like Turborepo, Nx or pnpm workspaces manage the dependencies between internal packages, provide incremental build caching, and let you run tasks only on packages affected by a change.
- Multi-repo with published packages: a multi-repo variant where shared libraries are published to a private registry (private npm, Verdaccio, GitHub Packages) and consumed as versioned dependencies. Keeps repository isolation but adds the cost of publish cycles for propagating changes.
How it works
Code sharing in a monorepo
With workspaces, an internal package is referenced directly by its local path — no need to publish to npm on every change:
// apps/api-gateway/package.json
{
"dependencies": {
"@org/shared-utils": "workspace:*"
}
}
// The workspace tool (pnpm, Turborepo, Nx) resolves
// "@org/shared-utils" to packages/shared-utils/ locally.
// No publish step is needed to test the change.
Incremental caching in a monorepo
Tools like Turborepo keep a dependency graph between packages and
a content hash for each one. When only shared-utils
changes, only the packages that depend on it are rebuilt and
retested. Without this caching layer, a large monorepo's CI
retests everything on every push — making it slower than the
equivalent multi-repo.
Propagating changes in a multi-repo
In a multi-repo, a change in a shared library requires: (1)
commit and push in the library, (2) publishing a new version to
the registry, (3) updating package.json in each
consumer, (4) a PR + CI in each consumer repository. For a
breaking change affecting 10 services, that means 10 separate PRs
that need to be coordinated manually.
When to use each strategy
Prefer monorepo when:
- The code is strongly interdependent: frequent changes cross multiple projects or packages. A monorepo allows a single PR, unified review, and CI that validates everything at once.
- The team is small to medium and works across the same projects: full code visibility reduces silos and makes cross-cutting refactors easier without repository coordination.
- Standardization is a priority: linter, formatter, TypeScript and CI configuration can be centralized once and applied to every project automatically.
- There are many shared internal libraries: the development cycle of internal libs without needing to publish them dramatically speeds up iteration.
Prefer multi-repo when:
- Teams have full autonomy and independent release cycles: teams that rarely or never share code and need different access permissions benefit from multi-repo's isolation.
- Projects use very different technologies: a service in Rust, another in Python and a frontend in TypeScript have very little to gain from a monorepo — the shared tooling doesn't apply.
- There are packages meant to be published as open-source: libraries destined for the community are more naturally managed in their own repositories, with their own issues, releases and maintainers.
- The team doesn't want to invest in monorepo tooling: without incremental caching, a large monorepo will punish CI with slow builds. If there's no willingness to maintain Turborepo, Nx or similar, multi-repo is simpler to operate.
Pros and cons
Monorepo — Pros
- Atomic changes: a change that affects multiple packages shows up in a single commit and PR.
- No need to publish to iterate on internal libraries — the workspace resolves locally.
- Standardized tooling, linting and CI centralized in one place.
- Full code visibility — any developer can discover and reuse what already exists.
- Cross-cutting refactors (renames, interface signature changes) are done in a single PR.
Monorepo — Cons
- Slower CI without incremental caching — without the right tooling, everything is retested on every push.
- The repository grows indefinitely; git clone and history operations get heavy without tools like git sparse-checkout.
- Access permissions are per-repository in Git — granular control over who accesses what requires workarounds (CODEOWNERS, branch protection).
- Accidental coupling is easier: the barrier to importing across projects is zero.
Multi-repo — Pros
- Total isolation: one repository can't accidentally affect another.
- Native per-repository access permissions.
- Each repository's CI is simple and fast — it only tests what's in that repo.
- Fully independent release cycles.
Multi-repo — Cons
- Propagating changes in shared libs requires multiple manually coordinated PRs.
- Duplicated configuration (linter, CI, tsconfig) in each repository — drift shows up over time.
- Discoverability is hard: what already exists and can be reused?
- Versioning hell: internal dependencies with lagging versions coexisting across different services.
Common pitfalls
1. Confusing monorepo with monolith
This is the most frequent — and most costly — confusion. Monorepo and monolith are orthogonal axes. Monorepo is a code versioning strategy; monolith is a deployment architecture decision.
The four combinations are all completely valid:
- Monolith in a monorepo: a single application deployed as one block, with all its code in a single repository.
- Microservices in a monorepo: multiple independently deployable services, all versioned together (Google's model).
- Monolith in a multi-repo: a single application deployed as one block, with its code in its own repository.
- Microservices in a multi-repo: each service has its own independent repository (the most common model in early microservices adoption).
Warning: moving from multi-repo to monorepo doesn't automatically distribute a monolithic system. And adopting microservices doesn't require multi-repo. These are independent decisions.
2. Monorepo without incremental caching
Assembling a monorepo by combining projects into a single repository without configuring incremental build and test caching is a recipe for CI that grows linearly with the repository's size. Before migrating to a monorepo, decide which orchestration tool will be used (Turborepo, Nx, Bazel) and set up the incremental pipeline from the start.
3. Accidental coupling via internal imports
In a monorepo, the technical barrier to importing from another
package is zero — the relative path just has to work. Without
boundary enforcement (lint rules like
eslint-plugin-boundaries or Nx's project
restrictions), teams inadvertently create dependencies between
packages that should be independent. The result is a monorepo
where nothing can be deployed in isolation — the worst of both
worlds.
4. Giant history without sparse-checkout
In monorepos with years of history and many binary files,
git clone can take minutes. The solution is to use
git clone --filter=blob:none --depth=1 (shallow +
partial clone) or configure sparse-checkout so each developer
only gets the directories they need.
Related topics
The monorepo vs multi-repo decision influences how teams organize code internally, but doesn't determine that organization. A monorepo with multiple projects can have each project organized by feature or by technical layer — these are independent decisions that complement each other.
The distinction between monolith and microservices is orthogonal to the repository strategy: the deployment architecture defines how components run in production, while the repository strategy defines how the code is versioned and developed. Organizations that adopt microservices frequently migrate to a monorepo precisely to regain the visibility and change coordination that per-service multi-repo makes difficult.