A monorepo that runs the full test suite on every commit works fine with ten projects. With two hundred, CI spends twenty minutes re-testing code a given commit never touched, and the team learns to ignore failures because “it’s probably a flaky test, not my change.” The problem isn’t monorepo size: it’s the missing link between what actually changed and what actually needs re-checking.
The dependency graph replaces the calendar
A monorepo build tool (Nx, Turborepo, Bazel, or an in-house equivalent) first constructs a graph: every package declares its internal dependencies, and the tool derives who depends on whom. A commit touching packages/auth doesn’t trigger a rebuild of the whole repo, only auth and everything that depends on it, directly or transitively.
# Nx: list projects affected relative to the main branch
nx show projects --affected --base=main
# Turborepo: same idea, filtered by working-tree changes
turbo run test --filter=...[main]
The difference from a plain git diff on changed paths is the word “transitively”: if payments depends on auth, a change in auth must revalidate payments even though no file inside payments was touched. Naive path-based filtering misses that dependency and lets a regression through.
The remote cache: never redo work someone already did
The graph says what to rebuild; the cache says whether it’s worth doing at all. Every task (build, test, lint) is hashed from its real inputs: source file contents, declared dependencies, tool versions. If that hash was already computed, anywhere, by anyone on the team or by CI, the result is simply downloaded from a remote cache shared across the whole organization:
// nx.json (excerpt)
{
"tasksRunnerOptions": {
"default": {
"options": {
"cacheableOperations": ["build", "test", "lint"],
"remoteCache": { "url": "https://cache.example.internal" }
}
}
}
}
A developer rebuilding locally a branch CI already built pulls the cached result instead of recompiling, which turns an effective remote cache into a measurable velocity gain well beyond CI itself.
The classic trap: the hash that lies
The cache is only trustworthy if the hash genuinely captures everything that influences the result. The most common mistake: an environment variable or config file that affects the build but never enters the hash calculation. The symptom is a cache hit serving a stale artifact, a bug that “only shows up in CI,” or one that disappears after a --no-cache run for reasons nobody can explain.
# Diagnosing a suspect cache: compare the computed hash against
# what the tool should have accounted for
nx run payments:build --verbose
# Nx prints the full list of inputs that composed the task's hash
The discipline that avoids this trap: explicitly declare every input that influences a task (files, environment variables, tool versions), and never assume the tool correctly guesses what matters.
What this actually buys the team
CI that only tests what’s genuinely affected restores meaning to failures: a red test signals a real problem tied to the change, not a flaky test drowned in a twenty-minute run that tests everything. It’s also what keeps a CI/CD pipeline sustainable as a monorepo grows, without rewriting how the code is organized. This selective-build discipline is the same reflex behind a successful CI/CD industrialization: only revalidate what actually changed.
Takeaway
A monorepo build that scales rests on two distinct mechanisms: a dependency graph that determines what needs revalidating, and a remote cache that avoids redoing work already done elsewhere. The graph must include transitive dependencies, not just modified files. The cache is only trustworthy if its hash genuinely captures every input that influences the result, otherwise it silently serves stale artifacts.