Skip to content
SJ
All writing
9 min read

A Monorepo Without the Slow Builds

Teams adopt a monorepo for shared code, then find every change rebuilds everything. That is a graph problem, not a monorepo problem.

MonorepoNXArchitectureCI/CD

The monorepo complaint is always the same: it started well, and now a one-line change in one application runs the entire test suite for everything. That is not what a monorepo costs. It is what an unconfigured one costs, and the difference is whether the tooling understands the dependency graph or just sees a folder of projects.

What you actually get

Worth being clear about the benefit, because it is narrower than the enthusiasm suggests.

Atomic cross-project changes. Changing a shared type and updating all six consumers happens in one commit, reviewed together, merged together. In separate repositories that is six pull requests, a version bump, and a window during which the versions disagree.

One version of everything. No package publishing to consume internal code, no diamond dependency where two applications need different versions of the same library. Everything is built from the same source.

Refactoring across boundaries. Renaming something and finding every usage works, because every usage is present.

What you do not get is looser coupling. A monorepo makes it trivially easy for anything to import anything, which is why the boundaries section below is not optional — without it, you have built a distributed monolith with better ergonomics.

The affected graph is the whole point

The tooling builds a graph of which project depends on which, derived from the actual imports rather than from configuration. Given a base commit, it computes which projects the diff touches — plus everything downstream of them — and runs tasks only for those.

# only what this branch actually affects
nx affected -t lint test build --base=origin/main

A change to one application's component tests that application. A change to a shared UI library tests every application that imports it, transitively, because that is genuinely the blast radius. The graph is not an optimisation you tune; it is a description of reality, and the speed comes from acting on it.

Two things break affected detection in practice, and both look like the tool malfunctioning. A shallow clone in CI means the base commit is not present, so the comparison silently degrades to everything. And a file that everything depends on — a root configuration, a global type declaration, the lockfile — makes every project affected, correctly but unhelpfully. Keep genuinely global files few and stable, because every change to one is a full rebuild by definition.

Boundaries, or you have written a monolith

Nothing stops the admin application importing a module from inside the customer application. It resolves, it compiles, it ships, and now those two applications cannot be reasoned about or deployed independently. Six months of that and extracting anything is a research project.

The fix is to tag projects and declare which tags may depend on which, then enforce it as a lint rule so a violation fails CI rather than review:

// tags describe two axes: scope, and type of project
// scope:admin, scope:shop, scope:shared
// type:app, type:feature, type:ui, type:util

"depConstraints": [
  { "sourceTag": "scope:admin",  "onlyDependOnLibsWithTags": ["scope:admin", "scope:shared"] },
  { "sourceTag": "scope:shop",   "onlyDependOnLibsWithTags": ["scope:shop", "scope:shared"] },
  { "sourceTag": "type:ui",      "onlyDependOnLibsWithTags": ["type:ui", "type:util"] },
  { "sourceTag": "type:util",    "onlyDependOnLibsWithTags": ["type:util"] }
]

Two rules encoded there are worth naming. Applications may depend on libraries and never on other applications. And the layering is one-directional — a UI library cannot reach up into a feature, which prevents the cycle where everything transitively depends on everything.

The other half of a boundary is the public surface. Each library should export through a single entry point, and importing deep paths from another library should be a lint error. Without that, the boundary exists on paper while consumers reach into internals, and any refactor of those internals is a breaking change to code you did not know was watching.

What belongs in a library

The usual failure is a shared or common library that accumulates everything with no theme. It ends up depended on by every project, which means every change to it rebuilds and retests the entire repository — reintroducing exactly the problem the graph was supposed to solve.

Split by cohesion, not by kind. A library called utils is a drawer; a library called currency is a module. When you must split, split along the axis that changes together — the code that is always edited in the same commit belongs in the same library.

A test that works: if you cannot describe what a library is for in one sentence without using the word “and,” it is at least two libraries.

Caching, local and shared

Computation caching stores task results keyed by a hash of the inputs — source files, dependencies, configuration, the command. Run the same task with the same inputs and the result is replayed instantly, including its terminal output.

Locally this makes repeated commands nearly free. The larger win is a shared remote cache: CI populates it, and a developer pulling main gets cache hits for everything they did not change. In a large repository this is often the difference between a fifteen-minute pipeline and a two-minute one, because most of the work has already been done by someone else.

Correctness depends entirely on inputs being declared accurately. A task that reads an environment variable or a file the tool does not know about will be cached against inputs that did not capture what actually determined the output — and a wrong cache hit is a far more confusing bug than a slow build. If results seem inconsistent, the first suspicion should be an undeclared input.

When separate repositories are right

  • Different release cadences with real independence. A library published to external consumers on its own versioning schedule does not benefit from being in your product repository.
  • Different access requirements. A monorepo is largely all-or-nothing for read access. If a contractor should see one service and nothing else, that is an argument the tooling cannot answer.
  • Genuinely unrelated products. Sharing a repository without sharing code buys the costs and none of the benefits.
  • No appetite for the tooling. A monorepo without affected-detection, boundary enforcement and caching is worse than separate repositories. The tooling is not an enhancement; it is the thing that makes the model work.

The short version

The value is atomic cross-project change, not loose coupling — coupling gets easier, so it must be constrained deliberately. Run tasks by affected graph rather than by folder, and check clone depth when it misbehaves. Tag projects and enforce dependency direction in CI. Keep libraries cohesive so no single one is depended on by everything. Add a shared remote cache early. And if you are not going to configure any of that, use separate repositories.

Written by Saumya Jain

Full Stack Engineer working on headless commerce, NestJS microservices, and real-time systems. Currently open to remote work.