Skip to content

Monorepo & Workspace Concepts: Shared Vocabulary

The cross-cutting vocabulary the rest of this survey leans on. Where a per-tool deep-dive instantiates a feature in concrete config, this page defines the concept once — workspace topology, dependency isolation, local cross-references, the task DAG, caching/remote execution, and lockfiles — and maps the 44 surveyed tools onto each axis. Every term is grounded in a real tool; follow the cross-link to its deep-dive for the mechanics.

Scope. This is a reference document, not a tool deep-dive. It is the spine for the cross-tool synthesis (consensus standard + "the dub delta") and the umbrella index (master catalog + taxonomies), and the monorepo-tooling analogue of async-io's primitives + techniques. The five research dimensions — workspace declaration, dependency isolation, task orchestration, caching/remote execution, CLI ergonomics — are the spine of every deep-dive and the columns of the master catalog.

Last reviewed: June 5, 2026


Why a layered vocabulary

A monorepo tool accretes capabilities in a predictable order, each tier a precondition for the next: you cannot resolve a local cross-reference until you have discovered the members; you cannot run a topological task DAG until you have a package graph to topo-sort; you cannot content-address a cache key until you have declared a task's inputs; and you cannot offer remote execution (REAPI) until actions are hermetic and content-addressed.

The field stratifies into three bands the rest of this doc revisits: package managers that resolve and isolate dependencies but own no task graph (npm, pnpm, uv, cargo, composer); task orchestrators that overlay a DAG and a cache onto a package manager's workspace (nx, turborepo, lage, wireit); and polyglot build engines that own the action graph, content-addressed cache, and remote execution down to the leaf compiler invocation (bazel, buck2, pants, please).

1. Workspace topology

A workspace is a set of co-located packages ("members") built and resolved as a unit. Two design choices define it: whether the root is itself a buildable package or a stateless grouping manifest, and how members are discovered.

Root-package vs. virtual workspace

ModelRoot manifest is…Exemplars
Root-packageA real, buildable package and the rootcargo ([workspace] in a package's Cargo.toml), npm, yarn-berry, maven aggregator
Virtual workspaceA stateless grouping manifest, not a packagecargo (virtual: root has only [workspace]), pnpm, uv (virtual form), go-work
Either (dual-mode)Tool supports both formscargo, uv, bun
No native rootNo workspace manifest; emergent or per-projectpoetry, composer, cmake, earthly, the generic runners

cargo is the canonical dual-mode design and the template the dub proposal adopts: a root-package workspace is a functional crate that also carries a [workspace] table, whereas a virtual workspace has only [workspace] and no [package]. pnpm is purely virtual; go-work is virtual and developer-local — its go.work use-lists modules for Minimal Version Selection (MVS) and is kept out of VCS.

Member discovery

How the tool finds its members:

Discovery mechanismHow it worksExemplars
Explicit arrayMembers hand-enumerated by path; no globbingmaven (<modules>), go-work (use), rush (rush.json projects[]), gradle (include())
Glob arrayPath globs (often with ! negation) expand to membersnpm/yarn-berry/bun (workspaces), pnpm (packages:), cargo, uv
Filesystem auto-detectEvery directory with a marker file is a package; topology is the treebazel (BUILD files), buck2 (BUCK), pants/please, nix-flakes
Inherited from package managerTool reads the host PM's workspace; declares no members of its ownnx, turborepo, lerna, lage, wireit, moon

The "inherited" row is the defining trait of the JS/TS task orchestrators: lage reads the host's workspaces globs via Microsoft's workspace-tools, and turborepo/nx read package.json#workspaces (or pnpm-workspace.yaml) and overlay only a task layer. The polyglot engines instead make membership implicit in the directory treebazel treats every directory with a BUILD file as a package addressed by a //-rooted label, no members array.

NOTE

A workspace boundary is anchored by a marker file the CLI walks up to find: Cargo.toml with [workspace] (cargo), pnpm-workspace.yaml (pnpm), MODULE.bazel/WORKSPACE (bazel), .buckconfig (buck2), pants.toml (pants), .plzconfig (please), a .gn dotfile (gn), or a .tup/.redo database (tup, redo).

2. Dependency isolation models

Once members are known, their third-party dependencies must be placed on disk so every member can import them without duplication or conflict. Four models dominate, ordered from least to most isolated.

ModelMechanismHazard it trades onExemplars
Flat hoistingOne shared node_modules at the root; deps deduped/lifted to the topPhantom deps; nondeterministic flatteningnpm, yarn-berry (node-modules linker), lerna (via PM)
Strict / isolated symlinksPer-package symlink tree; a package sees only its declared depsSymlink-aware tooling requiredpnpm (node_modules/.pnpm virtual store), rush
Content-addressed storeA single global CAS of packages; members hard-link/reflink or resolve in-memoryNo on-disk node_modules tree at all (PnP)yarn-berry (PnP .pnp.cjs), pnpm store, uv, bun
Per-project vendoringEach project owns a private dependency directoryByte duplication across projectscomposer (vendor/), poetry/hatch (per-project .venv)

Flat hoisting (the original npm model) lifts every dependency into one root node_modules, deduplicating versions but exposing phantom dependencies — a package can import something it never declared because a sibling pulled it to the top. pnpm kills this with an isolated symlink tree: real packages live in a content-addressed store (store/cafs, layout files/<hex[:2]>/<hex[2:]>, hard-linked into a per-project virtual store) and each package is symlinked only its declared deps. Content-addressed virtual stores push furthest — yarn-berry's Plug'n'Play drops node_modules entirely (.pnp.cjs maps each import to a zip in .yarn/cache/, "Zero-Install"), and uv materializes from its global cache via LinkMode::Clone (reflink/CoW) or Hardlink. The REAPI engines (bazel, buck2) and nix-flakes generalize the content-addressed store to all build inputs and outputs — a CAS keyed by digest is the substrate of both their dependency model and their cache (§5).

NOTE

The native build systems and generic runners have no dependency-isolation layer: make, ninja, cmake, meson, scons, waf, just, task, and mise delegate all package placement to the native toolchain; their "cross-references" (§3) are graph edges between files or targets, not isolated package trees.

3. Local cross-references

The defining monorepo capability: a member depending on a sibling member's source, resolved locally without publishing to a registry. Four mechanisms recur.

The workspace: protocol family

A dependency selector meaning "resolve from the workspace, never the registry": in dev it symlinks (or PnP-maps) to the member's source dir, and at publish time it is rewritten to a concrete registry range.

FormSelector exampleExemplars
workspace: proper"@acme/greeter": "workspace:*"yarn-berry, pnpm, bun, rush (via pnpm)
Implicit/automaticany sibling import resolves localgo-work (every used module is a main module), pants (inferred from imports)
Workspace-source{ workspace = true }uv ([tool.uv.sources]), cargo (dep.workspace = true)

yarn-berry is the reference: a workspace:*/^/~ selector links to the member's source with LinkType.SOFT (symlinked, never fetched, not persisted to the lockfile), and the beforeWorkspacePacking hook rewrites it to a real range at publish. go-work makes this implicit — because every used module is a co-equal main module, a cross-member import resolves to on-disk source through MVS with no replace, version, or publish step: the workspace: protocol without the syntax.

Path dependencies, project references, and label edges

The lower-tech analogues — a relative filesystem path, a typed project value, or a graph label — achieving the same local-first resolution without a selector protocol.

MechanismEdge is…Exemplars
Relative path= depA directory path to the siblingcargo (path = "../sibling"), composer (path repos), poetry (path, develop=true), and dub today (baseline)
Typed project referenceA type-checked project value, not a path stringsbt (dependsOn(util)), mill (moduleDeps = Seq(foo)), gradle (project(":path"))
Label edgeA //path:target graph label in one namespacebazel, buck2, please, gn, pants
Coordinate interceptionA normal published coordinate, intercepted locallymaven (ReactorReader serves the sibling's fresh target/), gradle composite-build substitution

The dub baseline sits in the path= row: a Sparkles member depends on sparkles:core-cli with path="../..", the manual scheme the dub proposal replaces with a workspace:-style protocol — see dub-baseline.md. maven's coordinate interception is a distinct trick: a sibling is an ordinary <dependency> GAV, but the reactor's ReactorReader (a MavenWorkspaceReader) intercepts resolution within the build to serve the freshly-built target/ output, falling back to ~/.m2 only outside a reactor build.

Version unification (catalog: / [workspace.dependencies])

A central registry of versions at the root so members reference a shared upstream by name, eliminating version drift. cargo is the archetype: a central [workspace.dependencies] table consumed via dep.workspace = true, plus [workspace.package] field inheritance (version.workspace = true, authors.workspace = true). The JS ecosystem reached the same destination via the Gradle-inspired catalog: protocol (pnpm, bun catalogs:, yarn-berry): a root catalog pins one react and every member writes "react": "catalog:". Both are the dub proposal's Milestone-2 target (vibe-d.workspace = true).

4. The task / target graph

Dependency placement (§2–3) determines what builds against what; the task graph determines what runs in what order. The pivotal distinction is package-graph vs. action-graph.

Package-graph vs. action-graph

Graph kindNodes are…GranularityExemplars
Package graphWhole members/projectsCoarse (per-pkg)pnpm -r, yarn-berry foreach -t, bun --filter
Task graph(package × task) pairsMediumturborepo, nx, lage, wireit, rush
Action graphSingle compiler/tool invocationsFine (per-action)bazel (Skyframe), buck2 (DICE), cargo (units), ninja (file edges)
Implicit data-flow DAGAPI/command calls; edges follow data flowFine (per-op)dagger (BuildKit LLB), earthly, garden (Stack Graph)

A package graph sorts whole members (pnpm's pnpm -r run chunks the project DAG via graphSequencer under pLimit-bounded --workspace-concurrency). A task graph crosses that package graph with a per-task pipeline: lage's "target graph" is the explicit pipeline crossed with the package dependency graph, where ^build is "build of my dependencies", ^^transpile is transitive, pkg#task is a specific node, and bare build is same-package. An action graph dissolves packages entirely: cargo schedules a DAG of "units" (one rustc/build-script/doc invocation each), and bazel's action graph is the task DAG, so a Skyframe rebuild touches only the reverse-transitive closure of changed inputs.

Topological execution

The shared engine: topo-sort the graph, run independent legs concurrently under a job cap, build a member's prerequisites before its dependents.

ConceptDefinitionExemplars
Topological orderA node runs only after all its predecessors finishyarn-berry foreach -t, maven reactor, sbt, cargo JobQueue
Concurrency capA bound on simultaneously-running legs-j/--jobs (cargo, mill, ninja), --concurrency 10 (turborepo), --parallel 3 (nx)
Cycle detectionReject or warn on a dependency cyclemaven (ProjectSorter DFS), wireit, yarn-berry (CYCLIC_DEPENDENCIES)
JobserverGNU-Make-compatible token pool shared across nested toolscargo, make, ninja (since 1.13), redo

Most package managers stop before this engine — npm run --workspaces runs scripts sequentially with no topology, precisely why turborepo, nx, lage, and wireit exist as overlays. yarn-berry's yarn workspaces foreach -t is the direct inspiration for the dub proposal's loop.

Change detection

Bounding work to what actually changed. Three families, increasingly precise.

MechanismWhat it comparesExemplars
mtime / timestampFile modification times vs. outputsmake, ninja, cmake/meson (delegated)
Input hashingA content hash of declared inputs vs. a stored fingerprintturborepo, nx (xxh3_64), bazel, gradle, cargo fingerprints
Affected / --sinceA VCS diff against a ref → changed members + dependentslerna (--since), nx affected, moon (Git-aware), please (plz query changes --since)

These compose: moon pairs per-task content hashing with a Git-aware affected tracker, and nx layers --since affected detection on top of its xxh3_64 computation hash.

WARNING

mtime-based detection is fragile on ephemeral CI. make's incrementality "vanishes on a fresh checkout" because every mtime is new, so it rebuilds everything — the structural reason CI-heavy monorepos migrate to input-hashing tools whose cache survives a clean clone (§5).

5. Caching

A cache reuses a prior result instead of recomputing it, varying by where (local vs. remote), what (downloaded packages vs. task outputs), and how keyed (content-addressed and hermetic, or not).

The caching ladder

TierReuses…Keyed byExemplars
Download / metadata cacheFetched package archives onlyintegrity digestnpm (_cacache), composer, maven (~/.m2), poetry, go-work ($GOCACHE)
Local task-output cacheBuild/test outputs on this machineinput content hashturborepo (.turbo), nx (.nx/cache), gradle, mill, bazel (--disk_cache)
Remote / shared cacheTask outputs across machines & CIsame hash, fetched over HTTP/gRPCturborepo (HttpCache), nx (Nx Cloud), gradle (HttpBuildCache), scons (CacheDir), waf (wafcache → S3/GCS), sbt 2.x
Remote execution (REAPI)Runs the action on a remote workeraction digest → CAS + ActionCachebazel, buck2, pants, please, buildbuddy, buildbarn, nativelink

The crucial cliff is between remote cache and remote execution. Almost every modern orchestrator now offers a remote cache that replays a prior result (terminal logs + output files) keyed by a content hash — turborepo, nx, lage, rush, wireit, gradle, moon. Far fewer run the action remotely: remote execution ships the action's inputs to a worker fleet and runs the compiler there, which requires the action to be hermetic (fully described by its declared inputs, no ambient state).

Cache keys and hermeticity

A cache key is sound only if it captures every input that can change the output — source-file contents (wireit SHA-256, nx xxh3_64, bazel Merkle CAS), command + args (turborepo, rush), env vars (lage's environmentGlob), the hashes of upstream task outputs (lage, wireit transitive fingerprints), and the toolchain/platform (bazel, nix-flakes full closure). Content-addressing is the unifying technique: an action is reduced to a digest (SHA-256/BLAKE3) over its complete input set, and the cache maps that digest to the output blobs. bazel, buck2, and the dedicated REAPI backends — buildbuddy, buildbarn, nativelink — implement the Remote Execution API (REAPI v2): ContentAddressableStorage (CAS) + ByteStream + ActionCache + (optionally) Execution. These backends own no workspace; they only ever see the hashed, post-analysis action graph a client emits. nix-flakes reaches the deepest hermeticity in the catalog — a content-addressed store keyed by the full build closure, with binary caches giving exact-environment hits — but over Nix's own protocol, orthogonal to REAPI.

IMPORTANT

Caching is the largest single capability gap in the catalog. The language package managers (cargo, uv, go-work, npm, pnpm, composer) and dub (baseline) cache only downloads and local incremental build state; none has a shared task-output cache. sbt 2.x is the rare language package manager with native REAPI-compatible task caching. The full dub gap analysis lives in the cross-tool synthesis.

6. Lockfiles

A lockfile pins the exact resolved version of every dependency for reproducible, offline installs. The monorepo question is scope: one lockfile for the whole workspace, or one per member.

ModelScopeExemplars
Unified root lockfileOne lock resolving all members togetherpnpm (pnpm-lock.yaml), yarn-berry (yarn.lock), bun (bun.lock), cargo (Cargo.lock), uv (uv.lock), rush (single by default)
Per-project lockfileEach member resolves and locks independentlypoetry (poetry.lock), hatch, composer (composer.lock)
Sharded / subspace locksMultiple locks within one workspacerush (subspaces), pnpm (opt-in)
No lockfileResolution is positional or content-hash, not pinnedgo-work (MVS, no workspace lock), bazel (MODULE.bazel.lock), make/ninja (none)

Resolution unification is the prize of the unified model: uv resolves the whole workspace into one uv.lock against a single shared .venv, and cargo produces one Cargo.lock for all members, so a transitive dependency is pinned to one version monorepo-wide — no two members can silently disagree. The per-project model (poetry, hatch) forgoes this, allowing cross-member version drift; go-work is the outlier with no workspace lockfile at all, relying on MVS to pick the minimal satisfying version.

Dub today has a per-package dub.selections.json (with a Nix-format nix/dub-lock.json shared across sub-packages); unifying these into a single root selections file is the dub proposal's Milestone-1 deliverable — see dub-baseline.md.

Putting it together: the concept-to-tool grid

Each axis is a spectrum, minimal-to-maximal, with dub today at or near the minimal end of most:

AxisMinimal endMaximal end
Workspace topologyno root (poetry, composer)dual-mode (cargo, uv)
Member discoveryexplicit array (maven)filesystem auto-detect (bazel)
Dependency isolationper-project vendoring (composer)content-addressed CAS (bazel, nix-flakes)
Local cross-refsrelative path= (dub)workspace: + catalog: (pnpm, cargo)
Task graphnone (npm, uv)fine action graph (bazel, buck2)
Change detectionmtime (make)input-hash + --since (nx, moon)
Cachingdownload-only (npm, dub)REAPI execution (bazel, buck2)
Lockfileper-project (poetry)unified root (cargo, pnpm, uv)

The consensus modern monorepo — the subject of the cross-tool synthesis — lands in the middle-to-right of every axis: a dual-mode root, glob discovery, an isolated or content-addressed store, a workspace:/catalog: cross-reference protocol, a topological task DAG with input-hash change detection, a remote cache, and a unified root lockfile. Dub today (baseline) sits at the minimal end of most axes; the dub proposal is the milestoned plan to close that delta, borrowing the cargo workspace model, the yarn-berry workspace: protocol and topological loop, and the pnpm/cargo unified lockfile and version catalog.

Sources

  • Per-tool primary sources are cited in each deep-dive; this page synthesizes the 44-tool catalog into shared definitions.
  • Structural models: async-io's primitives and techniques (the layered-vocabulary pattern), and the coroutines concepts doc.
  • Cross-cutting sibling: dub-baseline.md (the system under improvement). The umbrella index and the cross-tool synthesis build on these definitions.