Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Compare the Nx approach

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.

Internal dependency graph with the workspace protocol Feature packages depend downward on a shared utils package and a zero-dependency types leaf, all linked by the workspace protocol into a symlinked node_modules. @repo/web app package @repo/ui workspace:* @repo/api workspace:* @repo/utils stateless deps @repo/types zero deps (leaf)
Every edge points downward: app packages depend on shared libraries via workspace:*, and the types leaf depends on nothing — a strict DAG with no cycles.

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

Monorepo Architecture & Orchestration