---
name: monorepo-boundary-guard
description: Structures a pnpm or Turborepo workspace so packages have real boundaries — apps versus packages, internal package conventions, version drift caught by syncpack, a task graph whose cache actually hits, and tsconfig project references. Use when setting up a monorepo, fixing slow or wrong cache behaviour, or resolving dependency drift across workspaces.
license: MIT
compatibility: pnpm or Bun workspaces with Turborepo or Nx, and TypeScript. Requires permission to run the workspace's package manager and build.
metadata:
  category: devops
  version: "1.0.0"
---

# 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/shared` or `packages/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-mismatches`** in 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 for `build`. It is usually **wrong for `typecheck` and `lint`**,
  which can run against source and need no upstream artifacts. Declaring it serialises the graph and
  throws away most of your parallelism.
- Declare `inputs` narrowly. 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 `outputs` accurately, 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.json` extended 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 `paths` aliases.** `paths` are 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/Button` bypasses
  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-mismatches` is clean and runs in CI.
- [ ] A task run twice reports a **cache hit** on the second run — paste the summary showing it.
- [ ] `typecheck` is 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.
