Monorepo Boundary Guard
A monorepo is a bet that shared code is cheaper than shared nothing. The bet pays off only if the
packages have real boundaries; without them you have a single application with extra package.json
files, a build that touches everything on every change, and a cache that never hits.
Apps and packages are different things
apps/*— deployable. Never imported by anything. An app importing another app means one of them is a package.packages/*— imported, never deployed. Each has one clear responsibility and a name that says it.- No
packages/common,packages/sharedorpackages/utils. Same rule as inside a single repo, with higher stakes: everything depends on it, so every change to it invalidates every cache entry in the workspace.
Keep internal packages private and unbuilt where your bundler allows — "main": "./src/index.ts"
with the app compiling the TypeScript directly. It removes a build step, a watch process and a whole
class of "stale dist" bugs. Add a build step only when you publish externally or hit a real
constraint.
Version drift is a real bug
Two packages on different versions of the same dependency ship both to the browser, or fail at
runtime in ways the type checker cannot see — two copies of React, two of a validation library whose
instanceof checks now fail.
- Run
syncpack list-mismatchesin CI and fail on drift. - Use the pnpm catalog so a version is declared once and referenced as
catalog:everywhere. - Internal dependencies use
workspace:*, never a version number. - Peer dependencies for framework packages, so the app owns the single copy.
Make the task graph tell the truth
Most cache disappointment comes from one mistake: declaring dependencies the task does not have.
dependsOn: ["^build"]is correct forbuild. It is usually wrong fortypecheckandlint, which can run against source and need no upstream artifacts. Declaring it serialises the graph and throws away most of your parallelism.- Declare
inputsnarrowly. A task listing the whole package directory is invalidated by a README edit. List the source globs and the config files that genuinely affect the output. - Declare
outputsaccurately, or the cache stores nothing and silently replays a no-op. - Mark tasks with side effects — deploys, migrations — as
cache: false. A cached deploy is a very bad afternoon.
Verify rather than assume: run a task twice and confirm the second run reports a cache hit. If it does not, the inputs are wrong.
TypeScript across packages
- A shared
tsconfig.base.jsonextended by each package. - Project references if packages are built; each references its dependencies and gets incremental builds. Skip them entirely if packages are consumed as source — they add ceremony for nothing.
- Use workspace resolution, not
pathsaliases.pathsare a compiler fiction the bundler and Node do not share, so they work until something runs outside the type checker. - Never reach across a package boundary with a relative path.
../../packages/ui/src/Buttonbypasses the boundary, the cache and the public surface. Lint against../..crossing a package root.
Judgement calls
- When is a package a package? When two apps use it, or when it has a genuinely different release cadence. Extracting a package for one consumer buys ceremony and no isolation.
- Turborepo or Nx? Turborepo for task running and caching over an existing structure. Nx when you want generators, an enforced dependency graph and plugin-driven conventions.
- How granular? Fewer, larger packages beat many tiny ones. Every boundary costs a build edge, a version to keep in step and a place for a cycle to hide.
Verification gate
-
syncpack list-mismatchesis clean and runs in CI. - A task run twice reports a cache hit on the second run — paste the summary showing it.
-
typecheckis not needlessly gated on^build; confirm by reading the resolved task config. - No app imports another app; no relative import crosses a package root.
- A clean clone installs and builds from scratch with the documented command.
- Paste the command output rather than describing it.