Frontend Architecture
Monorepo boundaries: the dependency graph decides your CI bill
A two-line copy fix to a marketing page sits in CI for 41 minutes. The change touched one component, but the pipeline rebuilt and tested all 38 projects, because the import resolved through @acme/utils — a “shared utils” package that every app and library in the repo depends on. Touch anything that touches utils, and the affected graph is the whole repo. The team’s verdict in retro: “the monorepo is slow.” The monorepo wasn’t slow. The boundary was wrong.
The dependency graph is the product
A monorepo is not “all the code in one folder.” That’s just a folder. The thing that makes a monorepo fast or slow is the dependency graph — the directed graph of which package imports which. Turborepo and Nx build this graph by parsing your imports and package.json workspaces, then use it for two jobs: ordering (build a library before the app that consumes it) and scoping (figure out the blast radius of a change).
Every node is a package or project; every edge is a dependency. A healthy graph is wide and shallow: many leaf libraries, few things depending on any one of them. A sick graph has a hub — one node with a huge fan-in that sits on the path between everything. That hub is usually called utils, common, or shared, and it is the single most expensive mistake in a monorepo, because the affected-graph algorithm is only as good as the graph you give it.
”Affected” vs build-everything
The naive CI pipeline runs build && test across every package on every PR. At 5 packages nobody notices. At 40, a full run is 30–45 minutes and your merge queue backs up. The fix is affected (Nx) / filtered (Turborepo): compute the diff against the base branch, find which projects changed, walk the graph to add every project that depends on a changed one, and run tasks only for that set.
The numbers are dramatic when the graph is clean. A 20-package workspace commonly drops from ~14 min (build everything) to 3–5 min (affected only). When a change is local to a leaf, the affected set can be a single project and CI finishes in under a minute. The whole bet of a monorepo rests on this: most PRs touch a small slice, so most PRs should pay for a small slice.
| Strategy | What runs per PR | ~40-pkg CI time | Failure mode |
|---|---|---|---|
| Build everything | All projects, always | 30–45 min | Scales with repo size, not change size |
| Affected only | Changed projects + their dependents | 1–5 min (typical PR) | A hub package makes “affected” = everything |
| Affected + remote cache | Only tasks with no cached output | seconds–2 min on a warm cache | Cache key too broad → false misses |
Remote caching: the second multiplier
Affected scoping says “don’t run untouched projects.” Remote caching says “don’t even run touched projects if someone already produced this exact output.” Turborepo and Nx hash every input to a task — source files, dependencies, env vars, the task config — and store the output (build artifacts, test results, logs) under that hash in a shared cache. If a teammate, or your last CI run, already built that hash, the task is a cache hit: the output is downloaded instead of recomputed, in milliseconds.
This is what closes the loop between developers and CI. Vercel measured an 80% drop in publish times for Next.js once SWC builds were shared through the remote cache. Mercari reported roughly a 50% reduction in Turbo task duration and a 30% reduction in total job duration after wiring Turborepo’s remote cache into GitHub Actions. A common warm-cache figure is CI going from ~6 minutes to ~45 seconds. Cache hit rate is the metric to watch: in a many-package repo where most PRs are local, hit rates of 80–90% are achievable, and each point of hit rate is CI minutes (and money) you don’t spend.
Why this works
The cache key is the whole game. If you forget to include an input — say, a shared tsconfig or an env var that changes output — you get a false hit: the cache serves a stale artifact and ships a bug. If you include too much — a volatile file that changes every run, or an over-broad glob — you get a false miss: the hash never repeats, the cache is useless, and you’ve paid the storage cost for nothing. Tuning cache hit rate is mostly tuning what counts as an input.
Boundaries: stop the hub before it forms
The reason “shared utils” wrecks the affected graph is structural, not a tooling bug: if 30 projects import it, then by definition a change to it affects 30 projects, so the cache invalidates for all of them and CI rebuilds the world. The fix is to enforce module boundaries so the graph can’t degenerate into a hub-and-spoke mess in the first place.
Nx does this with tags and an ESLint rule, @nx/enforce-module-boundaries. You tag each project (type:feature, type:ui, scope:checkout) and declare depConstraints: a feature may depend on ui, ui may depend only on util, nothing may depend on a feature. Imports that violate the rule fail lint — and the same rule catches circular dependencies (“Circular dependency between A and B detected”), which are the other way a graph turns to spaghetti. Turborepo leans on package boundaries and package.json exports to make a package’s public surface explicit, so consumers can’t reach into its internals. The senior move is to split the god-package: instead of one utils everyone imports, ship @acme/format-date, @acme/http, @acme/result, each with a narrow fan-in, so a change to date formatting invalidates only the handful of things that actually format dates.
CI hits 40+ min: every PR rebuilds the repo because everything imports one @acme/utils. Pick the fix that addresses the cause.
Monorepo vs polyrepo, honestly
The monorepo’s superpower is the atomic change: rename a function in a shared library and update every caller in one PR, with one CI run proving it all still compiles. No version bumps, no registry, no “deploy the lib, wait, bump consumers, deploy them.” The cost is real: you need the graph-aware tooling (Nx/Turborepo/Bazel), remote caching, and enforced boundaries, or the repo collapses under its own CI. Polyrepo flips it: each repo is trivially fast and independent, but shared code travels through a registry, and a breaking change in a shared package means coordinated bumps, version drift, and “which service is on the old version” spelunking. The honest framing: monorepo trades higher tooling complexity for lower coordination cost; polyrepo trades lower tooling complexity for higher coordination cost. Pick monorepo when packages share a lot and change together; pick polyrepo when teams are truly independent and communicate only over APIs.
In a 40-package monorepo, what does 'affected' (Nx) / 'filtered' (Turborepo) actually compute for a PR?
Your remote cache hit rate is high, but one PR served a stale build that shipped a bug. Most likely cause?
Order how a graph-aware tool decides what to run for a PR:
- 1 Build the dependency graph from imports + workspace package.json files
- 2 Diff the PR against the base branch to find directly changed projects
- 3 Walk the graph to add every project that depends on a changed one (the affected set)
- 4 Hash each affected task's inputs and check the remote cache for that hash
- 5 Run only the tasks that miss; download outputs for the tasks that hit
- 01Explain why a single 'shared utils' package can push monorepo CI from 4 minutes to 40, and what to do about it.
- 02What's the difference between affected scoping and remote caching, and why do you need both?
A monorepo is fast or slow depending on one thing: the dependency graph. Graph-aware tools (Nx, Turborepo) parse imports into a directed graph and use it to compute the affected set — the changed projects plus everything that depends on them — so most PRs build a slice instead of the world, often dropping a 40-package repo from 30–45 minutes to 1–5. Remote caching multiplies this by hashing each task’s inputs and downloading stored outputs on a hit, which is how teams report 50–80% CI reductions and warm-cache runs in seconds; the catch is the cache key, where a missing input causes a dangerous false hit and an over-broad one causes useless false misses. None of it survives a bad graph: a single “shared utils” hub makes the affected set the whole repo and cache-misses every dependent, so the discipline that actually keeps a monorepo fast is enforced module boundaries — Nx tags, the enforce-module-boundaries lint rule, and explicit package exports — that split god-packages and ban circular and cross-domain imports. Against polyrepo, the trade is honest: monorepo buys atomic cross-package changes at the price of graph-aware tooling and boundary discipline; polyrepo buys trivial per-repo speed at the price of registry versioning and cross-repo coordination.