Skip to content

Monorepo Layout — pnpm Workspaces + Turborepo/Nx

FieldValue
TypeSkill Resource
Source~/.copilot/skills/architecture/references/monorepo.md
DescriptionNot 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 version by 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

  1. 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.
  2. 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.
  3. Configure the workspace. pnpm-workspace.yaml plus a pinned "packageManager": "pnpm@9.x" in the root package.json; forbid npm/yarn via engines.
  4. Wire the pipeline. turbo.json with build depending on ^build, cached test/lint/typecheck, and an uncached persistent dev task.
  5. Enforce boundaries in lint, not docs. eslint-plugin-import zone rules (or Nx tags): apps cannot import other apps; packages/contracts imports nothing; ui imports contracts only.
  6. 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 on main.
  7. Version deliberately. Changesets for packages/* libraries (a PR adds a .changeset/*.md); apps and services version by git SHA.
  8. Add a remote cache once it earns its keep. A self-hosted turborepo-remote-cache on R2 or MinIO, with TURBO_TOKEN and TURBO_TEAM set in CI.
  9. 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 a package.json with explicit exports.
  • 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/foo import from apps/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).

References