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

Debugging Circular Dependencies in Monorepos

A circular dependency is two or more packages that import each other directly or transitively, violating the Directed Acyclic Graph (DAG) that every workspace manager and bundler assumes. The result is a module that evaluates before its exports are defined — yielding undefined values, stack overflows, or a build that simply hangs. This guide shows how to reproduce the exact error, locate the cycle, and refactor it out permanently.

Exact symptoms and error strings

Match your pipeline or runtime logs against these signatures to confirm you're dealing with a cycle and not a missing dependency:

Exact symptoms and error strings Match your pipeline or runtime logs against these signatures to confirm you're dealing with a cycle and not a missing de Exact symptoms and error strings Match your pipeline or runtime logs against these signatures to confirm you're dealing with a cycle and not a missing dependency:
Exact symptoms and error strings — the core idea of this section at a glance.
Error: Cannot find module '@repo/package-b'
RangeError: Maximum call stack size exceeded
Circular dependency detected: package-a -> package-b -> package-a
Cycle detected in dependency graph

The most deceptive symptom is silent: an imported value is undefined at module top level even though the source clearly exports it. That is a cycle resolving with a partially initialized module, not a typo.

TypeError: Cannot read properties of undefined (reading 'createClient')

Root cause

Workspace managers (pnpm, npm, Yarn) resolve local packages via symlinks in node_modules, so an import of @repo/package-b from @repo/package-a is a real module edge in the graph. When package-b imports back from package-a, the runtime must evaluate one before the other can finish — but neither can finish first.

CommonJS fails hard: require returns the partially populated module.exports of the in-progress module, so a destructured import is undefined and a function call on it throws Maximum call stack size exceeded. ESM is more forgiving thanks to live bindings — the binding is hoisted and filled in later — but any value accessed synchronously during initialization is still undefined. Neither format tolerates cycles reliably in production.

This is fundamentally a graph-shape problem, which is why the durable fix lives at the architecture level described in Cross-Package Dependency Management: enforce a strict DAG so a cycle can never form, rather than patching each one as it surfaces.

To see the failure concretely, picture two packages that each reach for the other at module top level:

// packages/a/src/index.ts
import { formatB } from '@repo/b';
export const labelA = 'A:' + formatB();

// packages/b/src/index.ts
import { labelA } from '@repo/a';        // <- closes the cycle
export const formatB = () => labelA.toUpperCase();

When Node evaluates @repo/a, it pauses to load @repo/b, which immediately reads labelA — but labelA has not been assigned yet, so under CommonJS formatB calls .toUpperCase() on undefined and throws, while under ESM labelA is a live binding that reads undefined at that instant. The values are correct only if nothing is accessed during initialization, which is too fragile to ship.

Breaking a two-package cycle A bidirectional edge between package A and package B is replaced by both depending downward on a shared zero-dependency types leaf. cycle (fails) @repo/a @repo/b DAG (resolves) @repo/a @repo/b @repo/types zero deps
Replace the bidirectional A↔B edge with both packages depending downward on a shared zero-dependency leaf — the cycle disappears.

A circular dependency is when two or more packages depend on each other, directly or through a chain, so the dependency graph is no longer acyclic. This is corrosive in a monorepo because build order becomes undefined — if package A depends on B and B depends on A, neither can build first — and it defeats affected detection and boundary reasoning, which assume a directed acyclic graph. Cycles also cause subtle runtime bugs when a module is used before the cycle has finished initializing it.

Cycles are easy to introduce and hard to spot by reading code, because the loop can span several packages through a chain of imports nobody traces end to end. They often appear gradually — a convenient import here, a shared helper there — until the graph closes a loop. The failure they cause is frequently intermittent, showing up only under a particular build order or module-load sequence, which is why detecting them structurally rather than by symptom is what makes them tractable.

Resolution and config patch

Work through these steps in order. Detection comes first, because runtime errors usually point at the symptom, not the origin of the cycle.

Resolution and config patch Work through these steps in order. Resolution and config patch Work through these steps in order.
Resolution and config patch — the core idea of this section at a glance.
  1. Detect and map the cycle. Run static graph analysis to print the exact import chain:

    # Universal static analysis across source files
    npx madge --circular src/
    
    # Nx: open the interactive project graph
    npx nx graph
    
    # Turborepo: print the task dependency graph
    turbo run build --graph
  2. Identify the shared surface. From the chain (a -> b -> a), find which package logically owns the shared types or stateless helpers. That is what moves out.

  3. Extract a leaf package. Move the shared interfaces, types, and stateless utilities into a dedicated leaf — @repo/types or @repo/shared-core — that has zero internal dependencies. Refactor both original packages to import only from the leaf, and delete the direct cross-imports between them.

  4. Fallback only if extraction is blocked. Where an architectural fix is temporarily impossible, defer the synchronous edge with a dynamic import so the cycle resolves at call time instead of init time:

    // Synchronous — throws or returns undefined on a cycle
    import { helper } from '@repo/package-b';
    
    // Deferred — the binding resolves when helper() is actually called
    const { helper } = await import('@repo/package-b');
  5. Rebuild the resolution graph. Clear stale symlinks and reinstall so the manager rematerializes a clean tree:

    rm -rf node_modules
    pnpm install --frozen-lockfile

Detect the cycle, then break it by extracting the shared code:

# Find circular dependencies across the workspace
npx madge --circular --extensions ts,tsx packages/
# Or use the task runner's own graph validation
pnpm turbo run build --graph

Break the cycle by moving the shared code both packages need into a third, lower-layer package that both depend on, converting A → B → A into A → shared and B → shared. Occasionally the right fix is to invert a dependency that points the wrong way, or to merge two packages that were never really separable.

Validation and debug commands

Confirm the cycle is gone before you push:

Validation and debug commands Confirm the cycle is gone before you push: Validation and debug commands Confirm the cycle is gone before you push:
Validation and debug commands — the core idea of this section at a glance.
# Should print "No circular dependency found!"
npx madge --circular --extensions ts,tsx src/

# Re-trace any package that was involved in the cycle
pnpm why @repo/package-b

# Verify the symlink targets resolve to local source
ls -la node_modules/@repo/

If madge still reports a cycle after the refactor, a bundler misconfiguration may be cloning a package into two module instances that look mutually dependent. Check that external versus noExternal (Vite) or externals (Webpack) treat every @repo/* package consistently.

Prevention and CI guardrails

  • Add an import/no-cycle ESLint rule so a cycle fails lint locally before it reaches CI:
Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows. Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows.
Prevention and CI guardrails — the core idea of this section at a glance.
{
  "plugins": ["import"],
  "rules": {
    "import/no-cycle": ["error", { "maxDepth": 1 }]
  }
}
  • Add a dedicated pre-merge check that fails fast on any graph violation:

    {
      "scripts": {
        "check:cycles": "madge --circular --extensions ts,tsx src/ || exit 1"
      }
    }
  • Keep tsconfig.json compilerOptions.paths aligned exactly with workspace aliases, and enable strict: true to surface the implicit any values that often mask a broken export from a cycle.

  • Enforce a strict downward-only dependency model — types and constants in a leaf, stateless helpers above that, feature packages on top — as described in Monorepo Architecture & Orchestration. Never allow sibling or upward edges.

  • Run a circular-dependency check (madge, or the task runner's graph validation) in CI.

  • Break cycles by extracting shared code into a lower-layer package both sides depend on.

  • Enforce module boundaries with tags/lint rules so illegal edges fail before they close a loop.

  • Keep the dependency graph acyclic — every downstream capability depends on it.

Breaking a cycle by extracting shared code

The fix for a circular dependency is almost always structural: extract the code both packages need into a third, lower-layer package that both depend on. If A imports from B and B imports from A, the shared pieces causing the mutual dependency move into a new shared package, converting the cycle A → B → A into two acyclic edges A → shared and B → shared. The graph is directed and acyclic again, so build order is well-defined and affected detection works.

Breaking a cycle An A-B-A cycle resolved by extracting shared code. Cycle • A imports B • B imports A • build order undefined Acyclic • A imports shared • B imports shared • clean topological order
Extracting shared code into a lower layer turns a cycle into an acyclic graph.

Occasionally a different fix applies. If the cycle exists because an import points the wrong way — a lower-layer package reaching up into a higher-layer one — inverting that dependency removes the loop without a new package. If two packages are so intertwined that every attempt to separate them recreates the cycle, they may genuinely be one package that was split prematurely, and merging them is the honest answer. Whichever applies, the target is a directed acyclic graph, because that structure is what ordered builds, affected detection, and boundary enforcement all depend on. Treating a cycle as a design signal — usually that shared code wants its own home — rather than a nuisance to work around is what keeps the architecture clean as the monorepo grows.

Preventing cycles with boundary enforcement

Detecting cycles after they appear is useful, but preventing them from being introduced is better, and that is what module-boundary enforcement provides. By tagging each package by layer — a scope and a type — and configuring a lint rule that forbids illegal edges, an import that would create a cycle or violate the intended layering fails lint on the pull request that introduced it. A UI package importing a feature package, or a lower-layer package reaching up, is caught at the point of introduction rather than discovered later as an intermittent build failure.

Boundary enforcement Tag layers, forbid illegal edges, backstop with a check. tag by layer scope + type lint illegal edges fail on the PR madge in CI backstop
Enforced boundaries keep the graph acyclic by construction.

This turns architectural intent into something the tooling guarantees. Instead of relying on reviewers to notice that an import closes a loop — which is hard to see across packages — the boundary rule enforces the acyclic structure mechanically. Running a circular-dependency check in CI as a backstop catches anything the boundary rules miss, so the two together keep the graph acyclic by construction. The payoff compounds: an acyclic graph keeps build order well-defined, affected detection accurate, and refactors local, so the effort spent enforcing boundaries pays back as the monorepo grows and the number of possible import paths — and therefore possible cycles — multiplies.

Frequently Asked Questions

How do I detect circular dependencies in a pnpm or npm workspace? Run madge --circular src/ for a universal static scan, or use the workspace-native nx graph and turbo run build --graph. These parse import statements into a directed graph and highlight cycles before runtime, so you catch the origin rather than the downstream symptom.

Does ESM handle circular dependencies better than CommonJS? Somewhat. ESM uses live bindings, so a partially initialized module can be referenced without an immediate crash — but a value accessed synchronously during initialization is still undefined. CommonJS fails immediately with Maximum call stack size exceeded or missing exports. Neither should be relied on to tolerate cycles in production.

Can TypeScript catch circular imports at compile time? No. TypeScript compiles files independently and does not enforce import-graph topology. Use eslint-plugin-import/no-cycle or a bundler plugin to enforce a cycle-free architecture during development and in CI.

What folder structure prevents future cycles? A strict hierarchy: a @repo/types leaf for shared types and constants with zero dependencies, utility packages for stateless helpers above it, and feature packages that depend only downward. Enforce the direction with the import/no-cycle lint rule so a regression fails the build.

How do I find circular dependencies in a monorepo?

Run a tool like madge --circular over your packages, or use the task runner's graph validation. Both surface the loop as a named chain of packages, turning an intermittent build failure into a concrete, locatable problem.

How do I break a circular dependency?

Extract the shared code both packages need into a third, lower-layer package both depend on, converting A → B → A into A → shared and B → shared. Occasionally the fix is inverting a wrong-way dependency or merging two packages that were split prematurely.

How do I stop cycles from being introduced?

Enforce module boundaries: tag packages by layer and add a lint rule that forbids illegal edges, so an import that would create a cycle fails on the PR that introduced it. Add a madge --circular CI check as a backstop.

Are all circular dependencies harmful?

Not always at runtime — some cycles resolve because modules are fully initialized before use — but they always make build order undefined and defeat affected detection, and they cause intermittent init-order bugs. Treat any cycle as a design problem to break, not a nuisance to tolerate.

Can a task runner detect cycles for me?

Yes — running the task runner's graph command (or a madge --circular check) surfaces a cycle as a named chain, and adding it to CI fails the build when a new import closes a loop, so cycles are caught at introduction rather than as an intermittent failure later.

Related

Cross-Package Dependency Management