monorepo
Monorepo reference pack (2026): tool selection across Nx, Turborepo, pnpm workspaces, Bazel, Moon, and Make; layout plus enforced dependency boundaries; workspace protocols, catalogs, peer dependencies, hoisting, and dedupe; shared tsconfig, ESLint, and Prettier packages; task graphs, affected detection, remote caching, CI sharding, and merge queues; workspace generators plus API and schema codegen; Changesets, fixed vs independent versioning, release trains, and publishing; polyrepo-to-monorepo migration; CODEOWNERS, drift detection, and monorepo health metrics. Use when creating, migrating, reviewing, or operating a monorepo: choosing or replacing a task runner, fixing slow or flaky CI, defining package boundaries and ownership, deduplicating dependencies, wiring shared configuration, automating releases, or auditing monorepo health. Depth lives in references/, one topic per file.
Monorepo
A monorepo is one repository holding multiple deployable or publishable units that share a dependency graph, a toolchain, and a single change set. It buys atomic cross-package changes, consistent versions, one CI story, and cheap code reuse. It costs investment in task orchestration, caching, boundary enforcement, and governance. A monorepo is not a folder layout; it is an operating model.
This pack is an entrypoint plus nine deep references. Read the core rules first; they encode defaults that rarely deserve debate. Then load only the references the task needs. Each reference starts with a one-line scope and carries version floors, decision tables, minimal runnable snippets, anti-patterns, and checklists.
Content targets the stable ecosystem of 2026: Nx 20+/21+, Turborepo 2.x, pnpm 9/10+, Bazel 7/8 with Bzlmod, Moon 1.x, GNU Make 4.3+, Changesets, and current CI platforms (GitHub Actions, GitLab CI). Versions move monthly: treat stated numbers as floors, pin what you use, and verify upstream before quoting numbers in deliverables.
Non-negotiable core rules
- One package manager, one lockfile, one Node/toolchain version per repository.
packageManageris pinned in the root manifest; CI installs frozen (pnpm install --frozen-lockfile,npm ci). See 03-dependencies. - Internal dependencies use the workspace protocol (
workspace:*), neverfile:paths or published ranges against in-repo packages. See 03-dependencies. - Boundaries are declared and machine-enforced. Default direction:
types -> utils -> data-access -> feature -> app. Violations fail lint, not review. See 02-structure-boundaries. - Every package exposes an explicit public API through
exportsand an entrypoint; deep imports into another package'ssrc/are banned. See 02-structure-boundaries and 04-shared-config. - Shared configuration lives in versioned packages (
@acme/tsconfig,@acme/eslint-config), not in copy-pasted root files or per-package drift. See 04-shared-config. - Every cacheable task declares inputs, outputs, and environment. A missing input or env var silently poisons the remote cache for everyone. See 05-caching-ci.
- Pull requests run affected-only; the default branch runs the full graph. Shallow clones break affected detection, so CI must fetch enough history. See 05-caching-ci.
- Remote cache is trusted infrastructure: scoped credentials, no secrets in artifacts, cache writes restricted to trusted branches, poisoning treated as an incident. See 05-caching-ci.
- Generated code is reproducible and never hand-edited: pinned generator versions, committed output or a CI drift check, explicit
DO NOT EDITheaders. See 06-codegen. - The version policy is explicit and documented per group: fixed, linked, or independent. Internal-only packages are
privateand excluded from release automation. See 07-versioning-release. - Publishing is automated with provenance, from a clean CI checkout, never by hand and never from a dirty tree. See 07-versioning-release.
- Every package has an owner in
CODEOWNERS(or the platform equivalent), including generated and tooling packages. Ownership coverage is measured, not assumed. See 09-governance-metrics. - Drift is detected mechanically: version skew, config divergence, unowned projects, and undeclared dependencies all have checks. See 09-governance-metrics.
- CI time, cache hit rate, and flake rate are tracked budgets. Unchecked growth is a defect, not a fact of life. See 05-caching-ci and 09-governance-metrics.
Decision tables
Task runner and workspace tool
| Situation | Default | Alternative | | --- | --- | --- | | JS/TS app monorepo, frontend-heavy, remote cache wanted | Turborepo + pnpm workspaces | Nx | | Large JS/TS repo, generators, plugins, project graph UI, release tooling | Nx | Turborepo | | Polyglot (TS, Go, Python, Rust) with hermetic, distributed builds | Bazel (Bzlmod) | Moon, Pants | | Polyglot repo, small platform team, wants toolchain management | Moon | Nx with plugins | | Library publishing workspace only | pnpm workspaces (+ Changesets) | npm/Yarn workspaces | | Make-based legacy or C/C++ embedded repo | GNU Make, modernized | Bazel, Meson |
Repository layout
| Need | Place | Notes |
| --- | --- | --- |
| Deployable units | apps/<name> | thin; wired from libs |
| Publishable libraries | packages/<name> | public API + build output |
| Non-published shared code | libs/<scope>/<name> or packages/ | keep taxonomy consistent |
| Tooling, generators, scripts | tools/ | owned by platform team |
| Cross-cutting docs and ADRs | docs/ | architecture decisions with dates |
Internal dependency contract
| Consumer | Dependency type | Version spec |
| --- | --- | --- |
| App consumes internal lib | dependencies | workspace:* |
| Lib consumes lib at runtime | dependencies | workspace:^ |
| Singleton runtime (React, ORM client) | peerDependencies | range covering supported majors |
| Type-only dependency | devDependencies or peerDependenciesMeta.optional | avoid runtime coupling |
| One-off CLI or generator | devDependencies | pin exactly |
Versioning and release
| Situation | Policy |
| --- | --- |
| Packages released together as one product | fixed group (single version) |
| Interdependent packages that must not diverge far | linked group |
| Independent libraries with separate audiences | independent |
| Internal apps and config-only packages | private, ignored by release tooling |
| Public API changes | semver major, changeset required in PR |
CI caching
| Concern | Practice |
| --- | --- |
| Compute tasks | local cache plus remote cache keyed on inputs |
| PR scope | affected-only with merge-base diff |
| Hot path | shard test suites by historical duration |
| Correctness | full graph on default branch, no cache exemption |
| Debugging | --force, --summarize, verbose hash inspection |
Load strategy
- Start with the index below; load at most two references per task. The core rules already cover most reviews.
- Pair one structural reference (02-structure-boundaries or 03-dependencies) with one operational reference (05-caching-ci or 07-versioning-release) when a task spans design and delivery.
- 08-migration and 09-governance-metrics are the strategic passes; load them for whole-repo audits, reorganizations, and platform planning.
- When tool behavior differs from this pack, the installed tool version wins; verify upstream and prefer the pinned configuration in the repository.
Reference index
| File | Scope | Load when | | --- | --- | --- | | 01-tools.md | Nx vs Turborepo vs pnpm workspaces vs Bazel vs Moon vs Make: capabilities, fit, migration cost, decision table | Choosing or replacing the task runner, or justifying the current one | | 02-structure-boundaries.md | apps/packages/libs layout, package taxonomy, ownership, dependency direction, public APIs, enforcement tooling | Designing a repo layout or fixing boundary violations | | 03-dependencies.md | Workspace protocols, catalogs, peer deps, hoisting, dedupe, overrides, version policy, update automation | Adding dependencies, resolving version conflicts, or automating upgrades | | 04-shared-config.md | Shared tsconfig/ESLint/Prettier/test config packages, project references, internal package consumption, build outputs and exports | Setting up shared config or wiring internal package builds | | 05-caching-ci.md | Task graphs, hashing, local/remote caching, affected detection, CI sharding, merge queues, flaky task isolation | Making CI fast, correct, and flake-resistant | | 06-codegen.md | Workspace generators, schema-to-types pipelines, protobuf/OpenAPI codegen, generated-code consistency and drift checks | Adding code generation or fixing generated-code drift | | 07-versioning-release.md | Changesets, fixed vs linked vs independent versioning, release trains, canary/snapshot, publishing, changelogs | Designing release flow or publishing packages | | 08-migration.md | Polyrepo to monorepo, incremental adoption, hybrid strategies, history preservation, common failure modes | Planning or executing a consolidation or split | | 09-governance-metrics.md | CODEOWNERS, drift detection, healthy-monorepo metrics, budgets, documentation, platform team model | Auditing health, assigning ownership, or setting SLOs |
Port
- Port id —
skill://senior/monorepo(version inmetadata.port-version). - Kind — read-only reference pack; no side effects, no tools required.
- Entrypoint — this
SKILL.md; depth lives inreferences/. - Load modes
- Installed agent:
skill({ name: "monorepo" })in OpenCode; Claude Code reads<skills-dir>/monorepo/SKILL.md. - Orchestrator: read
SKILL.md, then load only the references the task needs. - Not installed: consumers MUST degrade gracefully using their own guidance and report the degraded mode. Never block on the pack.
- Installed agent:
- Consumer contract —
metadata.consumerslists the agents that may load it; consumers reference it asload skill monorepo (optional). - Stability —
stable; breaking changes bumpport-versionmajor.
References (9)
01-tools
Tool Selection
Scope: Nx, Turborepo, pnpm workspaces, Bazel, Moon, and Make compared on capability, fit, and migration cost.
Version floors (verify upstream; treat as minimums):
| Tool | Current line | Core | Notes |
| --- | --- | --- | --- |
| pnpm | 9/10+ | TS/JS | workspaces, catalogs, strict node_modules |
| Turborepo | 2.x | Rust | task graph over package-manager workspaces |
| Nx | 20+/21+ | TS plugins, Rust cache | project graph, generators, plugins, cloud |
| Bazel | 7/8+ | Java | Bzlmod is the default module system |
| Moon | 1.x | Rust | polyglot tasks plus proto toolchain manager |
| GNU Make | 4.3+ | C | universal, timestamp-based, no package graph |
1. What a task runner must provide
Evaluate tools against these capabilities, not against feature checklists.
- Graph — knows which packages depend on which, and in what order tasks must run.
- Scheduling — runs independent tasks in parallel with deterministic ordering and correct streaming.
- Affected detection — turns a git diff into the minimal set of packages and tasks to run.
- Caching — hashes declared inputs and restores declared outputs, locally and remotely.
- Distribution — remote cache or remote execution shared across machines and CI runners.
- Isolation and correctness — declared environment, hermetic-enough inputs, no ambient state.
- Observability — answers "what ran, why did it run, how long, was it cached".
A workspace manager (pnpm/npm/Yarn) provides install correctness and filtering; it does not provide 3-7. That gap is exactly what a task runner fills.
2. Capability matrix
| Capability | pnpm workspaces | Turborepo | Nx | Bazel | Moon | Make |
| --- | --- | --- | --- | --- | --- | --- |
| Dependency install | yes, strict | delegates to PM | delegates to PM | external (rules) | delegates to PM/toolchain | no |
| Task graph | partial (recursive) | yes | yes | yes | yes | manual |
| Affected detection | no | yes | yes | yes (query) | yes | no |
| Local cache | store only | yes | yes | yes | yes | no |
| Remote cache | no | yes | yes (Nx Cloud/self) | yes (native protocol) | yes (self/remote) | no |
| Remote execution | no | no | no (distributed tasks limited) | yes | no | no |
| Generators/codegen | no | turbo gen | extensive plugin generators | rules + genrules | templates | no |
| Polyglot native | JS only | JS tasks, any binary | plugins + arbitrary tasks | full | full | any |
| Hermeticity | n/a | moderate | moderate | strict | moderate | weak |
| Plugin ecosystem | large | small | very large | very large | small | n/a |
| Learning curve | low | low | medium | high | medium | low |
| Config format | YAML | JSON/JSONC | JSON | Starlark | YAML/TOML | makefiles |
| Incremental adoption | easy | easy | easy | hard | medium | easy |
3. pnpm workspaces
The install layer is the foundation of every JS/TS monorepo; choose it first, independently of the runner.
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
- "tools/*"
// root package.json
{
"private": true,
"packageManager": "pnpm@<pinned version>",
"scripts": {
"build": "pnpm -r --filter './packages/**' build",
"test:changed": "pnpm -r --filter '...[origin/main]' test"
}
}
Strengths: strict, content-addressed node_modules (no phantom dependencies), workspace: protocol, catalogs, reliable recursive and filtered runs, pnpm deploy for pruned deployments.
Weaknesses: filters approximate but do not replace a task graph; no task cache, no affected with correct dependency semantics (...[base] is useful but coarse), no remote sharing.
Use bare workspaces when the repo is small, build/test time is low, and the team values simplicity. Add a runner the moment CI walls out or cache hit rates become a recurring topic. See 03-dependencies for protocol and catalog details.
4. Turborepo
A Rust task runner over any package-manager workspace. It owns the graph and cache, not the install.
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["tsconfig.base.json"],
"globalEnv": ["CI"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", "!**/*.md"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"env": ["NEXT_PUBLIC_*"]
},
"test": { "dependsOn": ["build"], "outputs": [], "cache": false },
"lint": { "cache": true },
"dev": { "cache": false, "persistent": true }
}
}
Operational notes:
dependsOn: ["^build"]means "build dependencies first";["build"]means "my own build first". Confusing these is the most common configuration bug.outputsdrives cache restoration; an emptyoutputsmeans nothing is restored, which is correct only for tasks with no artifacts.--affected(withTURBO_SCM_BASE/TURBO_SCM_HEAD) or--filterselects the work set; both depend on sufficient git history.- Remote cache runs on Vercel infrastructure or self-hosted; use
signature: trueso cache artifacts are signed and untrusted writers cannot poison consumers. Verify the current hosting and pricing model upstream. turbo prune <pkg> --dockerproduces a minimal workspace for container builds.turbo boundaries(tag rules inturbo.json) can fail builds on undeclared cross-package imports; use it together with lint rules from 02-structure-boundaries.
Best fit: JS/TS product monorepos, frontend-heavy teams, teams that want a small config surface and remote cache without adopting a framework.
5. Nx
A batteries-included system: project graph, task runner, generators, plugins, module-boundary lint rules, and release tooling.
// nx.json
{
"targetDefaults": {
"build": { "dependsOn": ["^build"], "cache": true, "inputs": ["production", "^production"] },
"test": { "cache": true, "inputs": ["default", "^production"] }
},
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": ["default", "!{projectRoot}/**/*.spec.ts"],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
}
}
// packages/ui/project.json (or inferred from package.json)
{
"name": "ui",
"tags": ["scope:shared", "type:ui"],
"targets": {
"build": { "executor": "@nx/js:tsc", "outputs": ["{options.outputPath}"] }
}
}
Operational notes:
- Plugins infer targets from existing tool config (
@nx/vite,@nx/webpack,@nx/eslint), so the repo keeps standard files; pin plugin versions to the Nx major. nx affected -t build test --base=origin/main --head=HEADis the standard PR command;--paralleland--maxParallelcontrol workers.nx graphis the most valuable feature in practice: a visual, queryable dependency graph that shortens architecture reviews.nx releasesupports versioning and publishing with conventional commits or changesets; adopt it only if you are not already committed to Changesets. See 07-versioning-release.- Nx Cloud provides remote cache, distributed task execution, and analytics. Self-hosted caching exists but is less capable than the cloud product; verify features and pricing upstream.
Best fit: large JS/TS repos with many package types, teams that value generators and code organization rules, full-stack repos (React, Node, mobile), and organizations that want release tooling included.
Trade-offs: more concepts and configuration, faster release cadence, and plugin lockstep upgrades. A small repo pays overhead for capabilities it will not use.
6. Bazel
The reference implementation of hermetic, correct, distributed builds. Everything is an action with declared inputs and outputs; the cache is content-addressed and correct by construction when rules are hermetic.
Strengths:
- Polyglot with one graph: JS/TS (
rules_js,rules_ts), Go, Python, Rust, Java, C++, protobuf. - Remote execution across a farm of workers; remote cache is part of the protocol.
- Correct incremental and affected behavior at a scale no JS tool matches.
- Strong supply-chain story: pinned module versions (
MODULE.bazel.lock), vendored dependencies, reproducible toolchains.
Costs:
- Starlark
BUILD/MODULEfiles, toolchains, and rules to learn; every language needs rule maintenance. - Debugging is its own discipline; error messages are opaque until experience builds.
- Developers need
bazelsemantics (sandboxing, visibility,--config) that other tools hide. - Migration of an existing JS repo is measured in quarters, not sprints.
Best fit: large polyglot organizations, monorepos with millions of lines, regulated environments requiring reproducible builds, and repos already feeling hermeticity pain. For a JS-only repo of moderate size, Bazel is almost always the wrong cost/benefit. See 08-migration before committing.
7. Moon
A Rust task runner with an integrated toolchain manager (proto) and polyglot-first design.
# moon.yml (project-level)
language: typescript
tasks:
build:
command: tsc -b
inputs: ["src/**/*", "tsconfig.json"]
outputs: ["dist"]
test:
command: vitest run
deps: ["~:build"]
Strengths: one config style across languages, file groups and task inheritance (.moon/tasks/*.yml), affected detection, local/remote caching, and toolchain pinning without Bazel's Starlark burden.
Weaknesses: smaller ecosystem and community than Nx/Turborepo; fewer ready-made plugins; you may write more custom task wiring.
Best fit: polyglot repos (TS + Go + Python + Rust) where Bazel is too heavy and Turborepo does not cover non-JS tasks; teams wanting reproducible toolchains.
8. Make
GNU Make remains the lowest-common-denominator runner and a reasonable choice for C/C++/embedded or legacy repos, or as a thin front door (make ci) that delegates to a real runner.
PNPM_PKGS := $(wildcard packages/*)
.PHONY: install build test changed clean
install:
pnpm install --frozen-lockfile
build: install
pnpm -r --filter './packages/**' build
changed: install
pnpm -r --filter '...[origin/main]' test
clean:
rm -rf packages/*/dist
Known limits and pitfalls:
- Timestamp-based incrementality is not content-based;
git checkoutcan lie. - No affected detection and no remote cache; you rebuild or script your own.
.PHONYis required for non-file targets; forgetting it causes "nothing to be done".- Recursive
$(MAKE)breaks-jcorrectness unless order-only prerequisites are declared:packages/%: | node_modulesstyle rules help, but the graph stays manual. - Shell portability (tabs,
SHELL,set -e) is a recurring source of CI-only failures.
Use Make as glue, not as the monorepo engine, unless the domain is genuinely non-JS and simple. just is a friendlier command runner for the glue role; verify its current support surface upstream.
9. Adjacent tools worth knowing
| Tool | What it is | Consider when |
| --- | --- | --- |
| Rush | Microsoft's pnpm-based workspace manager with change files | enterprise JS repos needing strict policies |
| Lerna | legacy publish tool, now a thin layer over Nx/Turbo | only maintaining an existing Lerna repo |
| Lage | Microsoft task runner, lage pipeline config | small TS repos wanting a light runner |
| Wireit | npm-scripts enhancement with caching and dependencies | avoiding a new runner for small repos |
| Pants / Buck2 | polyglot build systems, Bazel-adjacent | teams that want Bazel semantics with different ergonomics |
| just / go-task | command runners, no graph | developer entrypoints and local glue |
Verify maintenance status and feature parity upstream before betting on any of these; the build-tool market consolidates and shifts quickly.
10. Migration cost
| From -> To | Effort | Risk | Notes |
| --- | --- | --- | --- |
| pnpm workspaces -> Turborepo | days | low | add turbo.json, keep scripts; adopt --affected |
| pnpm workspaces -> Nx | days to weeks | low-medium | graph inference works with existing configs; tags require decisions |
| Turborepo -> Nx | weeks | medium | target/input model differs; cache and CI must be re-validated |
| Nx -> Turborepo | weeks | medium | lose generators/graph UI unless replaced by scripts |
| any JS runner -> Bazel | months | high | full rules/toolchain investment; run a pilot first |
| Make -> any runner | weeks | medium | scripts and implicit ordering must be made explicit |
| Turborepo/Nx -> Moon | weeks | medium | rewrite task definitions; polyglot gains must justify it |
Rules for any migration:
- Never migrate tooling and repository structure in the same change window.
- Keep a green main branch: migrate one concern at a time, behind config flags where possible.
- Measure before and after with the same metrics (CI wall time, cache hit rate, install time). See 09-governance-metrics.
- Budget for CI rework; caching correctness is the hard part, not config translation.
11. Decision table by scenario
| Scenario | Recommendation | Why | | --- | --- | --- | | 2-10 JS packages, fast CI | pnpm workspaces only | no runner tax; revisit at ~10 packages | | 10-100 JS/TS packages, frontend-heavy | Turborepo + pnpm | small config, strong cache, easy adoption | | 20-500 JS/TS projects, many types, releases | Nx + pnpm | generators, graph, boundary rules, release tooling | | Polyglot, no desire for Starlark | Moon | one task model across languages, toolchain pinning | | Polyglot at scale, hermetic/reproducible required | Bazel | correctness and remote execution | | C/C++/embedded or legacy scripts | Make, modernized | minimal disruption | | Publishing libraries only | pnpm workspaces + Changesets | release flow is the whole job; no runner needed |
12. Anti-patterns
- Choosing a tool by benchmark blog post instead of a two-week pilot on the real repo.
- Running two runners in the same repo without a clear ownership split.
- Replacing the package manager and the runner simultaneously.
- Caching by filename alone; ignoring env, lockfile, and tool versions. See 05-caching-ci.
- Adopting Bazel for a 20-package JS repo "for the future".
- Defining targets only in CI YAML; they belong in the repo config so everyone gets them.
- Treating
pnpm -ras a task graph: it runs in topological order but caches nothing. - Letting plugin versions drift across Nx packages, causing graph inference failures.
Checklist
- [ ] The runner was chosen against the seven capabilities, with a pilot and measured results.
- [ ] One package manager, one lockfile, pinned
packageManager, frozen CI installs. - [ ] Task graph declared in-repo (turbo.json, nx.json, moon.yml, BUILD files), not only in CI.
- [ ] Each cacheable task declares inputs, outputs, env, and cache policy.
- [ ] Affected detection uses an explicit merge base and full-enough git history.
- [ ] Remote cache credentials are scoped; signatures or trust boundaries considered.
- [ ] Migration sequence documented, reversible, and separated from structural changes.
- [ ] Escape hatches known:
--force, cache bypass, graph inspection commands.
Related: 02-structure-boundaries for tags and boundary rules, 05-caching-ci for hashing and remote cache operations, 08-migration for execution plans.
02-structure-boundaries
Structure and Boundaries
Scope: apps/packages/libs layout, package taxonomy, dependency direction rules, public APIs, ownership, and enforcement.
Structure is the cheapest form of architecture: it decides what can depend on what before any code is written. A monorepo without enforced boundaries degrades into a distributed tangle with a single lockfile.
1. Canonical layout
repo/
apps/ # deployable units (web, api, worker, mobile)
web/
api/
packages/ # publishable or reusable units
ui/ # design system components
config/ # shared config packages
tsconfig/
eslint-config/
contracts/ # API schemas, generated clients
libs/ # non-published shared code (scopes by domain)
billing/
feature-invoices/
data-access/
util/
tools/ # generators, scripts, repo automation
docs/ # ADRs, runbooks, onboarding
e2e/ # cross-app end-to-end suites
package.json
pnpm-workspace.yaml
turbo.json | nx.json | moon.yml
tsconfig.base.json
CODEOWNERS
Rules that make this layout work:
apps/*may depend onpackages/*andlibs/*, never on another app. Cross-app logic belongs in a package.packages/*may depend on other packages; they must not import apps.libs/*are internal; if one becomes a product, promote it topackages/deliberately.tools/*are dev-only and owned by the platform team; runtime code must never import them.- One concern per directory level. Mixing
apps,services,frontend, andbackendat the root creates taxonomy debates; pick names once and document them.
Alternatives that also work, provided the dependency direction is enforced:
| Layout style | Root dirs | Best when |
| --- | --- | --- |
| Type-first (Nx convention) | apps/, libs/ | JS/TS, heavy code sharing, Nx |
| Product-first | apps/, packages/ | library-heavy or npm-publishing workspaces |
| Domain-first | domains/<domain>/apps, domains/<domain>/libs | large orgs with autonomous domain teams |
| Polyglot | apps/, libs/, services/, crates/, cmd/ | multiple languages with per-language conventions |
| Flat | packages/* only | small repos; every package is a library |
Do not invent a hybrid taxonomy mid-flight; document the mapping and migrate with 08-migration.
2. Package taxonomy
Tags or directory naming encode package type. Nx's model is a good reference even outside Nx:
| Type | Purpose | May depend on |
| --- | --- | --- |
| app | bootstrap, routing, wiring | feature, ui, util, data-access, contracts |
| feature | user-facing capability, one route/flow | ui, data-access, util, contracts |
| ui | presentational components, no data fetching | util, design tokens |
| data-access | API clients, stores, repositories | util, contracts |
| util | pure helpers, no framework imports | other util |
| contracts | schemas, generated types, protocol definitions | nothing runtime-heavy |
| config | tooling configuration only | nothing |
| e2e | tests only | anything, but nothing depends on it |
Two independent axes are useful:
type:*—app,feature,ui,data-access,util,contracts,config,e2e.scope:*— domain or team boundary, for examplescope:billing,scope:checkout,scope:shared.
Dependency rule: a scope may depend on scope:shared and on itself, never on another scope without an explicit allowance. That single rule prevents most accidental coupling in a growing repo.
3. Dependency direction
Default direction, from most general to most specific:
types/contracts -> util -> data-access -> ui -> feature -> app
^ |
+------------------------------+
(within a type, same scope)
- Dependencies point inward/downward: specific code may import general code, never the reverse.
- Cycles are forbidden, including "cycles through a barrel" and "cycles through types".
- A
featuremust not import anotherfeaturedirectly; extract the shared part toutil/data-accessor compose at the app level via events, props, or routing. - Apps compose; they do not export reusable logic. If an app exports, it is a package.
- Environment-specific code (browser-only, node-only) is isolated in leaf packages so the dependency graph can express compatibility.
Encode these rules, do not trust memory:
// nx.json enforce-module-boundaries / eslint config
{
"depConstraints": [
{ "sourceTag": "scope:shared", "onlyDependOnLibsWithTags": ["scope:shared"] },
{ "sourceTag": "scope:billing", "onlyDependOnLibsWithTags": ["scope:billing", "scope:shared"] },
{ "sourceTag": "type:feature", "onlyDependOnLibsWithTags": ["type:ui", "type:data-access", "type:util", "type:contracts"] },
{ "sourceTag": "type:ui", "onlyDependOnLibsWithTags": ["type:util", "type:contracts"] },
{ "sourceTag": "type:util", "onlyDependOnLibsWithTags": ["type:util", "type:contracts"] },
{ "sourceTag": "type:contracts", "onlyDependOnLibsWithTags": ["type:contracts"] }
]
}
4. Public API and entrypoints
A package without an explicit public API has none: every internal path becomes a dependency contract by accident.
- Declare
exportsinpackage.json; the root export is the supported surface.
// packages/ui/package.json
{
"name": "@acme/ui",
"type": "module",
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
"./tokens.css": "./dist/tokens.css",
"./package.json": "./package.json"
},
"files": ["dist"]
}
- For internal source-consumed packages (no build step), export source with a
typescondition or use adevelopmentcondition. See 04-shared-config. - Barrels (
index.ts) are the API, not a convenience. Keep them curated: do notexport *from feature internals, and avoid barrels that re-export everything and create cycles. - Type-only exports are explicit (
export type), so consumers do not pull runtime code for types. - Path aliases in
tsconfigmust not create an alternate entrance into another package'ssrc/. Alias to package names, not to files. - Breaking a public API requires a semver-major release and a changeset; see 07-versioning-release.
5. Enforcement
| Layer | Tool | What it catches |
| --- | --- | --- |
| Lint | @nx/enforce-module-boundaries | tag violations, deep imports, cycles, lazy-load misuse |
| Lint | eslint-plugin-boundaries, eslint-plugin-import (no-restricted-paths) | custom element-type rules, forbidden paths |
| Graph | dependency-cruiser | cycles, orphans, forbidden directions in non-Nx repos |
| Runner | turbo boundaries | undeclared cross-package imports in Turborepo workspaces |
| Types | project references / tsc -b | imports that cannot resolve through package APIs |
| Policy | sherif, custom scripts | missing entrypoints, inconsistent fields, version skew |
| Review | CODEOWNERS | ownership gaps and unauthorized surface changes |
Example dependency-cruiser rule:
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{ name: "no-app-to-app", severity: "error",
from: { path: "^apps/([^/]+)/" }, to: { path: "^apps/(?!$1)" } },
{ name: "no-deep-imports", severity: "error",
from: { pathNot: "^packages/ui/" },
to: { path: "^packages/[^/]+/src/" } }
],
options: { tsConfig: { fileName: "tsconfig.base.json" }, doNotFollow: { path: "node_modules" } }
};
Practical rules for enforcement:
- Run boundary checks in lint (
nx lint,eslint .), not in a separate nightly job; developers must see failures locally. - Keep the rules in one config file, not scattered comments. Link the file from
CONTRIBUTING.md. - Fail on cycles first; they are objective and uncontroversial. Then add tag rules.
- Exemptions are temporary and tracked: an inline disable needs an owner and an expiry issue.
- When a rule is widely violated, the rule or the structure is wrong; decide which and change it explicitly.
6. Ownership
Structure defines technical boundaries; ownership defines human ones.
- Map directories to teams in
CODEOWNERS, most specific rule last. - Every package directory must match at least one rule; unowned packages rot fastest.
- Generated and vendored directories get their own owner (usually the platform or the producer team), not "everyone".
- Protected paths (public API, contracts, CI config, release tooling) require the owning team plus the platform team.
- Ownership is reviewed when packages move; a directory rename without a CODEOWNERS update silently orphans the package.
See 09-governance-metrics for coverage measurement and drift checks.
7. Splitting and moving packages
- Move code only with its tests, its owner, and its consumers in the same change set.
- Promotion path:
libs/->packages/when external consumers or a release cadence appear; never the reverse without a deprecation window. - A package earns extraction when at least two consumers exist and the API is stable; premature extraction creates churn.
- Deletion is a feature: remove empty or single-consumer packages rather than letting the repo accrete.
- Renames are breaking changes for consumers: ship an alias/reexport package for one release cycle when the package is published.
8. Polyglot layouts
When the repo contains multiple languages, keep per-language idioms but one top-level contract:
- JS/TS:
apps/,packages/, per 04-shared-config. - Go:
services/<name>/,cmd/,internal/,pkg/inside each module; one module per independently versioned unit. - Python:
services/<name>/,packages/<name>/withpyproject.tomlper package, workspace viauvorpoetrygroups. - Rust:
crates/<name>in a root workspaceCargo.toml. - Shared contracts:
packages/contracts(protobuf/OpenAPI) orproto/, consumed by all languages; see 06-codegen.
Cross-language dependencies should flow through generated contracts or network APIs, not through checked-in copies of code.
9. Anti-patterns
| Anti-pattern | Symptom | Fix |
| --- | --- | --- |
| No tags or boundary rules | arbitrary imports, cycles | add type:/scope: tags and enforce |
| Deep imports into src/ | refactors break unrelated apps | exports map plus lint rule |
| shared, common, utils mega-package | everything depends on everything | split by domain; name by capability |
| Barrel export * from internals | hidden public API, cycles | curated exports |
| Apps imported by packages | tangled deploy graph | move shared code to packages |
| file: links to avoid workspace protocol | broken dedupe, path fragility | use workspace:* |
| Directory rename without CODEOWNERS update | orphaned ownership | include ownership in the move PR |
| One team owns "the monorepo" | bottleneck, platform burnout | distribute package ownership; platform owns the system |
Checklist
- [ ] Top-level taxonomy chosen and documented; no synonyms (
libsvspackagesvsshared) fighting. - [ ] Every package tagged by
typeandscope, or the equivalent documented convention. - [ ] Dependency direction rules written down and machine-enforced in lint/CI.
- [ ] Each package has a curated
exportssurface; deep imports fail checks. - [ ] Cycles are a build failure, not a code-review discussion.
- [ ] CODEOWNERS covers every package and tooling directory; coverage measured.
- [ ] App-to-app and package-to-app imports are forbidden and detected.
- [ ] Promotion, extraction, and deletion paths are documented.
- [ ] Polyglot repos share contracts through generated packages, not copied code.
Related: 01-tools for runner capabilities, 04-shared-config for package build/output wiring, 09-governance-metrics for ownership and drift metrics.
03-dependencies
Dependency Management
Scope: workspace protocols, catalogs, peer dependencies, hoisting and dedupe, version policy, overrides, and update automation.
Dependency management in a monorepo has three layers: install correctness (package manager), version policy (constraints and singletons), and automation (updates, audits, drift). Most incidents come from conflating them.
1. Workspace protocol
Internal dependencies must resolve locally, always, and must be rejected if the local package is missing.
| Protocol | Meaning | Use for |
| --- | --- | --- |
| workspace:* | exact local package, any version | apps and tightly coupled internal deps |
| workspace:^ | ^ local version at pack/publish time | libraries consumed by other packages |
| workspace:~ | ~ local version | rarely; tight coupling within a patch range |
| workspace:^1.2.0 | range with a minimum | published compatibility floors |
| file:../pkg | filesystem link | never in a workspace; breaks dedupe and publish |
| link:../pkg | symlink without install | never in a workspace |
| plain 1.2.3 | registry version | external packages only; against an internal package it silently installs from the registry |
// packages/ui/package.json
{
"dependencies": { "@acme/tokens": "workspace:^" },
"devDependencies": { "@acme/tsconfig": "workspace:*" }
}
Rules:
- The root manifest is
private: true; only packages intended for publishing leave it unset. - CI verifies that no manifest contains
file:orlink:references to workspace paths. - When a package is published,
workspace:^is rewritten to the real version (pnpm/npm/Yarn all do this at pack time). Verify withnpm pack --dry-runbefore the first release. workspace:*on a published package rewrites to the exact version; use it for internal-package lockstep,workspace:^otherwise.
2. Catalogs
Catalogs centralize version decisions so every package uses the same range for the same dependency. pnpm 9.5+ implements catalog: in pnpm-workspace.yaml; check current support if using other managers.
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
catalog:
react: ^19
react-dom: ^19
typescript: ~5.9
vitest: ^3
zod: ^4
catalogs:
react18:
react: ^18
react-dom: ^18
catalogMode: strict
{ "dependencies": { "react": "catalog:", "react-dom": "catalog:" } }
Notes:
catalogMode: strictforbids direct version ranges, forcing every dependency through the catalog. This is the strongest consistency policy and the highest-friction one.- Use named catalogs only for genuine parallel majors (for example a legacy React 18 app while the platform moves to 19). Two names for one major is drift.
- Catalogs govern declarations, not resolution; overrides (below) still handle transitive conflicts.
- The catalog is the single place to audit "what versions do we run" per dependency.
3. node_modules layout and hoisting
| Manager | Layout | Phantom deps | Notes |
| --- | --- | --- | --- |
| pnpm | strict symlinked store, .pnpm/ | prevented by default | best isolation; node-linker=hoisted escapes hatch |
| npm workspaces | hoisted, nested on conflict | possible | simple; audit with npm ls |
| Yarn classic | hoisted | possible | legacy |
| Yarn Berry (PnP or node-modules) | PnP or virtual | prevented in PnP | PnP needs tool compatibility checks |
| Bun | hoisted with isolated install option | possible | verify current workspace semantics upstream |
Practices:
- Prefer the strict layout; a phantom dependency is a latent breakage that appears the day a transitive dep disappears.
public-hoist-patternandshamefully-hoistare compatibility escape hatches for stubborn tools (older CLIs, some test runners). Each use gets a comment and an issue to remove it.- Never install dependencies implicitly at the root to "make it work"; declare them where they are imported.
- The lockfile is committed and reviewed. Lockfile-only diffs are still diffs: they carry supply-chain changes.
- Keep
enginesandpackageManagerpinned; mismatched managers silently rewrite lockfiles.
4. Peer dependencies
Libraries must not force a second copy of a singleton runtime into an app's graph.
| Dependency kind | Declare as | Examples |
| --- | --- | --- |
| Singleton runtime the consumer must own | peerDependencies | React, Vue, Angular core, ORM client, zod in plugin ecosystems |
| Runtime code the lib controls | dependencies | small utilities, internal helpers |
| Types only | devDependencies + optional peer | @types/*, framework types |
| Optional integration | peerDependencies + peerDependenciesMeta.optional | date libraries, adapters |
Rules:
- Test the peer ranges in CI: install the minimum supported and the latest in a matrix when the library is published.
- Widen ranges only when tests prove compatibility; narrowing is a breaking change.
- Avoid
dependenciesfor anything the app also uses directly, or you will ship duplicates. pnpm installauto-installs peers by default in recent versions; disable for published library workspaces if strictness matters, and declare peers explicitly in the library manifest.
5. Version constraints and single-version policy
| Policy | Meaning | Use when | | --- | --- | --- | | Single version | exactly one version of a dependency repo-wide | runtime singletons, large libraries, framework core | | Range drift allowed | each package picks its range | leaf utilities with no shared state | | Catalog strict | all declarations from one catalog | consistency is a hard requirement |
Detect skew before it bites:
pnpm why -r <package> # who depends on it and why
pnpm dedupe --check # would dedupe change the lockfile
pnpm outdated -r # outdated across the workspace
pnpm audit --prod # known vulnerabilities (also run in CI)
Non-JS equivalents: npm ls, yarn why, and for policy tools syncpack or sherif to list mismatched versions across manifests.
If several versions of a critical dependency exist, fix the cause (conflicting peer ranges, duplicated majors) rather than forcing resolution with overrides.
6. Overrides and patches
overrides(npm/pnpm) andresolutions(Yarn) force a transitive version repo-wide. Use them for security hotfixes and genuine incompatibilities, not for tidiness.- Every override is documented with the reason and a removal condition; overrides outlive their justification by years.
- Test after changing an override: forced versions can break the very package that needed the old one.
pnpm patch <pkg>/patch-packagecreate a local patch when an upstream fix does not exist. Patches are emergency measures: keep them minimal, pin the base version, and open an upstream PR.- Never patch via
postinstallscripts that rewrite files; they are invisible to review and break under remote caching.
7. Update automation
Renovate is the strongest fit for workspaces; Dependabot is adequate for simple repos. Verify configuration syntax against the current tool version upstream.
Renovate essentials for monorepos:
// renovate.json
{
"extends": ["config:recommended", ":dependencyDashboard", ":semanticCommits"],
"lockFileMaintenance": { "enabled": true, "schedule": ["before 6am on monday"] },
"postUpdateOptions": ["pnpmDedupe"],
"rangeStrategy": "bump",
"packageRules": [
{ "groupName": "react", "matchPackageNames": ["react", "react-dom", "react", "/^@types\\/react/"] },
{ "groupName": "test tooling", "matchPackageNames": ["vitest", "playwright", "@vitest/**"] },
{ "matchUpdateTypes": ["major"], "dependencyDashboardApproval": true },
{ "matchDepTypes": ["peerDependencies"], "rangeStrategy": "widen" }
]
}
- Group by ecosystem and tool family; ungrouped bots create review fatigue and rebase churn.
- Enforce major-update approval: majors deserve a human decision, a changeset, and a test run.
- Let the bot run the full affected pipeline; a dependency bump that does not pass tests is not mergeable.
- Schedule lockfile maintenance separately from version bumps; mixed diffs are unreviewable.
- Keep a dependency dashboard; "no updates for six months" is a risk signal, not stability.
- Dependabot equivalent:
directory: "/"plusgroupsandmulti-ecosystem-groupsin.github/dependabot.yml; verify current syntax upstream.
8. Security and audit in a workspace
- Run
pnpm audit --prod(or the manager equivalent) in CI on a schedule and on lockfile changes; also run SBOM generation for published artifacts. - Use
onlyBuiltDependencies/ignoredBuiltDependencies(pnpm) to whitelist lifecycle scripts. Postinstall scripts are the primary npm supply-chain vector; default to blocking new ones. - Pin GitHub Actions to commit SHAs; pin generator and release tooling versions.
- For published packages, enable npm provenance/trusted publishing; see 07-versioning-release.
- Treat the root lockfile as a security artifact: it is the exact dependency set for every app, so a compromised transitive dependency is repo-wide, not app-local.
9. Anti-patterns
| Anti-pattern | Symptom | Fix |
| --- | --- | --- |
| Mixed file: and workspace: links | dedupe failures, broken publishes | workspace protocol only |
| shamefully-hoist=true | phantom deps return | fix the offending tool or isolate it |
| Root node_modules as a dumping ground | undeclared imports work locally, fail in CI/prod | declare per package |
| Duplicate React/query libs | hooks errors, bundle bloat | peer deps + single-version policy |
| Unreviewed override | mystery resolved versions | document, test, set expiry |
| Renovate with no grouping | rebase fatigue, stale PRs | group, batch, schedule |
| Lockfile merge conflicts resolved by deletion | version regressions | regenerate lockfile, review diff |
| Catalog named after a team, not a major | hidden divergence | one catalog unless a real major split |
Checklist
- [ ] All internal deps use
workspace:; CI rejectsfile:/link:. - [ ] Version decisions centralized (catalog or single-version policy) for critical deps.
- [ ] Strict
node_moduleslayout; every hoist exception documented. - [ ] Singleton runtimes declared as peers with tested ranges.
- [ ] No unexplained duplicate versions of framework or state libraries.
- [ ] Overrides and patches documented with removal conditions.
- [ ] Renovate/Dependabot configured with grouping, schedules, and major-approval gates.
- [ ] Audit and SBOM checks run in CI; lifecycle scripts whitelisted explicitly.
- [ ] Lockfile review is part of every dependency PR.
Related: 01-tools for workspace-manager choice, 04-shared-config for internal package consumption, 09-governance-metrics for drift detection.
04-shared-config
Shared Configuration and Package Wiring
Scope: shared tsconfig/ESLint/Prettier/test config packages, project references, internal package consumption patterns, build outputs, and exports maps.
Credentials, lint rules, and compiler flags must not vary by package. Put configuration in versioned packages, consume it everywhere, and let the task graph rebuild consumers when it changes.
1. Config as packages
packages/config/
tsconfig/ # @acme/tsconfig
base.json
library.json
app.json
package.json
eslint-config/ # @acme/eslint-config
index.js
react.js
node.js
package.json
prettier-config/ # @acme/prettier-config
vitest-config/ # @acme/vitest-config
Rules:
- Config packages are
private: trueandversion: 0.0.0unless external consumers exist; they are consumed withworkspace:*. - Export JSON/JS objects, not scripts with side effects; a config package must be side-effect free.
- Config changes are code changes: they require a changeset if published, a CODEOWNERS owner, and a rebuild of affected consumers (declare them as task inputs).
- Never copy a config file into a package "just for this one setting". Use
extends/spread and an override. - Keep the number of config packages small; one per tool family, not one per framework unless the divergence is real.
2. Shared tsconfig
Layer configs so each package extends the narrowest useful base.
// packages/config/tsconfig/base.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "preserve",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true
}
}
// packages/config/tsconfig/library.json
{
"extends": "./base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"rootDir": "src",
"outDir": "dist"
}
}
Practice:
- The root
tsconfig.base.json(if kept) only wires path aliases and references; real defaults live in the config package. - Do not use path aliases to bypass package boundaries. Alias package names to their built or source entrypoints, never to
../other/src/file. - One TypeScript version at the root. Per-package TypeScript versions produce incompatible types and editor confusion.
skipLibCheck: trueis standard for apps; libraries still compile their own declarations and testattw/publint.- When the runtime uses a bundler, set
module: preserve/moduleResolution: bundler; for Node-native ESM usenodenexton the entry packages. Consistency matters more than the exact choice.
3. Project references and incremental builds
TypeScript project references turn tsc -b into a correct incremental build with declarations shared between packages.
// packages/ui/tsconfig.json
{
"extends": "@acme/tsconfig/library.json",
"include": ["src"],
"references": [{ "path": "../tokens" }]
}
tsc -b packages/ui # builds dependencies first
tsc -b --watch # incremental dev loop
Notes:
composite: truerequiresdeclaration: true, explicitrootDir, and inclusion of every source file.- Reference the built declaration output, not the source of another package, when packages are compiled. Source-first consumption is the alternative pattern (below).
- Cache
*.tsbuildinfo: declare it as a build output (commonly.tsbuildinfonext todist) so CI restores it. - Build configs (
tsconfig*.json,package.jsonexports) must be task inputs; if they are not, cache invalidation misses real changes.
4. Shared ESLint (flat config)
// packages/config/eslint-config/index.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["**/dist/**", "**/coverage/**", "**/*.generated.*"] },
js.configs.recommended,
tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/consistent-type-imports": "error",
},
},
);
// packages/ui/eslint.config.js
import base from "@acme/eslint-config";
export default [...base, { rules: { "no-console": "off" } }];
Rules:
- One root lint command (
eslint .ornx run-many -t lint) so CI and editors agree. - Typed linting needs a
tsconfigthat includes the file;projectServiceeases multi-package setups. Verify current typescript-eslint options upstream. - Formatting stays out of ESLint; one formatter only.
- Framework configs are separate exports (
@acme/eslint-config/react), not conditionals inside one config. - Lint rules that enforce architecture belong here (module boundaries); see 02-structure-boundaries.
5. Prettier and formatting
- One shared config package (
@acme/prettier-config) referenced from each package'spackage.jsonor a root.prettierrcthat extends it. - CI runs
prettier --check .; editors run on save. - Generated files, lockfiles, and vendored code are ignored via
.prettierignore, not per-package configs. - Formatting commits are separate from logic. A repo-wide reformat lands once, on a quiet day, with no feature changes mixed in.
- Do not run two formatters (
prettierplusbiome format/dprint); pick one.
6. Shared test and tooling configs
| Config | Package | Notes |
| --- | --- | --- |
| Unit test | @acme/vitest-config | shared defineConfig with coverage thresholds and setup files |
| E2E | @acme/playwright-config | base projects, trace policy, shard support |
| Tailwind | @acme/tailwind-config | shared preset/theme tokens; app-level content globs |
| Storybook | shared base config | keep stories inside the package that owns the component |
| Docker | tools/docker/* templates | one base image per runtime version |
| Toolchain versions | root .nvmrc, packageManager, engines | one truth; verified in CI |
7. Two internal-package consumption patterns
| Pattern | How it works | Pros | Cons |
| --- | --- | --- | --- |
| Just-in-Time (source) | Consumer's bundler compiles the dependency's source via exports to ./src/index.ts under a dev condition | fast inner loop, no build ordering, no dist to clean | bundler must compile everything; published artifacts must strip dev conditions; type-checking scope grows |
| Compiled | Dependency builds dist/ first; consumer imports built output | publishable as-is, clear API boundary, cacheable build artifacts | build ordering and outputs must be correct; dev loop needs watch mode |
Just-in-Time exports example:
// packages/ui/package.json
{
"name": "@acme/ui",
"exports": {
".": {
"development": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"publishConfig": {
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }
}
}
}
Choose one pattern per package class and document it. Mixing both within a package produces "works locally, breaks in prod" bugs. Compiled packages are mandatory for anything published.
8. Build outputs and exports hygiene
- Standardize output directories:
dist/for compiled JS/TS packages,build/only for apps if the framework demands it. Do not mixlib/,esm/,cjs/per package without reason. fileslists exactly what ships (dist,README.md); everything else is excluded.exportsconditions order matters:typesfirst, thenimport/require, thendefault. Subpaths must be explicit; wildcard subpaths only when the API really is a directory tree.sideEffects: falseonly when true; a wrongsideEffectsflag silently drops CSS or polyfills from bundles.- Ship source maps and declaration maps for libraries; do not ship raw
src/unless the Just-in-Time pattern requires it. - Validate before publishing:
npm pack --dry-run,publint, andattw(--profile node16andesm-onlyas appropriate). See 07-versioning-release. - For apps, do not reuse a library's
exportsbuild settings; app builds are bundled and can emit with different targets.
9. Environment and tool versions
- Node/Python/Go/Rust versions are declared once:
.nvmrc/.tool-versions/mise.toml,engines, and CI matrix read from them. - Environment variables are validated at startup against a schema shared across apps (for example a
@acme/envpackage with zod); see 06-codegen for schema-to-types pipelines. - Never commit
.envfiles; commit.env.exampleand validate required keys in CI for apps that need them. - Tool versions (task runner, package manager, bundler, test runner) are pinned in manifests, not "latest".
10. Anti-patterns
| Anti-pattern | Symptom | Fix |
| --- | --- | --- |
| Copy-pasted tsconfig per package | flag drift, inconsistent type behavior | config package + extends |
| Path aliases into ../other/src | hidden coupling, bundler edge cases | package exports only |
| Per-package TypeScript versions | incompatible types across packages | one root version |
| Shared config with side effects | unpredictable imports | pure exported objects |
| Publishing src/ accidentally | bloated packages, broken types | files + attw checks |
| sideEffects: false without checking | missing CSS/polyfills in prod | audit imports and bundle |
| Mixed Just-in-Time and compiled deps | prod failures after local success | one pattern per package class |
| Config package unowned | silent quality decay | CODEOWNERS entry, required review |
Checklist
- [ ] Shared config packages exist for tsconfig, ESLint, formatter, and test runners.
- [ ] Every package extends shared config; overrides are explicit and few.
- [ ] Project references used where incremental
tsc -bis the build path. - [ ] One internal-package pattern per class, documented; dev conditions stripped at publish.
- [ ]
exports,files, andsideEffectsare deliberate for every package. - [ ] Build outputs are consistent and declared as task outputs.
- [ ] Tool and runtime versions pinned once; CI verifies them.
- [ ]
publint/attw/npm pack --dry-rungate published packages.
Related: 02-structure-boundaries for API surface rules, 03-dependencies for workspace protocols, 05-caching-ci for making config changes invalidate caches.
05-caching-ci
Caching and CI
Scope: task graphs, input hashing, local and remote caching, affected detection, CI sharding, merge queues, and flaky-task isolation.
CI is the monorepo's user interface. If it is slow, incorrect, or flaky, every other investment is taxed. Caching is a correctness problem before it is a speed problem: a wrong cache is worse than no cache.
1. Declare the task graph
The graph lives in the repository, not in CI YAML, so local and CI runs behave identically.
Turborepo:
// turbo.json
{
"globalDependencies": ["tsconfig.base.json", "pnpm-lock.yaml", ".node-version"],
"globalEnv": ["CI", "NODE_ENV"],
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**", "!.next/cache/**"] },
"typecheck": { "dependsOn": ["^build"], "outputs": [] },
"test": { "dependsOn": ["build"], "outputs": ["coverage/**"] },
"e2e": { "dependsOn": ["build"], "cache": false, "outputs": [] },
"lint": { "outputs": [] },
"dev": { "cache": false, "persistent": true }
}
}
Nx:
// nx.json
{
"targetDefaults": {
"build": { "dependsOn": ["^build"], "cache": true, "inputs": ["production", "^production"], "outputs": ["{projectRoot}/dist"] },
"test": { "dependsOn": ["build"], "cache": true, "inputs": ["default", "^production"] },
"e2e": { "cache": false }
},
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": ["default", "!{projectRoot}/**/*.spec.ts", "!{projectRoot}/**/*.md"],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json", "{workspaceRoot}/tools/**"]
}
}
Rules:
^buildmeans dependencies first;buildmeans this project first. Reviewers should check this distinction in every config change.- Every task that produces files declares
outputs; every task that consumes env declares it. Empty outputs with caching on is a common silent bug. persistenttasks (dev servers, watchers) are never cached and must not block the graph.- Global dependencies are the narrow set of root files that genuinely affect every package (base config, lockfile, toolchain pin).
2. How hashing works
A task's cache key is roughly:
hash( task name + command + package source globs + dependency task hashes + lockfile slice + declared env + tool versions )
Consequences:
- The same source with a different env value is a different key. Missing env declarations cause both false misses (rebuild) and false hits (wrong artifact).
- Changing a dependency's source changes the dependent's key through
^buildhashes; this is why graph correctness matters. - Changing the lockfile usually changes every key; that is intentional.
- Hidden inputs (generated files not declared, files outside the package root, absolute paths, timestamps) break determinism. Hunt them when cache hits look random.
- Debug with
turbo run build --summarize(or.turbo/runs/*.json) andnx run build --verbose/NX_VERBOSE_LOGGING=trueto inspect the computed hash inputs.
Never include:
- Timestamps, hostnames, absolute paths, or random values in build outputs.
- Credentials or
.envcontents in hashed outputs or uploaded artifacts. node_moduleswholesale; hash the lockfile and package manifests instead.
3. Local cache
- Turborepo:
.turbo/cache(gitignored); Nx:.nx/cache. - Local misses on a clean checkout are expected; local hits make developer loops fast and reduce CI cost indirectly by enabling local reproduction of CI failures.
- Keep the cache directory out of version control and out of build outputs. A cache directory that is itself an output creates recursive invalidation.
- Bound cache size: prune stale entries (
turbo prunefor deploys; cache eviction is automatic but disk pressure is real on dev machines).
4. Remote cache
| Option | Notes |
| --- | --- |
| Nx Cloud | remote cache + distributed task execution + analytics; hosted or self-hosted; verify current plans upstream |
| Vercel Remote Cache | native to Turborepo; hosted or self-hosted (turbo login/turbo link); verify current terms and limits upstream |
| Self-hosted S3/GCS/R2 | implement or use the runner's artifact API; full control, you own auth, retention, and correctness |
| CI-native cache (GitHub Actions) | useful for package-manager stores and tool caches, not a substitute for task-artifact caching |
Operations:
- Scope credentials per repository; read-only for pull requests from forks where possible.
- Sign cache artifacts (
signature: truein Turborepo) so consumers can verify provenance. Without trust boundaries, anyone who can write can poison what everyone runs. - Separate PR and main caches if correctness risk is unacceptable (for example, do not let unmerged branches write to the shared cache).
- Monitor hit rate and artifact size; unbounded artifacts (full
.nextdirectories, VM images) make the cache slow and expensive. - Treat a cache-poisoning report as a security incident: purge affected keys, rotate tokens, audit writers.
5. Affected detection
- Compute the diff against the merge base, not against the previous commit on the branch.
- Turborepo:
turbo run build --affectedwithTURBO_SCM_BASE/TURBO_SCM_HEAD, or--filter='...[origin/main]'. - Nx:
nx affected -t build test --base=origin/main --head=HEAD. - CI must fetch enough history:
actions/checkoutwithfetch-depth: 0(or fetch the base branch explicitly). A shallow clone makes every PR look like a full rebuild or, worse, a partial one. - Always run the full graph on the default branch; affected-only on merges hides integration failures.
- Tooling changes (
tools/, root configs, CI files) usually affect everything; declare them as global inputs rather than special-casing in CI. - Path-based filters (
--filter=./apps/web) are for explicit routing, not for correctness; use them only when the task truly is app-local.
6. A reference PR pipeline
name: ci
on:
pull_request:
push:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@<pinned-sha>
with: { fetch-depth: 0 }
- uses: pnpm/action-setup@<pinned-sha>
- uses: actions/setup-node@<pinned-sha>
with: { node-version-file: .nvmrc, cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: Lint and types
run: pnpm exec turbo run lint typecheck --affected
- name: Build and test
run: pnpm exec turbo run build test --affected --concurrency=50%
- name: Full verification on main
if: github.ref == 'refs/heads/main'
run: pnpm exec turbo run build test --force
Notes:
- Pin actions by SHA; tags move.
- Cancel superseded runs; a monorepo queue is the top cause of slow feedback.
- Keep job count small and parallelize inside the runner. Splitting into many jobs multiplies install cost unless the runner's remote cache handles it.
- Gate merges on the full-graph main run, not only the affected PR run.
7. Sharding and parallelism
| Technique | How | Watch out |
| --- | --- | --- |
| Package sharding | matrix over pnpm list --depth -1 or nx show projects filtered to changed | install repeated per shard; use store cache |
| Test-file sharding | vitest --shard=i/n, jest --shard=i/n, playwright test --shard=i/n | keep shards balanced by historical duration, not file count |
| Task-level concurrency | turbo --concurrency, nx --parallel | oversubscription on small runners causes timeouts |
| Graph-partitioned CI | assign packages to runners by graph clusters | complex; only worth it at large scale |
| Split setup | prebuild common deps in a "prepare" job, then fan out | artifacts must be addressable and cache-correct |
Balance shards with recorded durations; naive round-robin assignment drifts to a slow tail. Re-measure weekly or on graph changes.
8. Merge queues
- GitHub merge queue and GitLab merge trains test the merge result, not just the branch, preventing semantic conflicts between green PRs.
- Queue runs use a synthetic merge commit; cache keys change, so expect lower hit rates for the tested commit. Cache by source hashes, not commit hashes, to keep reuse.
- Keep the merge-queue job set minimal and fast: lint, types, build, targeted tests. Long e2e suites belong on main or nightly.
- Required checks must be identical in name across PR and queue contexts, or the queue stalls silently.
- Configure batching where available; batching reduces queue drain time but widens the blast radius of a failure.
9. Flaky tasks
Flakes destroy trust in the cache and in CI generally. Treat them as defects with owners.
- Record which tasks fail and retry; a task that needs retries to pass is flaky even when green.
- Isolate flaky tests (
test.fixme, quarantine tag) into a dedicated, allowed-to-fail job while they are fixed; never leave them retrying silently in the main pipeline. - Common monorepo causes: shared ports, shared databases, time-dependent tests, unbounded parallelism, missing env in tests that assume CI defaults, and cache hits that restore stale outputs into tests.
- For e2e: retry only to collect artifacts, then fail on retry (
--retries=1to get traces; do not pass a flake as success). - Track flake rate as a metric; see 09-governance-metrics.
--continue/continueOnErrorkeeps the pipeline informative (other tasks still run) but must not hide failures from the final status.
10. Cache correctness traps
| Trap | Effect | Detection |
| --- | --- | --- |
| Env var missing from env | wrong artifact reused | grep config for process.env in build scripts |
| File outside declared inputs | stale build | reproduce with --force, compare hashes |
| Output not declared | work redone, or missing artifact in CI | check cache restore logs |
| Non-deterministic output (timestamps, paths) | cache never hits | build twice, diff outputs |
| Postinstall mutation of source | key unchanged, output changed | forbid source mutation in scripts |
| Unsigned remote cache | poisoning risk | enable signatures, scope tokens |
| Tests with external side effects cached | skipped tests, false green | cache: false for e2e/integration |
11. Metrics to expose
- Install time, full build time, affected build time (p50/p95).
- Local and remote cache hit rates per task (not just aggregate).
- Queue time and CI wall time per PR.
- Flake rate and retry count per task.
- Affected ratio (how much of the graph a typical PR touches).
See 09-governance-metrics for targets and dashboards.
12. Anti-patterns
- Caching everything including e2e and deploy steps.
- Copying CI YAML between repos instead of declaring tasks in-repo.
fetch-depth: 1with affected detection.- Running the full graph for every PR "to be safe"; it trains the team to ignore CI cost.
- Long-lived caches without eviction or size limits.
- Retrying flaky jobs until green; the failure is the signal.
- One mega-job with no parallelism, or 40 jobs with duplicated installs.
- Treating remote cache as free infrastructure with no ownership.
Checklist
- [ ] Task graph declared in-repo with inputs, outputs, env, and cache policy.
- [ ] Affected detection uses merge base and full-enough git history.
- [ ] Remote cache configured, scoped, monitored, and (where supported) signed.
- [ ] Default branch runs the full graph; PRs run affected.
- [ ] Shards balanced by duration; setup cost measured.
- [ ] Merge queue configured with a stable required-check set.
- [ ] Flakes tracked with owners and quarantine, not retried into silence.
- [ ] Cache hit rate, CI time, and flake rate reported on a dashboard.
Related: 01-tools for runner choice, 02-structure-boundaries for keeping the graph clean, 09-governance-metrics for budgets.
06-codegen
Code Generation
Scope: workspace generators, schema-to-types pipelines, protobuf/OpenAPI codegen, and keeping generated code consistent with its source of truth.
Generation is how a monorepo keeps N consumers aligned with one schema. Every generated artifact has exactly one source of truth, one generator, and one drift check.
1. Generation taxonomy
| Kind | Examples | Source of truth | Output |
| --- | --- | --- | --- |
| Scaffolding | new package/app/component | template + prompts | new files, reviewed once |
| Schema-first types | OpenAPI, GraphQL, JSON Schema | openapi.yaml, schema.graphql, .json | typed clients, validators |
| Protocol | protobuf, Thrift, Avro | .proto files | messages, stubs, clients |
| Data layer | SQL, ORM schema | migrations, schema.prisma, SQL files | query types, client code |
| Infra | IaC, k8s manifests | templates + values | rendered YAML |
| Config | env schema, feature flags | schema definition | typed accessors, docs |
If an artifact has no schema or template in the repository, it does not belong in a codegen pipeline.
2. Workspace generators
Generators create consistent packages so humans do not hand-roll config.
| Tool | Fit | Notes |
| --- | --- | --- |
| Nx generators | Nx workspaces | nx g @nx/js:lib, @nx/react:component; local plugins for org templates |
| turbo gen | Turborepo | generators as workspace packages; good for lightweight scaffolds |
| Plop | any repo | interactive prompts + templates; simple and framework-free |
| Hygen | any repo | file-per-template, fast, script-friendly |
| Yeoman | any repo | older but still maintainable; heavier |
| Custom scripts | any repo | acceptable for one or two scaffolds; do not reinvent Nx |
Rules for generators:
- The generator is the only way to create a package; PR review for
packages/*assumes generated wiring. - Generated packages include tests, README, ownership entry, and CI visibility from birth (tags, catalog entries, workspace globs are automatic).
- Generators are tested: run the generator in CI against a temp workspace and assert the output builds.
- Keep prompts minimal; support non-interactive flags (
--name,--scope,--dry-run) for scripting. - Version generators with the tool they generate for (Nx plugin major follows Nx major).
- When a convention changes, update the generator and codemod existing packages; do not leave both forms alive.
Example local generator layout (Nx):
tools/generators/lib/
index.ts # generator entry: options, files, install
files/
package.json.template
tsconfig.json.template
src/index.ts.template
index.spec.ts # runs the generator, asserts tree
3. Schema-to-types pipelines (JS/TS)
| Source | Tool options | Output | Notes |
| --- | --- | --- | --- |
| OpenAPI 3.x | openapi-typescript (types only), orval/openapi-fetch (client), openapi-generator (multi-language) | types.ts, typed fetch client | generate from a committed spec, never from a running server |
| GraphQL | graphql-codegen (client and server presets) | typed hooks/operations | schema + documents; persisted queries optional |
| JSON Schema | json-schema-to-typescript, typebox-codegen | interfaces | pairs with runtime validator generation |
| Runtime validators | zod/valibot built from schemas, or schema-first with typebox | validators + inferred types | single source: schema first, types inferred |
| SQL/ORM | kysely-codegen, prisma generate, drizzle-kit | typed query clients | regenerate after every migration in CI |
Minimal OpenAPI pipeline:
# package.json scripts (contracts package)
"gen:api": "openapi-typescript ../../specs/billing.yaml -o src/generated/billing.ts"
"check:api": "pnpm gen:api && git diff --exit-code src/generated"
// src/index.ts
export type { paths, components } from "./generated/billing";
Rules:
- The spec is committed in the repo (
specs/,packages/contracts/). Generating from a URL or live service makes builds non-reproducible and offline-hostile. - Generated types are consumed through the contracts package; apps never import generated files across package boundaries.
- Validate at runtime too: types alone do not protect against a non-conforming server. See the typescript pack's validator guidance and 02-structure-boundaries.
4. Protobuf and Buf
Buf replaces hand-rolled protoc invocations with lint, breaking-change detection, and generation from a config.
# buf.yaml
version: v2
modules:
- path: proto
lint:
use: [STANDARD]
breaking:
use: [FILE]
# buf.gen.yaml
version: v2
managed:
enabled: true
plugins:
- local: protoc-gen-es
out: gen/es
opt: target=ts
- local: protoc-gen-connect-es
out: gen/es
opt: target=ts
buf lint
buf breaking --against "https://github.com/acme/apis.git#branch=main" # verified remote or git ref
buf generate
Rules:
- One proto module per domain, one generated package per language inside the monorepo (
packages/gen/es,gen/go,gen/py). - Pin plugin versions (
protoc-gen-es@<range>); plugin output changes are breaking for consumers. buf breakingruns in CI against the default branch; it is the cheapest API-compatibility gate available.- Never edit
gen/output; regenerate and commit, or regenerate in CI (choose one policy, below). - Field numbers, enums, and services follow proto evolution rules: never reuse removed field numbers, reserve them.
5. Where generated code lives
| Pattern | Layout | Use when |
| --- | --- | --- |
| Package per artifact | packages/contracts-billing/src/generated | schemas are products consumed by multiple apps |
| Co-located | apps/api/src/generated | single consumer; keep it out of shared packages |
| Language-split | gen/es, gen/go, gen/py | polyglot protobuf |
| Tool-owned | tools/<tool>/generated | repo automation, not runtime code |
Decisions that must be explicit:
- Generated packages are
privateunless they are published deliberately. - Generated code is excluded from lint/format but included in type-check and build.
- CODEOWNERS maps generated directories to the spec owner, not to whoever ran the generator.
- API docs are generated from the same source; do not hand-write types documentation.
6. Commit or generate on build
| Policy | Pros | Cons | Best when | | --- | --- | --- | --- | | Commit generated output | builds work without generators; reviewable diffs; caching simple | noisy diffs; merge conflicts in generated files | external API clients, published packages | | Generate in CI/build | clean diffs; always current | every build needs generators; cache keys must include specs and plugin versions | internal-only code, fast pipelines |
Either way:
- A CI job runs the generator and fails if the committed output differs (
git diff --exit-code), or builds from scratch and tests the generated surface. - Spec and generator versions are task inputs; if they are not, caches will lie.
- On spec change, the PR includes generated diff plus a changeset when the generated package is published.
7. Determinism requirements
- Pin generator and plugin versions exactly; never
@latestin a build step. - Sort maps/keys and imports in templates; byte-identical output across runs and machines.
- Do not embed timestamps, git hashes, or absolute paths in generated headers, or cache and diff checks will always fail.
- Generate with the same toolchain version as CI; a generator upgrade is a deliberate change with a diff to review.
- Use a stable file header:
// Code generated by <tool> <version> from <source>. DO NOT EDIT.
8. Drift detection in CI
- name: Verify generated code
run: |
pnpm run gen:all
git diff --exit-code -- '*/generated/*' 'gen/'
Additional checks:
- Schemas referenced by code must exist (no orphan specs, no unreferenced generated packages).
- Every generated package builds and type-checks; broken generated code must fail fast, not at app build time.
- Buf breaking checks for proto; OpenAPI diff tools (
oasdiff) for REST compatibility. - Track "generated at" provenance in release notes when consumers depend on it.
9. Anti-patterns
| Anti-pattern | Symptom | Fix |
| --- | --- | --- |
| Hand-editing generated files | changes lost on next run | edit the template/spec |
| Generating from a live server | non-reproducible builds, CI flakiness | commit the spec |
| Unpinned generator version | surprise diffs and breakage | pin exactly, upgrade deliberately |
| Spec outside the repo | consumers cannot build offline | vendor the spec in specs/ |
| Generated output not in cache inputs/outputs | stale or skipped codegen | declare inputs and outputs |
| Multiple generators for one artifact | conflicting output | one source, one generator |
| No drift check | committed output silently stale | git diff --exit-code in CI |
| Generated code linted/formatted | churn in every PR | ignore generated paths |
Checklist
- [ ] Every generated artifact has one committed source of truth.
- [ ] Generators are the only path to new packages; generator tests exist.
- [ ] Generator and plugin versions are pinned; output is deterministic.
- [ ] Generated code has a
DO NOT EDITheader and is excluded from lint/format. - [ ] CI regenerates and fails on drift, or builds the generated surface in every run.
- [ ] Specs and generator config are declared task inputs.
- [ ] API compatibility gates (Buf breaking, OpenAPI diff) run on spec changes.
- [ ] CODEOWNERS covers specs and generated directories.
Related: 02-structure-boundaries for where generated packages live, 04-shared-config for exports and build wiring, 05-caching-ci for correct codegen caching.
07-versioning-release
Versioning and Release
Scope: Changesets, fixed vs linked vs independent versioning, release trains, canary/snapshot releases, publishing, and changelogs.
Versioning is a policy decision that must be written down before the first release. The tooling is easy; deciding what "a version" means in your repository is the hard part.
1. Choose a version policy
| Policy | Behavior | Use when | Failure mode | | --- | --- | --- | --- | | Fixed | one version for a group; any change bumps all members | packages ship as one product (component library suite) | unrelated packages churn versions | | Linked | versions kept equal among members, bumped only when a member changes | tightly coupled suites with occasional independent releases | coordination overhead | | Independent | each package versions on its own changes | unrelated libraries with distinct audiences | version skew and dependency range chaos | | None (private) | never versioned, consumed via workspace | internal apps, config packages | accidental publishing |
Typical split in one repo:
- Product apps: not versioned (or versioned by deployment tag), built and deployed, never published.
- Internal libraries:
private: true, no release. - Published libraries: changesets with an explicit group policy.
Document the policy in CONTRIBUTING.md and encode it in .changeset/config.json. Never leave the policy implicit; contributors will guess differently.
2. Changesets workflow
Changesets is the default for JS/TS monorepos. Verify the current CLI and config schema upstream.
// .changeset/config.json
{
"$schema": "https://unpkg.com/@changesets/config/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@acme/web", "@acme/config-*", "@acme/e2e"],
"fixed": [["@acme/ui", "@acme/icons", "@acme/tokens"]],
"linked": [],
"snapshot": { "useCalculatedVersion": false, "prereleaseTemplate": "{tag}-{datetime}" }
}
Workflow:
- Every PR that changes a published package adds a changeset (
pnpm changeset): affected packages, bump type, human-written summary. - CI enforces a changeset on publishable changes (bot check or
changeset status --since=origin/main). - A "Version Packages" PR accumulates changesets; merging it runs
changeset version(bump versions, update internal ranges, write changelogs). - Merging the version PR triggers
changeset publish(or a separate release job) with provenance. changeset tagcreates git tags for each released package; keep tag naming consistent (@acme/ui@1.4.0orv1.4.0for a fixed group).
Rules:
- Changeset summaries are end-user changelog content. "Fix bug" is not a summary; write what changed and why it matters.
- Do not hand-edit
CHANGELOG.md; generated changelogs are the record. updateInternalDependenciescontrols how internal ranges are rewritten;patchis the sane default,minor/majoronly if the policy requires it.- The version PR is reviewed like code: unexpected packages in the diff mean the changeset was wrong.
- Private packages are ignored; if a private package changes, no release is needed, but its dependents may still need a bump through the dependency graph.
3. Release trains
A release train is a scheduled, batched release instead of continuous publishing.
| Model | Cadence | Best for | | --- | --- | --- | | Continuous | every merge to main | infrastructure libraries, fast-consuming teams | | Scheduled train | weekly/biweekly | enterprise consumers needing predictable upgrades | | Milestone train | per product milestone | coordinated product releases | | Freeze windows | release candidate -> GA | regulated or certification-bound products |
Practice:
- Announce the train schedule and the cutoff. After cutoff, changes go to the next train.
- Tag the train and publish release notes that aggregate the included changesets.
- Keep a hotfix lane: critical fixes branch from the release tag, get a patch version, and merge back.
- Version the train itself only if consumers consume the train; otherwise version packages and describe the train in release notes.
4. Canary, snapshot, and prerelease
| Mechanism | Command shape | Purpose |
| --- | --- | --- |
| Canary | changeset version --snapshot canary + changeset publish --tag canary | per-commit testing by early adopters |
| Snapshot | changeset version --snapshot | CI-only verification builds, not published |
| Prerelease | changeset pre enter next -> versions are 1.1.0-next.0 | staged majors/minors with a shared suffix |
| Dist-tag | npm publish --tag next | channel routing without version churn |
Rules:
- Prerelease mode is entered and exited deliberately; forgetting
changeset pre exitstrands the repo in prerelease versions. - Canary versions are disposable; never make a canary a dependency range in a shipped package.
- Publish canaries with a dedicated dist-tag (
canary,next) and never overwritelatest. - Test the consumed artifact: install the canary into a real app in CI before promoting.
5. Publishing
For npm (and equivalents on other registries):
# release.yml (sketch)
permissions:
contents: write
id-token: write # OIDC for provenance/trusted publishing
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<pinned-sha>
with: { fetch-depth: 0 }
- uses: pnpm/action-setup@<pinned-sha>
- uses: actions/setup-node@<pinned-sha>
with:
node-version-file: .nvmrc
registry-url: https://registry.npmjs.org
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build --filter "...[origin/main]"
- run: pnpm exec changeset publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Requirements and practices:
- Enable provenance/trusted publishing via OIDC where the registry supports it; long-lived tokens are the fallback, scoped to publish-only.
publishConfig.accessandpublishConfig.registryper package; scoped private registries configured in.npmrc(never commit tokens).- Build before publish in the same job; never publish whatever happens to be in
dist/. - Validate packages before upload:
npm pack --dry-run,publint,attw; fail the release on findings. - Publish from a clean checkout of the tag/commit; no local uncommitted state.
- Releases are idempotent when retried: Changesets skips versions already on the registry. Prefer retrying over manual
npm publish. - Git tags, GitHub/GitLab releases, and changelogs are created from the same run so they cannot diverge.
- For fixed groups, publish in dependency order; the tool handles topological order, but verify after policy changes.
6. Changelogs and release notes
- Per-package
CHANGELOG.mdgenerated from changesets; one root release note for humans that links the packages. - Changelog entries follow the bump semantics: breaking changes first, then features, then fixes.
- Link PRs/issues in changeset summaries where the tooling allows; traceability is the point.
- Deprecations get their own section and a removal timeline.
- Security fixes: coordinate disclosure, publish patch versions, and note affected ranges clearly without revealing exploit detail before the embargo ends.
7. Alternatives to Changesets
| Tool | Model | Consider when |
| --- | --- | --- |
| nx release | conventional commits or changesets + publish + changelog | already on Nx and want one tool |
| Lerna | legacy fixed/independent versioning + publish | maintaining an existing Lerna repo |
| semantic-release | fully automated from commit messages | single-package or very disciplined conventional commits |
| release-please | manifest-based release PRs | GitHub-centric, language-agnostic |
| Custom scripts | anything | you accept owning edge cases (dist-tags, provenance, prereleases) |
Pick one versioning mechanism. Two mechanisms in one repo guarantee conflicting changelogs and double publishes.
8. Failure modes
| Failure | Cause | Prevention |
| --- | --- | --- |
| Version skew across packages | independent policy without discipline | linked/fixed groups for coupled packages; drift checks |
| Duplicate publish attempts | non-idempotent custom scripts | use changeset publish; retry |
| Published package missing files | files/exports misconfigured | npm pack --dry-run gate |
| Wrong internal range after publish | workspace:* policy mismatch | test install from registry in CI |
| Changelog says nothing | bad changeset summaries | review changesets like code |
| Prerelease leaked to latest | missing --tag | explicit dist-tags, protected release env |
| Stale lockfile in release | install not frozen | frozen install before publish |
| Broken release because a dependency was unpublished | no dependency graph awareness | publish in topo order, verify with a consumer smoke test |
9. Anti-patterns
- Editing
package.jsonversions by hand. - One repo-wide version bump that publishes unchanged packages.
- Skipping changesets for "small" changes to published APIs.
- Publishing from a developer machine or a non-protected branch.
- Using
latestfor prereleases or canaries. - Changelogs generated from raw commit messages with no editing.
- Two release tools racing on the same packages.
- No smoke test that installs the published artifact.
Checklist
- [ ] Version policy documented per package group (fixed, linked, independent, private).
- [ ] Changesets configured with
ignore,fixed/linked, and internal dependency update policy. - [ ] CI requires a changeset for publishable changes and validates
changeset status. - [ ] Version PR reviewed; release job publishes from a clean checkout with provenance.
- [ ] Pre/post release validation: pack, publint, attw, consumer smoke test.
- [ ] Canary/prerelease channels use dedicated dist-tags;
latestis protected. - [ ] Tags, changelogs, and registry state come from one automated run.
- [ ] Hotfix path exists and is documented.
Related: 02-structure-boundaries for API stability, 03-dependencies for internal ranges, 09-governance-metrics for drift and release health.
08-migration
Migration to a Monorepo
Scope: polyrepo consolidation, incremental adoption, history preservation, hybrid strategies, and common failure modes.
Migration is an engineering project with a deadline, a pilot, and a rollback plan. Most failures come from treating it as a git mv exercise.
1. Decide whether to migrate
| Signal for merging | Signal against merging | | --- | --- | | Frequent cross-repo changes with coordinated releases | Teams ship independently with different cadences | | Dependency version skew between repos | Strong isolation/security boundaries between products | | Duplicated config, CI, and tooling maintenance | Repos with genuinely different toolchains and no shared code | | Reuse is blocked by publishing overhead | Repo size or clone time already painful | | Review/ownership friction from scattered PRs | Regulatory requirement for physical separation | | You want one dependency graph and one CI story | No platform capacity to operate shared infrastructure |
Quantify before deciding: number of cross-repo PRs per month, duplicated tool versions, average integration delay, and CI cost per repo. If cross-repo work is rare, a monorepo buys complexity you will not use.
2. Phase 0: baseline
- Inventory repositories: languages, package managers, Node/toolchain versions, CI systems, release flows, test strategies, secrets, and deploy targets.
- Build a dependency map: which repo consumes which, through what artifact (npm package, container, OpenAPI client, git submodule).
- Measure current pain: build times, integration failures, dependency skew, onboarding time.
- Choose the target: layout (02-structure-boundaries), runner (01-tools), release policy (07-versioning-release).
- Decide the source-of-truth model: monorepo primary, mirrors read-only; or hybrid during transition.
- Agree on success criteria and a deadline; without them, migration becomes permanent scaffolding.
3. Import patterns
| Pattern | Mechanics | Pros | Cons |
| --- | --- | --- | --- |
| Merge into an existing repo | add imported repo as a directory, preserve history where possible | one landing zone, incremental | root configs must absorb new toolchains |
| Fresh monorepo, import projects | create new repo, import each project | clean history and configs | coordination cost; two repos during transition |
| History-preserving import | git filter-repo or git subtree add | blame and bisect survive | merge conflicts if the source repo stays active |
| History-squash import | copy files, one "import" commit | simple, small repo | lose blame; archaeology breaks |
| Submodule/vendor | keep repos, reference them | no migration | not a monorepo; boundary stays hard |
History-preserving import with git filter-repo (verify current syntax upstream):
# In a clone of the source repo, rewrite to a subdirectory, then fetch into the monorepo.
git filter-repo --to-subdirectory-filter packages/billing
# In the monorepo:
git remote add billing /path/to/rewritten
git fetch billing
git merge --allow-unrelated-histories billing/main
Rules:
- Freeze writes to the source repo during the import, or you will reconcile twice.
- Tag the source repo at the import commit so future archaeology has an anchor.
- Do not mix structural moves and content changes in one commit; the diff should read as a move.
- Record the import mapping (old repo/path -> new path) in
docs/for one year minimum.
4. Incremental adoption and hybrid strategies
You do not need a big-bang cutover.
- Tooling first — bring the target package manager, config package, and task runner into the monorepo with a single pilot project.
- Shared libraries first — import low-risk libraries that multiple teams already copy or publish. They create the first internal dependency edges and prove the workspace protocol.
- One app at a time — import apps behind their existing pipelines; keep their build and deploy mostly intact, then migrate to the monorepo task graph.
- Decommission last — turn off old repositories only after the monorepo pipeline has been green for a full release cycle.
Hybrid patterns during transition:
| Pattern | Description | Exit condition | | --- | --- | --- | | Source of truth split by domain | monorepo owns libs, polyrepo owns one app | app imported | | Read-only mirror | CI publishes snapshots from monorepo to old repos/packages | consumers migrated | | Publish-and-consume bridge | monorepo publishes internal packages; old repos consume them as versions | old repos imported | | Reverse migration | extract a package back out when isolation is required | enforced boundary, separate CI |
Keep the number of transitional states small and time-boxed. Every hybrid adds a synchronization loop someone must own.
5. Sequencing a single repo import
- Create the target directory with a placeholder README and CODEOWNERS entry.
- Copy the source files without build outputs, lockfiles, or CI secrets.
- Add workspace membership (glob already covers the path) and internal dependency rewrites (
file:/relative ->workspace:). - Unify toolchain: one Node version, one TypeScript version, shared configs.
- Make the package build, lint, typecheck, and test under the monorepo graph.
- Simplify CI: delete the old workflow once the monorepo pipeline covers the package.
- Add ownership, docs, and a smoke test of the deployed artifact.
- Announce the freeze of the old repo; archive it (do not delete) after the first successful release.
Checkpoint after each import: main is green, CI time is within budget, and no cross-package deep imports were introduced.
6. Migrating dependencies and tooling
- Replace
file:and relative links withworkspace:protocol; see 03-dependencies. - Consolidate lockfiles; expect one large dependency resolution event. Dedupe before merging so the diff is readable.
- Move shared configs into config packages (04-shared-config) before mass-migrating packages; otherwise you copy divergent configs.
- Align test frameworks and versions in the same pass; mixed runners multiply CI complexity.
- Rebuild CI as a graph pipeline, not a concatenation of the old workflows (05-caching-ci).
- Keep one temporary escape hatch: a package that cannot yet run under the shared toolchain gets an explicit exemption with an expiry date.
7. Common failure modes
| Failure | Symptom | Prevention | | --- | --- | --- | | CI time explosion | PR feedback goes from minutes to an hour | affected detection, caching, sharding before import | | Boundary collapse | everything imports everything within weeks | tags and lint rules from day one (02-structure-boundaries) | | Ownership vacuum | no one reviews cross-cutting changes | CODEOWNERS per package before import; platform team for tooling | | Lockfile conflicts | constant resolution churn | small PRs, dedupe, schedule dependency work | | Tool drift | each package pins different versions | shared config packages, single toolchain version | | Platform bottleneck | one team approves every structural change | empower package owners; platform owns the system, not the code | | Lost release independence | everything must release together | explicit version policy, independent groups where needed | | Big-bang migration | months of shadow work, no value delivered | pilot first, incremental imports, measure each phase | | Irreversible move | cannot extract a package later | keep package APIs clean and dependencies declared so extraction stays possible | | History loss | blame/bisect unusable | history-preserving import or documented squash decision |
8. Rollback and exit strategy
- A package extraction plan is mandatory: clean
exports, declared dependencies, no repo-internal absolute paths, and a build that produces a publishable artifact. Any package that meets these can be extracted in days. - Keep CI for the old repo until the new pipeline has run a full release cycle.
- If the migration stalls, stop importing and stabilize what was imported. A half-migrated repo with broken ownership is worse than two polyrepos.
- Document the decision to stay hybrid, including the owner and review date.
9. Post-migration hardening
- Add drift checks (version skew, config divergence, orphaned packages) from 09-governance-metrics.
- Set and enforce CI budgets; measure the first month and publish the numbers.
- Onboard every team with a walkthrough of the graph, release flow, and ownership model.
- Run a retro at 30 and 90 days: what got faster, what got slower, what tooling needs another pass.
- Archive old repositories with a pointer README to the new location.
10. Anti-patterns
- Migrating all repositories at once without a pilot.
- Moving code while also rewriting it; do one or the other.
- Importing build outputs,
node_modules, or.envfiles. - Keeping old CI workflows alive "just in case" for months.
- No CODEOWNERS entry for imported packages.
- Copying package lockfiles into the monorepo.
- Using
file:links as a permanent bridge, breaking dedupe and publish. - Declaring victory before the first deploy/release from the monorepo.
Checklist
- [ ] Decision documented with quantified signals for and against migration.
- [ ] Baseline inventory and dependency map exist.
- [ ] Target layout, runner, and release policy chosen before importing.
- [ ] Pilot project imported and measured before broad migration.
- [ ] History strategy chosen (preserve or documented squash).
- [ ] Workspace protocol replaces all cross-repo links.
- [ ] CODEOWNERS, shared config, and CI graph cover every imported package.
- [ ] Rollback/extraction path tested at least once.
- [ ] Old repositories archived with pointers after one green release cycle.
Related: 01-tools for runner choices, 02-structure-boundaries for the target layout, 05-caching-ci for CI rework.
09-governance-metrics
Governance and Metrics
Scope: CODEOWNERS, drift detection, healthy-monorepo metrics and budgets, documentation, and the platform team model.
A monorepo is shared infrastructure. Governance is what keeps sharing from turning into coupling: named owners, detectable drift, and numbers everyone can see.
1. Ownership with CODEOWNERS
CODEOWNERS (GitHub) or the platform equivalent maps paths to reviewers. It is the cheapest accountability mechanism in a monorepo.
# .github/CODEOWNERS
* @acme/platform
/apps/web/ @acme/web-team
/apps/api/ @acme/backend-team
/packages/ui/ @acme/design-system
/packages/contracts/ @acme/architecture @acme/backend-team
/packages/config/ @acme/platform
/tools/ @acme/platform
/.github/ @acme/platform
/pnpm-workspace.yaml @acme/platform
/pnpm-lock.yaml @acme/platform
# generated code is owned by the spec owner, not the generator runner
/packages/gen/** @acme/architecture
/**/generated/** @acme/architecture
Rules:
- Most specific rule wins; order matters in the file (later rules take precedence in GitHub Code Owners). Verify current semantics upstream.
- Every top-level directory has an owner; never rely on the
*fallback for a package. - Lockfile and workspace config belong to the platform team; they are global contracts.
- Generated code and specs have a single owner each; split ownership causes contradictory fixes.
- Ownership is reviewed when packages move, merge, or are deprecated.
- Pairing rules (team plus platform) on protected paths prevent silent breakage without creating a bottleneck.
- Measure ownership coverage: percentage of project directories matched by a non-fallback rule. Target 100%; report the gap monthly.
2. Rulesets and required checks
- Protect the default branch: linear history or merge queue, required reviews for owned paths, required status checks.
- Required checks should be a small, stable set: install/lint/typecheck/build/test as configured by the graph. Keep names stable; renaming a check silently unblocks merges.
- Use rulesets (GitHub) or push rules (GitLab) to protect release tags, workflow files, and generated directories.
- Release environments require approval and are scoped to the release workflow; no human publishes from a laptop.
- CI configuration files are code-owned by the platform team; changes to them get a security-minded review.
3. Drift detection
Drift is any divergence that no single change intended. Detect it mechanically; do not rely on reviewer memory.
| Drift type | Check | Tooling |
| --- | --- | --- |
| Dependency version skew | same dep declared with multiple incompatible ranges | syncpack list-mismatches, sherif, pnpm why -r |
| Toolchain drift | Node/package-manager/tool versions differ between packages and CI | .nvmrc, engines, packageManager, CI matrix comparison |
| Config drift | tsconfig/ESLint options diverge silently | extend-only policy; config inheritance check |
| Ownership drift | packages with no CODEOWNERS match | script against project list |
| Boundary drift | unapproved cross-package imports | boundary lint rules, turbo boundaries |
| Generated drift | committed output differs from regeneration | git diff --exit-code after codegen |
| Dependency freshness | packages pinned to stale majors | Renovate dashboard, pnpm outdated -r |
| Dead packages | no consumers, no changes in months | dependency graph query, git log |
| Duplicate packages | two implementations of the same capability | inventory by scope tags |
| License drift | new licenses incompatible with policy | license audit in CI |
| Secrets drift | credentials in package configs or outputs | secret scanning, cache artifact scanning |
Example policy script shape:
# CI: fail on workspace hygiene violations
pnpm exec syncpack list-mismatches || exit 1
pnpm exec sherif --check || exit 1
node tools/scripts/check-ownership.mjs || exit 1
node tools/scripts/check-orphans.mjs || exit 1
Rules:
- Every automated drift check has an owner and a documented fix path; a check nobody can fix becomes noise.
- Prefer warnings in PRs and errors on main for new checks; graduate to errors everywhere after cleanup.
- Run drift checks on a schedule as well as on PRs, so dormant drift is caught without a code change.
4. Metrics that matter
Measure the system, not individual activity. Publish a small dashboard; more metrics usually mean less attention.
| Metric | Definition | Healthy direction | | --- | --- | --- | | Install time | frozen install on a cold runner | stable; alert on >20% regression | | PR CI wall time (p50/p95) | merge-base diff to final check | p50 minutes, p95 bounded | | Queue time | wait before a runner starts | low; alert on queue growth | | Cache hit rate | remote hits / cacheable tasks | high and stable; investigate drops | | Affected ratio | projects run / projects total per PR | low by design, not by gaming | | Flake rate | tasks needing retry or failing intermittently | <1-2% per task | | Build failure rate on main | red main commits per week | near zero; investigate clusters | | Change lead time | first commit to production/release | stable or improving | | Ownership coverage | projects with a real owner | 100% | | Boundary violations | new violations per week | zero tolerated | | Dependency freshness | share of deps on supported majors | reviewed monthly | | Repo size / clone time | full and shallow clone durations | bounded; alert on growth | | Onboarding time | first merged PR for a new engineer | measured, improving |
Anti-metrics to avoid: lines of code, commit counts per developer, number of packages, PR count. They reward the wrong behavior and say nothing about the system.
5. Budgets and alerts
Set explicit budgets and treat breaches as defects:
- CI p95 for a normal PR under a fixed budget (for example 15 minutes); exceeding it triggers an investigation, not a shrug.
- Remote cache hit rate above a floor (for example 70% for repeated CI runs); a drop usually means a hashing mistake or a new non-deterministic input.
- Full main build under a budget; growth tracked per month.
- Flake rate below 1% per task; a task above threshold gets quarantined and fixed.
- Install time budget; lockfile changes that double install time are blocked until resolved.
Wire alerts to the platform team's channel, with links to the failing signal and a runbook. See 05-caching-ci for how cache and CI metrics are produced.
6. Documentation
Minimum documentation set for a healthy monorepo:
| Document | Location | Content |
| --- | --- | --- |
| Root README | README.md | what the repo contains, how to install/build/test, links |
| Package README | each package | purpose, owner, public API, examples, status |
| CONTRIBUTING | CONTRIBUTING.md | branch/PR flow, changesets, boundaries, review expectations |
| Architecture decisions | docs/adr/ | decisions with dates and consequences |
| Runbooks | docs/runbooks/ | CI, cache, release, and incident procedures |
| Onboarding | docs/onboarding.md | day-one setup, graph tour, ownership map |
| Migration/import log | docs/migrations/ | old repo -> new path mapping and dates |
Rules:
- Documentation is tested where possible: links checked in CI, commands copy-pasteable, README examples executed in a smoke job.
- Keep one canonical place per fact; duplicated docs drift faster than code.
- A package without a README is not ready to be shared.
- Update docs in the same PR that changes behavior.
7. The platform team model
- The platform team owns the system: tooling, caching, CI, release automation, governance checks. It does not own every package.
- Package owners own their code, API, tests, and on-call for their domain.
- The platform team runs a service mindset: published SLOs, an intake path, and a changelog for tooling changes.
- Structural changes (layout, runner, boundaries) require an ADR and platform + stakeholder review.
- Rotation or office hours prevent the platform team from becoming a ticket queue.
- Measure platform work by developer outcomes (CI time, flake rate, onboarding) rather than ticket counts.
8. Anti-patterns
| Anti-pattern | Symptom | Fix | | --- | --- | --- | | Fallback owner for everything | no one feels responsible | per-directory owners, coverage check | | CODEOWNERS out of date | review requests stall or skip experts | ownership review on every move; automated coverage | | Metrics used for performance review | gaming, hiding problems | system metrics only, published transparently | | No budgets | slow erosion until CI is unusable | explicit budgets with alerts | | Drift checks with no owner | warnings ignored | assign owner and fix path, or delete the check | | Documentation duplicated | conflicting instructions | one canonical source | | Platform team as sole contributor | bottleneck, burnout | distribute package ownership, enable self-service | | Zero tolerance for red main misapplied | hotfix freezes | fast revert/revert-forward culture with metrics |
Checklist
- [ ] CODEOWNERS covers every package, tooling directory, and generated path; coverage measured.
- [ ] Branch protection and rulesets enforce reviews and stable required checks.
- [ ] Drift checks run in CI and on a schedule: skew, config, ownership, generated, orphans.
- [ ] Core metrics (CI wall time, cache hit rate, flake rate, queue time) are on a dashboard.
- [ ] Budgets have owners, alerts, and runbooks.
- [ ] README/CONTRIBUTING/ADR/onboarding docs exist and links are checked in CI.
- [ ] Platform team scope, SLOs, and intake path are documented.
- [ ] Structural changes require an ADR; changes reviewed with stakeholders.
Related: 01-tools for the system being governed, 05-caching-ci for metric production, 02-structure-boundaries for ownership-aware boundaries.