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:

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.

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.

  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

Validation and debug commands

Confirm the cycle is gone before you push:

# 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:

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

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.

Related

Cross-Package Dependency Management