Cross-Package Dependency Management
Internal packages in a monorepo must resolve to local source deterministically, version in lockstep, and never form a cycle — and the difference between getting that right and wrong is a build that either rebuilds only what changed or fails silently in production with a phantom dependency.
This page covers the full lifecycle of how packages inside a workspace reference each other: the workspace: protocol, hoisting control, build-order inference, supply-chain validation, and the refactors that break cycles. It sits under Monorepo Architecture & Orchestration, which frames where dependency management fits among topology, caching, and task orchestration.
1. Workspace resolution and the workspace: protocol
Workspace resolution dictates how internal packages are linked. Modern package managers use protocol prefixes to bypass registry lookups and generate local symlinks, so source changes are reflected immediately during development. The physical mechanics — when a symlink versus a hard link is used — are covered in Workspace Symlinks vs Hard Links; here the focus is the manifest contract that drives them.
Lock the package manager version at the repository root to prevent toolchain drift across developer machines and CI runners:
{
"packageManager": "pnpm@10.4.1",
"engines": {
"node": ">=18.0.0",
"pnpm": ">=10.0.0"
}
}
Declare the workspace boundaries so the manager knows which directories contain linkable packages:
# pnpm-workspace.yaml
packages:
- "packages/*"
- "apps/*"
- "!**/test/**"
Never use a bare semver string for an internal dependency. A string like "@repo/ui": "^1.0.0" tells the manager to consult the registry, which fetches a stale published copy instead of linking the local source. Use the workspace: protocol so resolution always points at the working tree:
{
"dependencies": {
"@repo/ui": "workspace:*",
"@repo/utils": "workspace:*"
}
}
workspace:* locks to the local source and, at publish time, the manager rewrites it to the exact current version. Use workspace:^ only when you publish internal packages to a private registry with independent versioning, where external consumers should receive a semver range rather than a pinned wildcard. The full set of protocol variants behaves as follows:
| Specifier | Published as | Use when |
|---|---|---|
workspace:* |
exact current version | packages share one release cycle |
workspace:^ |
caret range of current version | independently versioned private packages |
workspace:~ |
tilde range of current version | consumers should accept patch updates only |
workspace:1.2.3 |
exact pinned version | a hard pin survives publish unchanged |
The classification of these dependencies into runtime, dev, and peer buckets follows the same rules as any package, detailed in Understanding package.json Fields. For internal packages the common mistake is over-declaring: a build tool used only to compile the package belongs in devDependencies, never dependencies, or every consumer of the published package inherits it. A shared singleton such as react belongs in peerDependencies so the consuming application supplies a single instance — the alternative is the duplicate-instance bug covered later in this guide.
Initialize a clean tree with Corepack so every contributor runs the pinned binary:
corepack enable
corepack prepare pnpm@10.4.1 --activate
pnpm install --frozen-lockfile
Internal dependencies are the defining feature of a monorepo, and the workspace: protocol is what makes them behave correctly in both development and release. A range like workspace:^ resolves to a symlink into the local package during development, so a change to a shared library is live in every consumer immediately with no rebuild-and-relink cycle; at publish time the package manager rewrites the specifier to the real published version, so external consumers receive a normal semver range. Hardcoding a version instead reintroduces the stale-copy problem the protocol exists to eliminate.
The protocol also encodes how tightly internal packages couple. Using workspace:* pins to whatever version the local package currently is, which is simplest within a repo that always builds together; workspace:^ publishes a caret range so external consumers can deduplicate. Choosing between them is a decision about how the packages are consumed after publish, and getting it wrong can either over-constrain external consumers or let them drift onto an incompatible version of an internal dependency.
The protocol's publish-time rewrite is the detail that makes internal dependencies safe to publish. A workspace:^ specifier is replaced at pack time with a caret range against the dependency's published version, so external consumers receive a normal package with normal dependency ranges and never see the protocol. Verifying that no workspace: specifier leaked into a published manifest is worth doing, because a leaked specifier makes the package uninstallable for anyone outside the monorepo — a failure that is invisible internally, where the protocol resolves through symlinks, and total externally.
2. Hoisting control and transitive version pinning
Strict hoisting control eliminates the runtime Cannot find module errors caused by implicit transitive access. By isolating each package's node_modules, you force explicit declarations — the single most effective defense against phantom dependencies that pass tests locally and break in production.
Enable isolated linking at the workspace root:
# .npmrc (root)
node-linker=isolated
auto-install-peers=true
strict-peer-dependencies=true
To force a single version of a transitive dependency across the entire workspace — mandatory for patching a CVE without triggering cascading major bumps — use root-level overrides:
{
"pnpm": {
"overrides": {
"lodash@<4.17.21": ">=4.17.21",
"semver": "7.5.4",
"minimist": "1.2.8"
}
}
}
The equivalents are resolutions for Yarn and overrides for npm. The most expensive hoisting failure is a duplicated singleton — two copies of react resolved at different versions because one package declared it directly while another received it transitively. Because React compares hook identity across module instances, the result is the runtime "Invalid hook call" error even though both versions are individually valid. The fix is structural: declare the singleton as a peerDependency in every internal package that uses it, pin the single allowed version once at the root with an override, and confirm a single instance with pnpm why react. The end-to-end diagnosis is documented in Deduplicating Duplicate React Versions, and the resolution algorithm that produces the duplicate in the first place is explained in Dependency Resolution Explained.
Verify alignment before merging any override or peer change:
# Explain why a specific version was installed
pnpm why react
# List every workspace dependency at the top level
pnpm list --recursive --depth=0
# Fail the install if a peer range is unsatisfied
pnpm install --strict-peer-dependencies
Because the lockfile encodes the resolved graph for the whole workspace, keep it clean using the practices in Lockfile Management Strategies — an override that changes resolution but isn't committed produces a drift that only surfaces in CI.
Controlling hoisting is how a monorepo keeps its dependency boundaries honest. Broad hoisting flattens dependencies to the root, which is convenient but permits phantom dependencies and can mask a version conflict by silently sharing one copy where two were needed. Restricting hoisting — pnpm's strict default, or explicit .npmrc patterns — forces each package to declare what it imports, so the graph the tooling sees matches the graph the code actually uses. Transitive version pinning through root overrides then handles the cases where a shared transitive dependency must be forced to a single version across the workspace.
Transitive version pinning is the tool for the case where several internal packages, or their external dependencies, disagree about the version of a shared transitive package. Left alone, the resolver may install two copies, which for a stateful package produces the duplicate-instance bugs that resist debugging. A root-level override forces a single compatible version across the whole workspace, collapsing the copies — and because the override lives at the root, it applies to every package at once, which is exactly what you want for a shared dependency that appears under many packages.
The discipline with a pin is to scope it as narrowly as the problem requires and to treat it as temporary. A global override forces a version everywhere; a scoped one forces it only under a named path, leaving other consumers on their resolved versions and reducing the compatibility claim you are making. Document why each pin exists, verify with pnpm why that every path moved, and remove it once the packages that needed it have converged upstream, so the workspace's overrides reflect current, deliberate decisions rather than accumulated debt.
Hoisting is the mechanism that decides whether a shared dependency lives once at the root or separately per package, and its default behavior shapes which bugs are possible. Aggressive hoisting flattens dependencies to the root, which is convenient and can mask a version conflict by silently sharing one copy where two ranges genuinely disagree; strict, per-package resolution keeps each package's dependencies isolated so a conflict surfaces rather than hides. Controlling hoisting deliberately — usually toward strictness — is what keeps the graph the tooling sees aligned with the graph the code actually uses, which every downstream capability depends on.
3. Build order and task-graph orchestration
Task runners infer execution order by parsing the dependency graph, so the workspace:* edges you declared in section 1 double as the build-order specification. Adopting Turborepo Pipeline Configuration or Nx Workspace Architecture gives you topological sorting that prevents the race condition where a consumer builds before its provider. If you have not committed to an engine yet, Choosing a Monorepo Task Runner compares them against this exact requirement.
Declare input and output boundaries plus topological dependencies in turbo.json:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"inputs": ["src/**", "tsconfig.json"]
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"]
},
"lint": {}
}
}
The ^build token forces every upstream dependency to build first; without it a consumer could compile against a stale dist/ of its provider. The inputs and outputs arrays are what make the task cacheable: the runner hashes the listed inputs to form a cache key and restores the listed outputs on a hit, so declaring them precisely is the difference between a cache that helps and one that constantly misses. Keep inputs tight — source plus the configs that affect the build — and never list a generated directory as an input, or every build invalidates itself.
Scope execution to only the packages that changed, then let the graph fan out:
# Build only packages affected since main, plus their dependents
turbo run build --filter='...[origin/main]'
# Force a full rebuild, ignoring the cache, for a clean CI baseline
turbo run build --force
The filtering syntax that powers --filter is documented in depth in pnpm Workspace Filtering.
The internal dependency plus the task-graph edge together produce correct ordering. A package declares its internal dependency with the workspace protocol, and the task runner declares that a build waits on its dependencies' builds:
// packages/ui/package.json
{ "dependencies": { "@acme/tokens": "workspace:^" } }
// turbo.json
{
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
"test": { "dependsOn": ["build"] }
}
}
The ^build edge means every package builds after the local packages it depends on, so @acme/ui compiles against a freshly-built @acme/tokens rather than a stale artifact. The runner derives the topological order from these declared edges, which is why an undeclared cross-package import — invisible to the graph — is a build-correctness bug, not just untidy.
4. Supply-chain validation and isolation
Cross-package dependency management requires automated supply-chain validation: lockfile integrity checks and vulnerability scanning that block a merge when a critical CVE appears in a transitive dependency. Run both as required pull-request checks so no human has to remember to look.
# .github/workflows/dependency-validation.yml
name: Dependency Validation
on:
pull_request:
paths:
- 'pnpm-lock.yaml'
- '**/package.json'
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.4.1
run_install: false
- name: Install and verify the lockfile
run: pnpm install --frozen-lockfile # refuses to mutate the lockfile
- name: Security audit
run: pnpm audit --audit-level=high --json > audit-results.json
- name: Fail on high or critical CVEs
run: |
HIGH=$(jq '.metadata.vulnerabilities.high' audit-results.json)
CRITICAL=$(jq '.metadata.vulnerabilities.critical' audit-results.json)
if [ "$HIGH" -gt 0 ] || [ "$CRITICAL" -gt 0 ]; then
echo "Blocked: high/critical vulnerabilities detected."
exit 1
fi
The --frozen-lockfile flag is the isolation guarantee here: it ensures the audited tree is exactly the committed tree, not whatever the resolver would generate today. When an audit flags a transitive package you don't control, the override pattern from section 2 is the surgical fix; reserve broad version bumps for a separate, reviewed change:
# Review and apply minor updates interactively
npx npm-check-updates --target minor --interactive
# Record a coordinated release for the affected packages
npx changeset
Cross-package dependencies are an internal supply chain, and the same validation you apply to external packages applies to them. Because internal packages are consumed through symlinks in development but as published artifacts by external users, a package that works when a sibling imports its source can still ship a broken exports map to the registry — so at least one CI job should build and consume internal packages through their published entry points, exercising the resolution an external consumer hits. Combined with a frozen install, ignored scripts, and an audit threshold, the internal graph stays both correct and hardened.
Isolation between packages is enforced by declaring dependencies honestly and forbidding undeclared edges. A package should be able to import only what it declares, so a strict, symlinked layout or an explicit boundary lint rule turns an undeclared cross-package import into a caught error rather than a phantom dependency that works by accident. This isolation is what keeps a change's blast radius limited to its declared dependents, which is the property affected detection, ordered builds, and safe refactoring all rely on.
Validating internal packages the way an external consumer resolves them catches a class of bug the symlinked development path hides. Because a sibling importing an internal package's source never exercises the published exports conditions, a broken map — a missing require condition, a wrong types path — is invisible in development and total for an external consumer. A CI job that builds internal packages and consumes them through their published entry points, rather than the workspace symlink, exercises the same resolution an external install hits, so a packaging mistake fails your pipeline instead of a downstream user's.
5. Diagnosing and breaking cycles
When resolution fails because of bidirectional imports or a mismatched peer range, treat the cycle as an architecture problem, not a resolver bug. Debugging Circular Dependencies in Monorepos walks through the detection and refactoring workflow in detail; the diagnostic entry points are below.
# Visualize the tree and locate duplicate installs
pnpm list --recursive --depth=3 --long
# Trace why a specific version was installed
pnpm why webpack
# Inspect the actual symlink targets
ls -la node_modules/@repo/
Always resolve a cycle by decoupling the shared surface rather than forcing resolution. Extract the shared contracts into a dedicated leaf package with zero internal dependencies, and let everything else depend downward on it:
packages/
├── @repo/types/ # zero runtime deps — pure TS interfaces
├── @repo/utils/ # dependsOn: @repo/types
├── @repo/api/ # dependsOn: @repo/types
└── @repo/web/ # dependsOn: @repo/types, @repo/utils, @repo/api
This is the same DAG shown in the diagram above: every edge points toward the leaf, so no package can ever import a package that imports it back.
Cycles are worth detecting automatically because they are easy to introduce and hard to spot by reading code. A circular-dependency check — a madge-style scan or the task runner's own graph validation — run in CI fails the build when a new import closes a loop, turning a subtle, intermittent ordering failure into a caught, nameable error on the pull request that introduced it. Because a cycle makes build order undefined and defeats affected detection, catching it at the point of introduction is far cheaper than diagnosing the flaky build it later causes.
When a cycle does appear, the fix is almost always to extract the shared code both packages need into a third, lower-layer package that both depend on, converting a mutual dependency into two dependencies on a common base. Occasionally the right move is to invert a dependency — recognizing that the edge points the wrong way — or to merge two packages that were never really separable. Whichever applies, the target is a directed acyclic graph, because that structure is what every downstream capability, from ordered builds to boundary enforcement, relies on.
Common Pitfalls
| Mistake | Impact | Resolution |
|---|---|---|
| Bare semver strings for internal packages | Registry fetches a stale published copy instead of linking local source | Enforce the workspace:* protocol |
| Ignoring peer dependency alignment | Runtime Cannot find module in production |
Set strict-peer-dependencies=true |
Omitting --frozen-lockfile in CI |
Lockfile drift; non-deterministic builds | Run pnpm install --frozen-lockfile |
| Relying on implicit hoisting | Phantom dependencies pass tests, fail in prod | Use node-linker=isolated |
| Bidirectional package imports | Circular dependency initialization failures | Extract shared contracts to a @repo/types leaf |
Build order and the task graph
A monorepo's build is not a set of independent package builds but a single ordered operation over the dependency graph. A package must build after the local packages it imports, or it compiles against stale or missing artifacts, so the task runner derives a topological order from the graph and runs packages in dependency order — leaves first, consumers last — parallelizing independent packages where the graph permits. This is why an accurate graph is a build-correctness requirement: an undeclared cross-package import is an edge the runner cannot see, so a consumer can build before its dependency and fail in a way that reproduces only under certain scheduling.
The task graph extends this ordering to every task type, not just builds. dependsOn: ["^build"] means a package's build waits on its dependencies' builds; a test task can depend on the build so it runs against fresh artifacts; a lint task usually depends on nothing and runs freely. Combined with caching, the ordered graph becomes a fast one — each task's inputs hash to a key, and an unchanged task is replayed rather than recomputed — so a change to one package rebuilds that package and its dependents in order, replaying everything the change did not touch.
Diagnosing and breaking dependency cycles
Cycles are the failure mode a monorepo's dependency graph is most prone to, and they are corrosive because they make build order undefined: if package A depends on B and B depends on A, neither can build first. Cycles also defeat affected detection and boundary reasoning, since the graph is no longer a directed acyclic structure the tooling can order. Detecting them early — with a madge-style circular-dependency check or the task runner's own graph validation in CI — turns a cycle from a mysterious intermittent build failure into a caught, nameable problem.
Breaking a cycle is almost always the same move: extract the shared code both packages need into a third, lower-layer package that both depend on, converting the A → B → A cycle into A → shared and B → shared, which is acyclic. Occasionally the cycle signals that two packages are really one and should be merged, or that an import points the wrong way and should be inverted. Whichever applies, the goal is a directed acyclic graph, because that is the structure every downstream capability — ordered builds, affected detection, boundary enforcement — depends on. Enforcing acyclicity in CI keeps a cycle from ever being merged in the first place.
Frequently Asked Questions
Should I use workspace:* or workspace:^ for internal dependencies?
Use workspace:* for packages inside the same monorepo that share a release cycle — it forces symlink resolution to local source and bypasses the registry. Use workspace:^ only when you publish internal packages to a private registry with independent versioning, so external consumers receive a semver range.
How do I prevent phantom dependencies in a monorepo?
Enable strict isolation with node-linker=isolated in pnpm (or nohoist in Yarn). This ensures a package can only resolve dependencies it explicitly declares in its own package.json, so it can never accidentally import a hoisted transitive dependency that disappears in production.
Why does my CI build fail when dependencies update locally?
The lockfile is out of sync with a package.json change. Commit the updated lockfile and enforce --frozen-lockfile in CI so the resolver refuses to mutate it and instead fails loudly on the pull request that introduced the drift.
Can I safely override a transitive dependency version across all workspace packages?
Yes, via pnpm.overrides, yarn.resolutions, or npm.overrides. Verify compatibility with pnpm why <package> first, and reserve overrides for security patches and critical bug fixes — not arbitrary upgrades, which can silently violate a peer range.
Should internal dependencies use workspace:* or workspace:^?
workspace:* pins to the local package's current version — simplest within a repo that always builds together. workspace:^ publishes a caret range so external consumers can deduplicate. Choose based on how the packages are consumed after publish.
How do I break a circular dependency between two packages?
Extract the shared code both need into a third, lower-layer package both depend on, turning A → B → A into A → shared and B → shared, which is acyclic. Enforce acyclicity with a circular-dependency check in CI so a cycle cannot be merged.
What keeps internal dependencies healthy in a monorepo?
The workspace: protocol for internal references, an accurate dependency graph so build order and affected detection are correct, enforced module boundaries to prevent cycles, and root overrides for stubborn transitive duplicates. Together they keep the internal graph acyclic and every change's blast radius contained.
How do I prevent circular dependencies between packages?
Enforce module boundaries — tag packages by layer and add a lint rule that forbids illegal edges, so an import that would close a loop fails on the PR. Add a madge --circular check in CI as a backstop, so cycles are caught at introduction rather than as an intermittent build failure.
Related
- Debugging Circular Dependencies in Monorepos — detect and refactor import cycles before they crash production.
- Workspace Symlinks vs Hard Links — the physical layer beneath the
workspace:protocol. - Workspace Configuration Deep Dive — how to lay out
pnpm-workspace.yamland package globs. - Lockfile Management Strategies — keep the single source of truth for the graph clean.
- pnpm Workspace Filtering — scope installs and tasks to the packages that actually changed.