DEV Community

Wojciech Kot
Wojciech Kot

Posted on

Stop circular dependencies before they stop you — dependency-cruiser & the Stable Dependencies Principle

Circular dependencies are one of those bugs that feel harmless right up until they aren't. No build error. No runtime exception on import. Just a subtle undefined that shows up weeks later in production, in a stack trace that points to the symptom and not the cause.
This article shows how to catch them automatically using dependency-cruiser, and how to go one step further by encoding the Stable Dependencies Principle (SDP) directly into your project rules.

What is a circular dependency?

A circular dependency happens when module A imports from B, and B — directly or through a chain — imports back from A.

A → B → A          (direct cycle)
A → B → C → A      (indirect cycle)
Enter fullscreen mode Exit fullscreen mode

In practice it often looks harmless:

// user-context.ts
import { getAccessStatus } from "./access-status"

// access-status.ts
import { UserContext } from "./user-context"  // cycle
Enter fullscreen mode Exit fullscreen mode

Why JavaScript makes this dangerous

JavaScript resolves circular imports silently. No build error, no runtime exception — just wrong behaviour that is hard to trace.
When module A and B are in a cycle, whichever loads first will often see the other's exports as undefined (or trigger a ReferenceError in strict ESM environments) at the exact moment it evaluates. Here is exactly what happens:

// Step 1 — user-context.ts starts loading, hits the import of access-status.ts

// Step 2 — access-status.ts starts loading, hits the import of user-context.ts
//           user-context.ts is already loading but not finished
//           JavaScript returns what has been exported so far → nothing → UserContext is undefined

// Step 3 — access-status.ts finishes loading with undefined as UserContext
export const defaultPermissions = UserContext.getPermissions();

// Step 4 — user-context.ts finishes, but access-status.ts already captured undefined
Enter fullscreen mode Exit fullscreen mode

The bug shows up later when getAccessStatus is called, fails silently, and the stack trace points at the call site — not the import structure that caused it.

Detecting cycles with dependency-cruiser

Instead of guessing where these cycles hide, you can use dependency-cruiser. It scans your source files, maps out your entire dependency graph, and validates it against rules you define. It works right out of the box with TypeScript, path aliases, and monorepos like Yarn workspaces.

Getting it up and running takes few minutes. First, add it into your dev dependencies

yarn add -D dependency-cruiser
Enter fullscreen mode Exit fullscreen mode

Next, add a shortcut script to your package.json pointing to your configuration file:

"depcruise": "depcruise packages --config .dependency-cruiser.js",
Enter fullscreen mode Exit fullscreen mode

Now, create that .dependency-cruiser.js file at the root of your repository. We will start with a basic configuration designed purely to catch circular dependencies:

/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
  forbidden: [
    {
      name: "no-circular",
      severity: "error",
      from: {},
      to: { circular: true },
    },
  ],
  options: {
    tsConfig: { fileName: "tsconfig.json" },
    doNotFollow: { path: "node_modules" },
    exclude: { path: ["node_modules", "dist", "\\.spec\\.tsx?$"] },
    moduleSystems: ["es6", "cjs"],
  },
}
Enter fullscreen mode Exit fullscreen mode

With the rules in place, you can kick off the check:

yarn depcruise
Enter fullscreen mode Exit fullscreen mode

If your codebase is clean, the command runs silently and exits with code 0. However, if a cycle exists in your project, you'll see a violation that looks like this:

error no-circular: packages/client/src/hooks/use-user.ts 
      packages/shared/src/api/index.ts 
      packages/shared/src/api/access-status.ts 
      packages/client/src/context/index.ts 
      packages/client/src/hooks/use-user.ts
Enter fullscreen mode Exit fullscreen mode

Think of each → as a single import statement. The output traces the entire chain of dependency until the final line loops straight back to the first. That loop is exactly what you need to break.

Detecting cycles is reactive. The SDP is proactive.

Finding a cycle and breaking it is the right fix for the immediate problem. But it does not address why the cycle formed. And if you do not address that, it will form again somewhere else.
Cycles are a symptom. The root cause is a dependency arrow pointing the wrong way - a stable module that has been made to depend on an unstable one. The cycle is just the inevitable byproduct of that structural mistake.

The Stable Dependencies Principle (SDP) is a design rule from Robert C. Martin's Clean Architecture that formalises this:

Depend in the direction of stability.

Stability here is a structural property, not a code quality score. A module is stable when many other modules depend on it — any change would ripple across all those consumers, and that cost acts as a natural brake. A module that nothing depends on is free to change at any time — structurally unstable, even if the code inside it is excellent.
Martin defines instability (I) as a ratio:

I = Fan-Out / (Fan-In + Fan-Out)

Fan-In  = modules that import this one
Fan-Out = modules this one imports from

I = 0  →  maximally stable    (everything depends on it, it depends on nothing)
I = 1  →  maximally unstable  (it depends on many things, nothing depends on it)
Enter fullscreen mode Exit fullscreen mode

The rule stated in terms of I: a module's instability score must be higher than the score of every module it imports from. The dependency arrow must always point toward lower I — toward something that changes less often than you do.

By engineering your project around the SDP, you ensure that your most heavily relied-upon code never points toward volatile, fast-changing modules. You stop treating dependency design as a matter of gut feeling and turn it into a structural rule. When arrows are strictly forced to point toward stability, you don't just find cycles faster—you make hard for them to form in the first place.

What a healthy dependency graph looks like

The SDP gives you one rule for every arrow in your graph: it must point toward lower I — toward something more stable than the module drawing the arrow.

In practice that means your most reused, most depended-upon code sits at the bottom of the graph, and your most frequently changing code sits at the top. Nothing at the bottom imports from anything at the top.

✅  Correct — arrows always point toward stability
  ┌──────────────────────────────────────┐
  │  High-level modules  (I ≈ 1.0)       │  change often
  │  pages, app entry, top-level config  │  nothing else imports from them
  └──────────────────┬───────────────────┘
                     │
                     ▼
  ┌──────────────────────────────────────┐
  │  Mid-level modules  (I ≈ 0.5)        │  change occasionally
  │  domain logic, feature modules       │  some things depend on them
  └──────────────────┬───────────────────┘
                     │
                     ▼
  ┌──────────────────────────────────────┐
  │  Low-level modules  (I ≈ 0.0)        │  rarely change
  │  utils, types, constants, base hooks │  everything depends on them
  └──────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
❌  Wrong — an arrow points upward (toward instability)

  ┌──────────────────────────────────────┐
  │  High-level modules  (I ≈ 1.0)       │
  └──────────────────────────────────────┘
            ▲
            │  ← this import is the SDP violation
  ┌──────────────────────────────────────┐
  │  Low-level modules  (I ≈ 0.0)        │
  └──────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The upward arrow does not have to produce a cycle immediately. But it creates the structural condition in which a cycle will eventually form — because the high-level module will evolve, import more things, and at some point one of those imports will close the loop.

Encoding SDP as dependency-cruiser rules

This is where dependency-cruiser becomes more than a cycle detector — it becomes an architectural rule engine. You declare which import directions are forbidden, and it catches a violation the moment the import is written, before a cycle has a chance to form.

forbidden: [
  {
    name: "no-circular",
    severity: "error",
    from: {},
    to: { circular: true },
  },

  // low-level modules must never import from mid- or high-level ones
  {
    name: "no-utils-to-features",
    severity: "error",
    comment: "Stable utils must not depend on less stable feature modules.",
    from: { path: "^src/utils" },
    to:   { path: "^src/(features|pages)" },
  },

  // mid-level modules must never import from high-level ones
  {
    name: "no-features-to-pages",
    severity: "error",
    comment: "Feature modules must not depend on pages.",
    from: { path: "^src/features" },
    to:   { path: "^src/pages" },
  },
],
Enter fullscreen mode Exit fullscreen mode

no-utils-to-features is not an arbitrary naming convention. It is the SDP stated as a machine-checkable rule: a module with I≈0 must not import from modules with I≈0.5 or higher. A developer who adds that import sees a CI failure immediately, on that line, with that rule name. The cycle does not need to exist yet.
This is the distinction that matters: cycle detection tells you when a forbidden state has already been created. A direction rule catches the violation before that state can form.

Conclusion

Circular dependencies aren't a tooling problem; they are a structural failure. A cycle is simply what happens when dependency arrows point the wrong way for long enough.

dependency-cruiser gives you two layers of defense: a cycle detector that catches existing fires, and an architectural rule engine that prevents them entirely. While the first is a useful safety net, the second is what actually keeps a codebase clean as it scales.

The Stable Dependencies Principle provides the "why" behind these rules. Forcing stable code to depend on volatile code isn't just a bad convention. That hidden coupling is exactly what eventually surfaces as a silent undefined in production.

The configuration file is tiny, and the principle is simple. Together, they transform dependency management from a reactive cleanup task into an automated guardrail that holds the architectural line on every single PR.

Top comments (0)