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
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.
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.
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
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.
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 |
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.
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.