A monorepo is one Git repository that holds many independent projects: apps, services and libraries sharing tooling, history and version numbers. Instead of one repo per project, everything lives together, and CI runs only the parts affected by each change, so unrelated services build independently while cross-project changes ship in a single commit.
How does a monorepo work?
A monorepo is a Git repository with a directory layout something like apps/, services/, packages/ and libs/, where each subtree is a project that could, in principle, live on its own. What makes it a monorepo (and not just a big repo) is that the projects are built, tested and released with shared tooling (one lockfile, one linter config, one CI configuration), and that a single commit can touch several of them atomically.
The mechanics rest on three things:
- A workspace-aware package manager.
npm workspaces,pnpm workspaces,yarn workspaces,cargoworkspaces,go work, Gradle composite builds and Maven multi-module projects all express the same idea: many packages, one lockfile, one install step, direct source-to-source references between packages. Whenapps/webimportspackages/ui, it resolves to the local source, not a published version. - Affected-detection at build time. Given the set of files changed by a commit or a pull request, the build tool computes the transitive dependency graph and runs tasks only for the affected projects. Bazel does this precisely at file level; Nx and Turborepo do it at project level with a task cache keyed on inputs. A change to
apps/api/src/routes.tsbuilds and testsapiand everything that depends on it, and skipsmobile-appentirely. - Path-scoped CI pipelines. Even before the build tool runs, the CI platform can shortcut the whole thing: if the push doesn't touch a project's subtree, don't even start its pipeline. This is where pipeline definitions grow
pathsorchangesfilters, and it's the cheapest win.
Under the hood, the branching model is almost always trunk-based development. Long-lived branches per service defeat the point of the monorepo: you lose the atomic cross-cutting commit, and you spend the CI budget merging drift instead of shipping.
Why does a monorepo matter?
The monorepo debate reads like a religious war online, but the underlying question is boring: where does the friction go? Every architecture has friction; you choose whether to pay it at change time or at release time.
Polyrepo pays at change time. A refactor that touches an API and its three consumers is four repos, four pull requests, four review threads, four CI runs, and a version-bump dance to keep them in sync. It's slower, but each repo stays boundary-clean.
Monorepo pays at release time. The refactor is one pull request touching four subtrees, atomic and easy to review. But now you own the release story: which subset of the packages ships in this deploy, which stays behind, how do consumers know what changed. That work still exists in polyrepo; the monorepo just makes it visible.
Concretely, teams reach for a monorepo when:
- Cross-project changes are common. Shared UI libraries, shared API clients, shared protobuf definitions. If every third pull request already spans two repos, you're paying polyrepo tax without the isolation benefit.
- You want one source of truth for tooling. One TypeScript config, one CI recipe, one dependency-upgrade cadence. Renovate on twenty repos is a full-time job; on one repo it's a bulk pull request.
- The team is small enough to see the whole repo. For a startup engineering team of 5–50, the monorepo removes coordination overhead entirely. Above a few hundred engineers, you need Google-scale tooling (Bazel, remote execution, a proper code-search system) to keep it working, and at that scale, the tooling is the cost.
The trap is imagining monorepo as free organization. It isn't. It moves the discipline from "keep repos in sync" to "keep the build graph clean, cache warm, and CI selective". If the affected-detection step doesn't work, every push rebuilds the world and the whole thing collapses under its own weight.
When does a monorepo make sense, and when doesn't it?
A short field guide, no absolutism.
Reach for a monorepo when:
- Multiple projects share code that changes often (an SDK consumed by three internal services, a design system used by web + mobile).
- Refactors regularly want to be atomic across projects.
- The team can invest in build-graph tooling or is small enough to not need it.
- You want a single, boring answer to "what version of the linter/CI/format tool are we on".
Stick with a polyrepo when:
- Projects have genuinely independent lifecycles and no shared code (a marketing site and a payments API written by different teams in different languages).
- There are compliance or IP boundaries that must not mix (a PCI-scoped service, code shared with an external partner).
- The organisation is big and unwilling to standardise on one build tool. A monorepo without a shared build tool is just a slow polyrepo with worse
git log.
The wrong reason to pick either is aesthetics. "I like tidy repositories" and "I like one place to search" are both true and both trumped by whichever pattern minimises the friction on the changes your team actually makes.
How do popular CI/CD tools handle monorepo builds?
The problem every CI platform has to solve is the same: given a push that touched 12 files, run pipelines only for the projects those files affect, and skip the rest. How they solve it varies a lot.
- Bazel and its cousins (Buck2, Pants) treat the build graph as the source of truth. Every file declares its dependencies; changing a file computes the exact set of downstream targets to rebuild, and remote execution parallelises them across a farm. If you're operating a large polyglot monorepo (tens of thousands of files, many languages, tight caching requirements) Bazel is the better fit here. Its precision and remote-cache story are still the state of the art, and no CI-level path filter comes close. The cost is the year it takes to migrate a codebase into Bazel's
BUILDfiles. - Nx and Turborepo are the pragmatic mid-scale pick for JavaScript and TypeScript monorepos. Both compute affected projects from Git diffs, cache task outputs locally and remotely, and integrate with any CI runner. Nx has a stronger generator/plugin ecosystem; Turborepo is thinner and faster to adopt. Either drops CI times an order of magnitude on a Next.js-plus-microservices layout.
- GitHub Actions solves the coarse case with
on.push.pathsfilters and thedorny/paths-filteraction to fan out matrix jobs by subtree. It works well up to a point; above that point, reusable workflows and dynamic matrices start to bend, and most teams graduate to Nx or Turborepo inside the Actions runner rather than reinventing the graph in YAML. - GitLab CI offers
rules:changes:which conditionally includes jobs based on paths. It's ergonomic and pairs well with parent-child pipelines for large monorepos where each subtree emits its own child pipeline definition. - Jenkins handles this through polling with the
pathsFilteroption on multibranch pipelines. It's functional if Jenkins already owns your builds, but the ergonomics show their age, and most greenfield monorepos pick something else. - Buddy is a reasonable option when you want each project's pipeline expressed as its own YAML file, gated by paths, without introducing a separate build-graph tool. A
trigger_conditions: ON_CHANGE_AT_PATHblock scopes a pipeline to one subtree of the repo, so pushes that don't touch that subtree don't fire it. Multiple pipelines coexist in.buddy/, each owned by the team responsible for that project. This is the small-to-mid monorepo pattern: two, five, maybe twenty projects, where the coordination win of one repo matters more than the extreme incremental-build performance of a Bazel-style graph. When the repo grows past that, adding Nx or Turborepo inside the Buddy pipeline is the natural next step.
Nothing on this list is a monorepo tool in isolation. The realistic setup is a build-graph tool (Bazel, Nx, Turborepo) for affected-detection paired with a CI platform (Buddy, Actions, GitLab, Jenkins) for orchestration; pick each for what it's actually good at.
Example
A Buddy setup with two pipelines in the same repo (one for a web app under apps/web/, one for an API under apps/api/), each fired only when files inside its subtree, or a shared dependency, change. A push that only edits API code doesn't trigger a web build, and vice versa.
# .buddy/buddy.yml — monorepo with per-project pipelines, path-gated.
- pipeline: "web-build-and-publish"
events:
- type: "PUSH"
refs:
- "refs/heads/main"
trigger_conditions:
- trigger_condition: "ON_CHANGE_AT_PATH"
trigger_condition_paths:
- "apps/web/**"
- "packages/ui/**"
- "package.json"
- "package-lock.json"
actions:
- action: "Install & build web"
type: "BUILD"
docker_image_name: "node"
docker_image_tag: "20"
commands: |-
npm ci
npm run build --workspace=@acme/web
- action: "Publish web artifact"
type: "BUILD"
docker_image_name: "node"
docker_image_tag: "20"
commands: |-
bdy artifact publish "web:$BUDDY_EXECUTION_ID" apps/web/dist --create
- pipeline: "api-build-and-publish"
events:
- type: "PUSH"
refs:
- "refs/heads/main"
trigger_conditions:
- trigger_condition: "ON_CHANGE_AT_PATH"
trigger_condition_paths:
- "apps/api/**"
- "packages/shared/**"
- "package.json"
- "package-lock.json"
actions:
- action: "Test & build API"
type: "BUILD"
docker_image_name: "node"
docker_image_tag: "20"
commands: |-
npm ci
npm run test --workspace=@acme/api
npm run build --workspace=@acme/api
- action: "Publish API artifact"
type: "BUILD"
docker_image_name: "node"
docker_image_tag: "20"
commands: |-
bdy artifact publish "api:$BUDDY_EXECUTION_ID" apps/api/dist --create
- action: "Notify on API failure"
type: "HTTP"
method: "POST"
notification_url: "https://chat.example.com/hooks/api-team"
trigger_time: "ON_FAILURE"
headers:
- name: "Content-Type"
value: "application/json"
content: |
{"text": "API build failed on ${BUDDY_EXECUTION_REVISION}"}
The pattern scales in a boring, useful way. Adding a third project (say apps/mobile/) is a third pipeline in the same file with its own trigger_condition_paths. A team can own the block for its subtree without stepping on anyone else's build. Shared code goes under packages/** and is referenced in every pipeline that depends on it, so a change there fans out to everyone who imports it, which is exactly what a monorepo is supposed to do. The moment the affected-detection needs finer resolution than "did a file in this path change", the natural upgrade is to layer Nx or Turborepo inside the same commands: block — Buddy stays the orchestrator, the build tool takes over the graph.
Frequently asked questions
What's the difference between a monorepo and a monolith?
They're unrelated. A monolith is a runtime shape: one deployable process that owns most of the app. A monorepo is a source-control shape: one Git repository that holds many projects, which can themselves be microservices, libraries, mobile apps and infrastructure code. You can have a monorepo full of microservices (Google, Meta, Uber) or a polyrepo per team all shipping to the same monolith. The two decisions are made independently, and mixing them up is the fastest way to talk past someone in an architecture review.
Doesn't a monorepo make CI slow?
Only if every push rebuilds everything. The whole point of monorepo tooling is affected-detection: given the set of changed files, compute the graph of projects that depend on them and build only that subset. Tools like Bazel, Nx and Turborepo do this at build-graph level; CI platforms do a coarser version with path filters on pipelines or jobs. With either approach a typo fix in one service shouldn't rebuild the other forty.
How does versioning work when everything shares a repo?
Two common patterns. Fixed versioning gives every package the same version bumped in lockstep (simple, but every release forces a version on code that didn't change). Independent versioning lets each package version on its own, usually driven by conventional-commit messages and a tool like Changesets or Nx release. Independent versioning matches how users actually consume the packages; fixed versioning is easier to operate and works well for tightly coupled internal projects.
When is a polyrepo actually the right answer?
When teams need hard boundaries. Separate compliance zones (a PCI service that must not share history with the marketing site), open-source libraries with an external contributor community, or an acquired product on a completely different tech stack are all cases where the overhead of one repo per project pays for itself. The heuristic: if two projects never need a synchronous cross-cutting change, forcing them into the same repo just adds ceremony.
Suggest a new word or an edit to an existing one. Every submission is reviewed before it goes live.