Monorepo Architecture & Orchestration
Modern JavaScript and TypeScript ecosystems demand deterministic builds, strict dependency isolation, and scalable task orchestration. A monorepo centralizes shared code, tooling, and deployment pipelines behind a single version-controlled history, enabling atomic commits across many packages — but it also concentrates resolution, build-ordering, and caching complexity into one place. This guide establishes production-grade standards for topology design, dependency classification, lockfile integrity, ESM/CJS packaging, and task orchestration across npm, pnpm, Yarn, Turborepo, and Nx.
The payoff of getting this right is concrete: a well-architected monorepo turns a 20-minute cold CI run into a 90-second cache-hit run, blocks phantom dependencies before they reach production, and lets a single pnpm -r build rebuild only the packages that actually changed.
How the topics in this guide fit together
This guide is organized around the decisions a platform team makes when scaling a monorepo. At the foundation, Cross-Package Dependency Management governs how internal packages reference each other through the workspace: protocol, and how you keep that graph acyclic — the failure mode covered in depth by Debugging Circular Dependencies in Monorepos. The physical layer underneath it — how node_modules is materialized — is the subject of Workspace Symlinks vs Hard Links, with the most common breakage documented in Fixing Broken Symlinks in pnpm node_modules.
On top of that foundation sit the task runners. Choosing a Monorepo Task Runner compares the options before you commit; once committed, Turborepo Pipeline Configuration and Nx Workspace Architecture cover the two dominant engines, with their tradeoffs quantified in Nx vs Turborepo Performance Benchmarks and Nx's incremental targeting detailed in Configuring Nx Affected Commands in CI.
Scoping work down to what changed is handled by pnpm Workspace Filtering and its hands-on companion Using pnpm --filter for Targeted Builds. Finally, the speed multiplier across all of it is Remote Caching Setup, expanded by Optimizing Turborepo Remote Cache for CI, Fixing Turborepo Remote Cache Misses, and Self-Hosting a Turborepo Remote Cache. Tying affected detection, caching, and parallelism together into a fast pipeline is the focus of CI/CD Pipeline Optimization for Monorepos. When the packages in this monorepo are ready to ship, the workflows in Package Publishing & Release Engineering take over.
1. Topology, package resolution, and the packageManager contract
Monorepos consolidate multiple packages into a single repository, reducing cross-repo synchronization overhead and enabling atomic commits that span many packages at once. Polyrepos offer stricter physical boundaries but suffer from dependency drift, duplicated CI configurations, and fragmented versioning. For platform teams and library authors, the monorepo model wins decisively when paired with strict resolution rules and explicit package boundaries — and loses badly without them, because every weakness compounds across packages.
Package managers materialize internal workspace resolution differently. npm defaults to a flat, hoisted node_modules tree, which risks phantom dependencies — modules that import successfully in development because a transitive dependency happened to be hoisted, then fail in production. Yarn Berry introduced Plug'n'Play (PnP) to eliminate node_modules entirely via a .pnp.cjs resolver, though it also ships a node-modules linker for conventional layouts. pnpm enforces strict isolation through a content-addressable store plus a symlinked node_modules, guaranteeing that a package can only resolve dependencies it explicitly declared.
To guarantee deterministic resolution across every environment, pin the package manager version with the packageManager field in the root package.json. Corepack (bundled with Node.js since 16.13) reads this field and transparently proxies to the correct binary, so a developer who runs pnpm install with the wrong version installed gets an error instead of a silently different lockfile.
{
"packageManager": "pnpm@10.4.1",
"engines": {
"node": ">=18.0.0"
},
"scripts": {
"preinstall": "npx only-allow pnpm"
}
}
The only-allow preinstall guard is the second half of the contract: it rejects npm install or yarn invocations that would otherwise produce a competing lockfile. Pin both halves — the binary version and the allowed manager — and toolchain drift across developer machines and CI runners disappears.
Choosing a manager is itself a topology decision. npm's flat hoist is the path of least resistance and the most permissive, which is exactly why it leaks phantom dependencies most readily; it suits small workspaces where every package is published together and strictness buys little. pnpm's isolated linker is the safest default for a growing monorepo because it makes phantom dependencies structurally impossible — a package that forgot to declare lodash simply cannot resolve it. Yarn Berry's PnP is the strictest of all and the fastest to install, at the cost of tooling that must understand the .pnp.cjs resolver; reach for it when install time on CI is your dominant pain and your toolchain is PnP-aware. Whichever you pick, encode it once in packageManager and let Corepack enforce it for everyone.
# .npmrc (root) — pnpm strictness defaults
node-linker=isolated
strict-peer-dependencies=true
auto-install-peers=true
Internal workspace packages must resolve predictably regardless of which manager you choose. The mechanics of that resolution — symlinks for a single canonical copy versus hard links into a shared store — are covered in Workspace Symlinks vs Hard Links. The default for every modern manager is a symlink, because it preserves clear package boundaries, supports concurrent builds, and reflects source edits immediately without a reinstall. When those symlinks break — usually after a partial install, an interrupted pnpm install, or a manual node_modules edit — the resolver throws Cannot find module for a package that visibly exists on disk; that exact failure and its recovery are documented in Fixing Broken Symlinks in pnpm node_modules.
A predictable layout also depends on a deliberate workspace definition. Keep the package globs narrow and explicit in pnpm-workspace.yaml (or the workspaces array for npm/Yarn), exclude test fixtures so they are never linked, and group packages by role — packages/* for libraries, apps/* for deployables — so the dependency direction is obvious at a glance. The full layout patterns, including shared tooling configuration, are covered in Workspace Configuration Deep Dive and its companions Setting Up npm Workspaces for Small Teams and Setting Up Shared ESLint Configs in Workspaces. Teams migrating off an older toolchain should start with Migrating from Yarn 1 to pnpm Workspaces.
The packageManager field is the keystone of a reproducible monorepo, because every package manager implements hoisting and resolution slightly differently, and a mismatch between developers surfaces as lockfile churn nobody can explain. Declaring "packageManager": "pnpm@10.4.1" and enabling Corepack means every checkout — laptop or CI runner — activates the identical resolver, so the installed tree is a function of the lockfile alone rather than of whatever version happened to be on PATH. Without it, two engineers running pnpm install on the same commit can produce different node_modules and a lockfile diff that has nothing to do with their change.
Package globs define the boundary of the workspace, and keeping them explicit prevents accidental inclusion of directories that should not be packages. A packages/* plus apps/* layout is conventional, but the more important decision is the hoisting policy in .npmrc: disabling broad hoisting (hoist-pattern[]=) forces every package to declare what it imports, which eliminates phantom dependencies — the class of bug where a package works locally only because a sibling happened to hoist a dependency it never declared. Phantom dependencies pass CI and break the moment the graph is rearranged, so structural prevention beats detection.
The physical layout underneath the logical graph matters for both speed and correctness. pnpm's content-addressed store links each package into node_modules from a single on-disk copy, so a hundred packages sharing a dependency store it once and link it many times — fast to install and impossible to accidentally duplicate on disk. That strict, symlinked structure also surfaces phantom dependencies immediately, because a package genuinely cannot import what it did not declare, which is why strictness at install time translates directly into fewer 'works locally, breaks in CI' failures.
2. Dependency classification across the workspace
Inside a monorepo, dependency classification is not cosmetic — it determines what gets installed, what gets bundled, and what a consumer of a published package is forced to supply. Three buckets matter, and the boundaries between them follow the same rules covered for single packages in Understanding package.json Fields.
| Field | Installed for consumers? | Use it for |
|---|---|---|
dependencies |
Yes | Runtime imports the package needs at execution time |
devDependencies |
No | Build tools, test runners, type definitions, linters |
peerDependencies |
No (consumer supplies) | Shared singletons like react or a plugin host |
The line between peer and dev dependencies is the one teams get wrong most often; the decision tree in When to Use peerDependencies vs devDependencies resolves the ambiguity. For internal packages, never use a bare semver string. Use the workspace: protocol so the manager symlinks the local source instead of fetching a stale published copy from the registry:
{
"dependencies": {
"@repo/utils": "workspace:*"
},
"peerDependencies": {
"react": ">=18"
}
}
At install time the manager rewrites workspace:* to the actual version when the package is published, so the same manifest works locally and on the registry. The full mechanics of internal references — including when to use workspace:^ for independently versioned private packages — are the subject of Cross-Package Dependency Management.
To patch a transitive CVE across every package at once without cascading major bumps, declare a root-level override:
{
"pnpm": {
"overrides": {
"lodash@<4.17.21": ">=4.17.21",
"semver": "7.5.4"
}
}
}
The equivalents are resolutions for Yarn and overrides for npm. Use them only for security patches and critical bug fixes — verify each one with pnpm why <package> first, because an override that breaks a peer range will fail silently until runtime.
A subtle but expensive monorepo failure is two copies of the same singleton dependency — most famously two React versions, which produces the "Invalid hook call" error because hooks compare identity across module instances. This happens when one package pins react as a direct dependency and another receives it transitively at a different version. The fix is to declare such singletons as peerDependencies in every internal package and pin the single allowed version once at the root; the diagnosis and deduplication procedure is detailed in Deduplicating Duplicate React Versions. The broader resolution algorithm — how ranges become a single resolved tree — is explained in Dependency Resolution Explained, and the specific ERESOLVE failure that surfaces when peer ranges conflict is handled in Fixing npm ERESOLVE Peer Dependency Conflicts.
Classification behaves the same per package as in a single repo, but its consequences multiply across the graph. A framework declared as a direct dependency inside a shared library is installed independently for every consumer of that library, so one mislabelled react can fan out into a dozen duplicated copies across apps, each breaking context and inflating bundles. The audit is therefore a periodic hygiene task, not a one-time decision: run pnpm why react after significant dependency changes to confirm the framework resolves to exactly one physical copy across the whole workspace.
Version consistency across packages is its own discipline. When five packages each declare their own zod range, they can drift into incompatible resolved versions and reintroduce duplicate-instance bugs. A workspace catalog (pnpm) centralizes those versions so every package references catalog: instead of a hard-coded range, and a tool like syncpack can enforce that no two packages declare divergent ranges for the same dependency. The goal is a workspace where a shared dependency has one version everywhere, changed in one place, verified in CI.
Auditing the graph is a first-class monorepo task because the blast radius of a bad dependency is the whole repo. A single vulnerable transitive package can appear under many workspace packages at once, so remediation with a root-level overrides/pnpm.overrides pin fixes every path in one place — and re-auditing after the pin confirms no path was left behind. Running the audit as a CI gate, thresholded so only high and critical advisories block, keeps the signal actionable across dozens of packages rather than drowning reviewers in low-severity noise.
3. Lockfile integrity and CI enforcement
A monorepo lockfile is the single source of truth for the entire dependency graph, which makes its integrity non-negotiable. The strategies for keeping it clean — committing it, regenerating it deterministically, and resolving the merge conflicts it inevitably produces — are covered in Lockfile Management Strategies, with the gnarliest case handled by Fixing pnpm-lock.yaml Merge Conflicts.
The cardinal CI rule: never run a mutating install in a pipeline. pnpm install or npm install will silently rewrite the lockfile to satisfy whatever package.json says, which defeats the entire point of pinning. Use the frozen variant so the job fails loudly when the lockfile and manifests disagree:
steps:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Verify workspace integrity
run: pnpm list --recursive --depth=0
The npm equivalent is npm ci; Yarn Berry uses yarn install --immutable. Each one refuses to mutate the lockfile and exits non-zero on drift, which converts a class of "works on my machine" bugs into a failed check on the pull request that introduced them.
A monorepo lockfile is also the highest-value merge-conflict generator in the repository, because almost every pull request that adds or bumps a dependency touches it. The wrong reflex — hand-editing the conflict markers — corrupts the integrity hashes and produces an install that no longer matches any real resolution. The correct procedure is to accept either side, then regenerate deterministically with a single install; the pnpm-specific recipe is in Fixing pnpm-lock.yaml Merge Conflicts. Configure the lockfile as a binary-merge path in .gitattributes so Git stops attempting line-level merges that only create more corruption:
# .gitattributes
pnpm-lock.yaml merge=binary linguist-generated=true
Pair the frozen install with an explicit integrity report in CI so a tampered or partial lockfile is caught before any code runs:
pnpm install --frozen-lockfile
pnpm list --recursive --depth=0 # fails if the resolved tree is incomplete
A monorepo has a single lockfile at its root that pins the entire graph, which is a strength — one frozen install proves the whole repo resolves — and a responsibility, because that lockfile is the largest and least reviewable file in the repository. Enforce it with pnpm install --frozen-lockfile in every CI job so a manifest change that was not accompanied by a lockfile regeneration fails loudly rather than being silently reconciled. The frozen install is the single cheapest guarantee of reproducibility across a large workspace.
Because the lockfile is unreviewable by eye, shift the review effort to the manifest diffs and let automation vet the lockfile. Route dependency bumps through dedicated pull requests where reviewers read which packages moved and why, while CI proves the regenerated lockfile installs cleanly and a lockfile-lint step confirms every entry resolves from an allowed host. This combination — human review of intent, machine verification of the graph — is what keeps a monorepo's dependency updates both safe and fast.
The remote cache is the speed multiplier that makes a large monorepo's CI tractable, and it depends on the same reproducibility the lockfile provides. Because a task's cache key includes the resolved dependency graph, a frozen install against a committed lockfile is what lets a result computed on one runner be safely replayed on another — and on developer laptops. Scope cache write access to trusted branches so a pull request cannot poison the shared cache, keep PR builds read-only against it, and the cache becomes a trustworthy accelerator rather than a new attack surface.
4. ESM/CJS boundaries and dual packaging
Every published package in the monorepo must declare an explicit exports map. Relying on the legacy main/module fields produces ambiguous resolution in Node.js and bundlers, and lets consumers reach into internal paths you never meant to expose. The interoperability rules — and the failure modes when you get them wrong — are the subject of ESM and CJS Interoperability, and the dual-output recipe is laid out in How to Configure package.json for Dual Modules.
{
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs"
},
"./package.json": "./package.json"
}
}
Order matters inside each condition: types first so TypeScript resolves declarations, then import/require. A mismatched or missing require condition is the direct cause of the runtime ERR_REQUIRE_ESM failures documented in Fixing ERR_REQUIRE_ESM in Node.js. A different but related failure — an ESM consumer importing a named export that the package only exposes through a default CommonJS object — is covered in Resolving 'Named Export Not Found' in ESM. Validate every package's map with publint and attw (Are The Types Wrong?) in CI before it can be published.
Type declarations need the same dual treatment as runtime code, because a single index.d.ts written for one module system will misreport signatures to consumers using the other. Emit separate declaration files alongside the ESM and CJS bundles, and point each types condition at the matching one. The mechanics of producing both — and the Cannot find module declaration errors that result from skipping it — are covered in TypeScript Declaration Publishing, with the dual-emit recipe in Generating Dual CJS/ESM Type Definitions and the most common consumer-side error in Fixing 'Cannot Find Module' Type Declaration Errors.
In a monorepo the module-format boundary is easy to get subtly wrong because internal packages are consumed through symlinks during development but as published artifacts by external users. A package that 'works' when imported by a sibling through the workspace: symlink can still ship a broken exports map to the registry, because the sibling resolved the source directly and never exercised the published conditions. The fix is to build and consume internal packages through their published entry points in at least one CI job, so the same exports resolution an external consumer hits is validated inside the repo.
Dual packaging across many packages also multiplies the dual-package hazard. If two internal packages each ship ESM and CJS builds and an app reaches one through import and the other through require, shared singletons can double. Standardizing the whole workspace on a single module format where possible — most new monorepos are ESM throughout — sidesteps the hazard entirely, and where a package must be dual-format, keeping its stateful core in one shared module preserves a single instance.
5. Build orchestration, scripts, and the task graph
Monorepo performance hinges on modeling builds as a Directed Acyclic Graph (DAG). Build, test, and lint tasks must execute in topological order so a consumer never builds before its provider, while independent branches of the graph run in parallel. Task runners compute a cache key for each task from its source-file content hashes, declared inputs, environment variables, and the cache keys of its upstream dependencies — so a task is only re-executed when something it actually depends on changed.
The first decision is which engine to adopt; Choosing a Monorepo Task Runner frames the tradeoff between Turborepo's lightweight, config-driven model and Nx's deeper project-graph inference. With Turborepo you declare the graph explicitly in turbo.json per Turborepo Pipeline Configuration:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tsconfig.json"],
"outputs": ["dist/**"]
},
"test": { "dependsOn": ["build"] },
"lint": {}
}
}
The ^build token means "build every upstream dependency first." With Nx Workspace Architecture the graph is inferred from your import statements rather than declared, which powers the affected-project targeting detailed in Configuring Nx Affected Commands in CI. For a quantified comparison of cold-build and warm-cache timings, see Nx vs Turborepo Performance Benchmarks.
Scoping a run to a subset of the graph is where filtering earns its keep. With pnpm Workspace Filtering the --filter flag selects packages by name, glob, path, or graph relationship:
pnpm --filter "@repo/ui..." build
The trailing ... includes @repo/ui and every package that depends on it; the hands-on patterns are in Using pnpm --filter for Targeted Builds. Combined with a Git ref — --filter '...[origin/main]' — the same flag selects exactly the packages changed since a base branch plus their dependents, which is the foundation of fast pull-request CI: a one-line change to a leaf utility rebuilds only the handful of packages that actually consume it, while everything else is skipped or restored from cache.
Whether a script lives at the root or inside each package — and how those layers compose — is covered by Root-Level vs Package-Level Scripts. The general rule is to keep cross-cutting orchestration (build, test, lint, release) at the root where the task runner can see the whole graph, and keep package-specific lifecycle hooks inside each package. Fanning a single command out across the workspace — pnpm -r --parallel test, or a filtered subset — is the subject of Running Scripts Across Workspaces with pnpm.
The final multiplier is shared computation. Remote Caching Setup stores task outputs in an authenticated backend keyed by the same content hashes, so a CI runner — or a teammate — can download an artifact that someone else already built instead of recomputing it. Always pair remote caching with --frozen-lockfile: the lockfile hash is part of the cache key, and a drifting lockfile silently invalidates every entry. The CI-tuning, miss-debugging, and self-hosting paths are covered respectively by Optimizing Turborepo Remote Cache for CI, Fixing Turborepo Remote Cache Misses, and Self-Hosting a Turborepo Remote Cache.
When the graph is green and cached, release engineering takes over. Coordinated versioning, changelog generation, and provenance-signed publishing for interdependent packages are the domain of Package Publishing & Release Engineering, which automates the version bumps that internal workspace:* references would otherwise force you to hand-edit.
The task graph is the monorepo's execution engine, and its correctness depends entirely on the dependency graph being accurate. A task runner reads dependsOn: ["^build"] to mean 'build every package I depend on first', derives a topological order, and runs independent tasks in parallel — but only the declared edges are visible to it. An undeclared cross-package import means a consumer can build before its dependency, producing a flaky, order-dependent failure that reproduces only under certain scheduling. Accurate declarations are therefore a build-correctness requirement, not just documentation.
Caching turns that ordered graph into a fast one. Each task's inputs — source files, dependencies, config, declared environment — hash to a key, and a matching key replays the stored output instead of recomputing it. Combined with affected detection, which prunes packages no change can reach, a well-configured task graph means a one-line edit rebuilds one package and replays everything else from cache, whether that cache is local or shared across the whole team through a remote cache.
Affected detection is what keeps CI time proportional to the change rather than to the repo. By diffing against a base branch and walking the dependency graph, the runner selects only the packages a change can reach — the changed package plus its dependents — and skips the rest, so a one-line edit runs a small pipeline even in a hundred-package repo. It composes with caching: affected removes packages the change cannot reach, and the cache replays any survivor whose inputs already have a stored result, which is why the two together are the foundation of fast monorepo CI.
Common Mistakes
| Mistake | Impact | Resolution |
|---|---|---|
Omitting the packageManager field |
Different managers/versions produce divergent lockfiles across dev and CI | Pin packageManager and add an only-allow preinstall guard |
Allowing implicit cross-package imports that bypass exports |
Runtime resolution failures and leaked internals in production | Declare explicit exports maps; validate with publint/attw |
Running mutating install in CI |
Non-deterministic trees; phantom dependency bugs | Use pnpm install --frozen-lockfile / npm ci / yarn --immutable |
Over-hoisting internal packages to root node_modules |
Broken isolation; phantom dependencies pass tests then fail in prod | Use node-linker=isolated (pnpm) or PnP (Yarn) |
| Skipping remote-cache auth tokens | Every CI run rebuilds from scratch | Configure TURBO_TOKEN / NX_CLOUD_ACCESS_TOKEN and --frozen-lockfile |
| Bare semver for internal deps | Registry fetches stale published copies instead of local source | Use the workspace:* protocol |
Enforcing module boundaries across packages
A monorepo's biggest long-term risk is not build speed but architectural erosion: packages quietly importing each other in ways that create cycles and coupling nobody intended. The defense is to make boundaries explicit and enforce them mechanically. Tag each package by layer (type:ui, type:feature, scope:billing) and add a lint rule — Nx's module-boundary rule, or an ESLint import-restriction config — that forbids illegal edges, so a UI package importing a feature package fails lint rather than shipping.
The payoff compounds as the repo grows. Enforced boundaries keep the dependency graph acyclic, which keeps topological builds well-defined and affected detection accurate; they also keep refactors local, because a package's blast radius is limited to its declared dependents. Treat an attempt to import across a forbidden boundary as a design signal — usually the shared code wants extracting into a lower-layer package both sides can depend on, which is the acyclic resolution to what would otherwise become a cycle.
Frequently Asked Questions
How do I enforce ESM/CJS boundaries in a monorepo?
Define explicit exports maps in every package's package.json, set "type": "module" for ESM-first packages, and use dual-build tooling such as tsup or rollup to emit separate dist/esm and dist/cjs outputs with a types condition first. Validate every map with publint and attw in CI before publishing.
Why does my CI pipeline fail with lockfile mismatch errors?
A mutating install ran somewhere, or the lockfile was committed out of sync with a package.json change. Always run pnpm install --frozen-lockfile (or npm ci / yarn install --immutable) in CI and pin the packageManager field so every environment resolves to an identical tree.
Should I use symlinks or hard links for workspace packages?
Internal workspace references are symlinked by every modern manager so a single canonical copy reflects source edits immediately. Hard links are how pnpm deduplicates the content-addressable store into each package's node_modules; the two serve different layers, as explained in Workspace Symlinks vs Hard Links.
Turborepo or Nx — which task runner should I pick?
Turborepo favors a lightweight, explicit turbo.json and minimal lock-in; Nx infers the project graph from imports and ships richer generators and affected-command tooling. Read Choosing a Monorepo Task Runner for the decision criteria and Nx vs Turborepo Performance Benchmarks for measured timings.
How do I safely manage cross-package dependencies without creating circular references?
Enforce a strict DAG: keep shared types and stateless utilities in a leaf package that depends on nothing, and let feature packages depend only downward. Block regressions in CI with eslint-plugin-import/no-cycle or the @nx/enforce-module-boundaries rule. See Cross-Package Dependency Management for the full pattern.
Why do I get different node_modules on the same commit as a teammate?
Almost always a package-manager version mismatch. Different major versions of npm/pnpm/Yarn hoist and resolve differently. Pin packageManager and enable Corepack so every checkout uses the identical resolver, making the tree a function of the lockfile alone.
What is a phantom dependency and how do I prevent it?
A phantom dependency is a package a module imports without declaring, working only because a sibling hoisted it. Disable broad hoisting in .npmrc so every package must declare what it imports; the missing declaration then fails rather than passing by accident.
How do I keep one dependency version across all packages?
Use a pnpm catalog so packages reference catalog: instead of hard-coded ranges, and enforce it with syncpack in CI. A shared dependency then has one version everywhere, changed in one place and verified automatically.
How do I validate an internal package's published exports inside the monorepo?
Add a CI job that builds internal packages and consumes them through their published entry points rather than the workspace: source symlink. That exercises the same exports resolution an external consumer hits, catching a broken map the symlinked dev path would hide.
Related
- Cross-Package Dependency Management — how internal packages reference each other and stay acyclic.
- Choosing a Monorepo Task Runner — pick the orchestration engine before you commit to one.
- Remote Caching Setup — the shared-computation layer that makes large monorepos fast in CI.
- Workspace Symlinks vs Hard Links — how
node_modulesis physically materialized under the hood. - Package Publishing & Release Engineering — ship the packages once the graph is green and cached.
← Home