Monorepo Layout — pnpm Workspaces + Turborepo/Nx
| Field | Value |
|---|---|
| Type | Skill Resource |
| Source | ~/.copilot/skills/architecture/references/monorepo.md |
| Description | Not specified |
Source Content
Monorepo Layout — pnpm Workspaces + Turborepo/Nx
Guidance for consolidating multiple deployable apps into one repository: layout, package boundaries, change-based CI, and versioning. Validate any workspace config with scripts/lint_workspace_config.sh.
When this fits
- The codebase has more than two deployable apps that share code.
- Builds duplicate config across repos (
tsconfig.json,.eslintrc,vite.config). - CI runs the whole test suite on every PR when affected-only would be a step change.
- Library versioning is ad-hoc (
npm versionby hand, no changelog discipline). - Package boundaries need to be established before they ossify.
Not a fit for a single app with no shared code (a split repo is simpler), hard per-repo access control, wildly different deploy cadences, CI pipeline internals (that’s platform territory), or container/Helm packaging (also platform territory).
The method
- Pick the tool. Turborepo for minimal config and fast incremental builds; Nx if generators and a plugin ecosystem matter enough to justify the heavier setup.
- Lay out the tree.
apps/*— deployable applications.services/*— Go services.packages/*— shared libraries:contracts,ui,config-eslint,config-tsconfig,observability.tooling/*— scripts and internal tools.
- Configure the workspace.
pnpm-workspace.yamlplus a pinned"packageManager": "pnpm@9.x"in the rootpackage.json; forbid npm/yarn viaengines. - Wire the pipeline.
turbo.jsonwithbuilddepending on^build, cachedtest/lint/typecheck, and an uncached persistentdevtask. - Enforce boundaries in lint, not docs.
eslint-plugin-importzone rules (or Nx tags): apps cannot import other apps;packages/contractsimports nothing;uiimportscontractsonly. - Make CI change-based.
turbo run build test lint --filter=...[origin/main]so only affected projects and their dependents run on a PR; the full graph still runs onmain. - Version deliberately. Changesets for
packages/*libraries (a PR adds a.changeset/*.md); apps and services version by git SHA. - Add a remote cache once it earns its keep. A self-hosted
turborepo-remote-cacheon R2 or MinIO, withTURBO_TOKENandTURBO_TEAMset in CI. - Validate.
scripts/lint_workspace_config.sh [config-file]confirms JSON validity and that every declared workspace glob/path resolves to a real directory; fix to zero errors.
Constraints (MUST / MUST NOT)
- MUST: one lockfile (
pnpm-lock.yaml); every package has apackage.jsonwith explicitexports. - MUST: the dependency graph is a DAG — no cycles between packages.
- MUST NOT: introduce a “god package” depended on by everything.
- MUST NOT: let
apps/fooimport fromapps/bar. - MUST NOT: mix npm/yarn lockfiles into a pnpm tree.
Worked examples
Consolidating four React apps into one repo — propose the apps/* + packages/* layout, extract shared tsconfig/eslint into config packages, wire turbo.json, and enforce boundaries with lint zones.
CI taking 30 minutes on every PR — add --filter=...[origin/main], stand up a self-hosted remote cache on R2, and turn unchanged-package builds into instant cache hits.
Nx vs. Turborepo — Turborepo for minimal config and fast incremental builds; Nx when generators and plugins matter. Name the trade-off and pick; do not leave it open.
Who this leans on
- Rachel Potvin — tooling makes a monorepo possible; culture makes it work (see Why Google Stores Billions of Lines of Code in a Single Repository).
- Camille Fournier — Conway’s Law is a description, not a warning; repo boundaries match org boundaries or one of them breaks (The Manager’s Path).
- Will Larson — scope is the most important leadership question; decide what’s in the repo before the politics start (Staff Engineer).