skip to content

Monorepo Build Tooling

Purpose-built orchestrators — Bazel, Nx, Turborepo, Lerna, Pants — model the repo as a project graph with task pipelines, workspaces and remote caching. This is about what they add over a plain build tool, not about any one tool's syntax.

part ofSoftware design & architectureoverview, primer and where to startread it →
on this pageshow

questions

6

In a monorepo containing dozens of separate packages, why do teams adopt a dedicated build orchestrator (like Nx or Turborepo) instead of just writing a shell script that runs `build` and `test` in every package folder?

level: juniorimportance: must knowfreq 70%

answer

  1. project graph = DAG of package deps
  2. affected = changed + graph walk
  3. cache key = hash of inputs
  4. topological task order
  5. remote cache = shared, not just local

basics

~20 s

A shell script rebuilds and tests every package every run, which gets slow fast. A monorepo orchestrator tracks changes and dependencies between packages, so it only reruns what's actually affected and reuses cached results for the rest.

solid answer

~50 s

A naive script treats every package as independent, so CI time scales with total repo size, not with the size of the change — untenable once you have hundreds of packages. Dedicated tools like Nx, Turborepo, Bazel, or Pants build an explicit dependency graph of the workspace, letting them: (1) compute the 'affected' subset of packages for a given diff, (2) order tasks topologically so a package's tests never run before its dependencies build, (3) parallelize independent branches of the graph across cores or machines, and (4) cache task outputs keyed by input hashes, so a task whose inputs haven't changed returns instantly from cache instead of re-executing. The net effect is CI and local dev feedback loops that stay roughly constant-time relative to the size of a single change, not the size of the whole repository.

go deeper

for a junior

Should recognize the basic pain point (rebuild-everything-every-time doesn't scale) and know that these tools exist to avoid it, even without naming the graph/cache mechanics precisely.

for a middle

Should explain affected-package detection via a dependency graph and describe local caching by input hash, and name at least one real tool (Nx, Turborepo, Bazel).

for a senior

Should explain topological task ordering, remote/shared caching, and articulate the hermeticity trade-off between lighter JS-ecosystem tools and stricter tools like Bazel.

for a principal

Should reason about when the investment in this tooling layer pays off organizationally (repo size, team count, CI cost) versus when a simpler per-package CI matrix is the right call, and how graph quality becomes an engineering-culture problem, not just a tooling one.

## Why a loop over every package stops working Picture a repository holding 300 separate packages — some libraries, some services, some frontend apps — all versioned together in one git history. The "run everything in a loop" approach means a script iterates over every package directory and executes `build`, then `test`, then `lint`, regardless of what the current commit actually touched. That works fine at 5 packages. At 300, a one-line documentation fix in package #12 triggers a full rebuild and full test suite of packages #1 through #300, most of which have zero relationship to the change. From there: - **CI minutes balloon.** - **Feedback loops stretch** from seconds to tens of minutes. - **Engineers start batching changes** or avoiding small commits just to amortize the cost — the opposite of what you want from a fast-moving codebase. ## The project graph Monorepo build orchestrators (Nx, Turborepo, Bazel, Pants, and historically Lerna for JS package publishing) solve this by making the workspace's dependency structure a first-class, machine-readable object: the **project graph**. Each package (or "target" in Bazel terms) declares what it depends on: - **explicitly in a manifest** — package.json's `dependencies`, or a Bazel `BUILD` file's `deps` attribute; - **inferred by statically scanning import statements**, as Nx does for JS/TS. The tool builds a **directed acyclic graph (DAG)** from these declarations: an edge from A to B means A depends on B, so B must be built before A, and a change to B can only affect A, never the reverse. ## Affected detection and task scheduling With that graph in hand, the orchestrator can answer two questions cheaply: 1. **Which packages changed, given this diff?** — usually via `git diff` against a base ref. 2. **Which other packages are affected, given those changed packages?** — by walking the graph forward from every changed node along dependency edges. Only the resulting "affected" set gets tasks scheduled — everything outside it is skipped entirely, not even considered. The task pipeline then executes those tasks in **topological order** (a package's `build` task can't start until every dependency's `build` task has finished), and because sibling branches of the graph are independent, the scheduler parallelizes them across available CPU cores or, with remote execution, across a cluster of machines. ## Caching layered on top Layered on top of graph-aware scheduling is **caching**: each task execution is hashed based on its inputs — - source file contents, - the exact command and flags, - transitive dependency hashes, - relevant environment/toolchain versions. If that hash has been seen before (in a local `.cache` folder or, more powerfully, a shared **remote cache** reachable by every CI job and every engineer's laptop), the tool skips execution and just replays the previous output (build artifacts, test results, exit code) from the cache. This is why a second CI run of an unchanged commit, or a build that only touches an already-cached branch of the graph, can complete in seconds — nothing actually re-executes; results are fetched. ## The trade-off: correctness versus speed The core trade-off is correctness versus speed. Skipping work and trusting cached results is only safe if the hash genuinely captures everything that could change the output. Miss an input — an environment variable the build secretly reads, a system time dependency, a non-deterministic code generator — and you get a "cache hit" that silently serves a stale or wrong artifact, which is far worse than the slow baseline it replaced because failures now appear nondeterministic and hard to reproduce. Getting **hermeticity** right (Bazel invests heavily here via sandboxed execution) versus accepting some risk for less setup cost (Turborepo/Nx are more permissive by default) is exactly the tuning knob different tools expose. ## Failure modes in production Failure modes in production typically look like: - **(a)** a package's dependency graph is stale or incomplete — someone imports a module without declaring the dependency in the manifest, so the "affected" calculation misses it and CI reports green on a broken change; - **(b)** cache poisoning, where a flaky test passes once, gets cached, and then every subsequent "unchanged" run reports the stale pass even after the underlying bug resurfaces; - **(c)** graph explosion, where a widely-imported `utils` package sits at the root of the graph, so almost any change marks the entire repo as affected, defeating the whole optimization — a sign the graph itself needs restructuring, not just better caching. ## Where it shows up A concrete real-world case: Vercel's own monorepo (and many customers using Turborepo) rely on this exact affected + remote-cache model to keep PR CI times roughly proportional to the size of the change rather than the size of the repository, even as the number of packages grows into the hundreds.

  • What happens if a package's declared dependencies in its manifest don't match what its code actually imports?
    The project graph the orchestrator builds is wrong, so 'affected' detection can either miss a package that should have been retested (a false negative that lets a breaking change slip through green CI) or mark too much as affected (a false positive that wastes CI time). Tools like Nx mitigate this by statically parsing import statements rather than trusting only the manifest, but Bazel requires explicit, exact `deps` declarations and will fail the build if code references something not listed — trading upfront friction for a graph you can actually trust.
  • Why can't you just cache at the level of the whole repository instead of per-task?
    A whole-repo cache key changes on literally every commit, since some file somewhere is always different, so the hit rate would be near zero once you have any regular commit cadence. Per-task, per-package caching means only the tasks whose specific input set changed get a cache miss — a docs-only commit produces cache hits for every build/test task in the graph except the one package that changed, which is where nearly all the speedup comes from.
  • How is this different from just parallelizing `npm test` across packages with a tool like `concurrently`?
    Blind parallelization still executes every task for every package — it makes the wasted work happen faster, not less wasted. The orchestrator's value is in skipping tasks entirely (via affected detection and caching), which parallelization alone can't provide; the two are complementary, since orchestrators like Nx/Turborepo also parallelize the reduced task set.

Like a smart assembly line that only re-machines parts whose blueprint changed, pulls a finished part off the shelf if an identical one was made before, and never starts assembling a car body before its doors are ready.

saying these in an interview costs you the question

  • Says the monorepo tool 'just runs things in parallel' with no mention of dependency graph or caching
  • Assumes 'affected' packages are computed by naive string-matching file paths rather than graph traversal
  • Thinks caching only works locally and doesn't know remote/shared caching exists
  • Can't explain what makes a cache hit unsafe (non-hermetic inputs)
  • Confuses this layer with the underlying build tool it wraps (e.g., says Nx compiles TypeScript itself)

context

open as a page

How does remote build caching in tools like Bazel or Nx decide whether it can reuse a previous task's result instead of re-executing it, and what property does the task need to have for that reuse to be safe?

level: middleimportance: must knowfreq 75%

basics

~20 s

The tool fingerprints everything that could affect a task's output — its source files, commands, and dependencies — into one hash. If a previous run already produced and uploaded a result for that exact hash, it downloads that instead of rerunning. This only works if the same inputs always produce the same output.

open as a page

When would a team choose Bazel over a lighter workspace-orchestration tool like Turborepo or Nx for their monorepo, and what does that choice cost them operationally?

level: seniorimportance: must knowfreq 60%

basics

~20 s

Bazel is worth it when you need rock-solid reproducible builds across many languages and huge scale, but it takes real investment to set up and maintain. Turborepo and Nx are much easier to adopt for a JS-centric repo but give weaker guarantees and trust the underlying tools more.

open as a page

A CI pipeline for a 200-package monorepo needs to decide which packages to test on a pull request that touches 3 files. Walk through how a monorepo orchestrator computes that 'affected' set, and what breaks if the dependency graph it relies on is incomplete.

level: middleimportance: should knowfreq 65%

basics

~20 s

It looks at which files changed, maps them to their owning packages, then walks the dependency graph outward to find every package that depends on those, directly or indirectly. If the graph is missing an edge, an affected package can be skipped and ship broken without CI catching it.

open as a page

A team enables shared remote caching in their monorepo build tool. Soon after, engineers start seeing CI report a task as passing via a cache hit, even though the same code fails when run fresh locally. What's the likely root cause of this class of bug, and how do you prevent it going forward?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Some hidden input the task depends on (like an environment variable, network response, or timestamp) isn't included in the cache key. So a run that should be treated as different from a previous one gets matched to it anyway, and the old, wrong result gets replayed instead of rerunning.

open as a page

As a principal engineer deciding whether to migrate a 500-engineer monorepo from framework-native build scripts to a strict tool like Bazel purely for hermetic, remote-executed builds, how would you actually evaluate the ROI, and what organizational failure modes should you watch for during the migration itself?

level: principalimportance: nice to knowfreq 30%

basics

~20 s

You weigh the ongoing CI-time and developer-hours saved against the real cost of migration and the ongoing tax of maintaining strict build declarations. Big or fast-growing polyglot orgs usually win; migrations commonly fail not from the tech but from teams not budgeting real time for it and reverting under deadline pressure.

open as a page