Implera is currently offline. The blog stays up.
Back to insights

Insights

Understanding Circular Dependencies and How to Fix Them

Consider three files.

// a.ts
import { fromB } from "./b";
export const fromA = () => fromB() + 1;

// b.ts
import { fromC } from "./c";
export const fromB = () => fromC() + 1;

// c.ts
import { fromA } from "./a";
export const fromC = () => fromA() + 1;

Nothing looks wrong at a glance. Each file imports what it needs. Each exports what it declares. But run a.ts and something strange happens. Depending on the runtime, you get NaN, a TypeError, or a silent return of undefined that does not match what the function was supposed to do.

The problem is the shape. A imports B, B imports C, C imports A. The cycle means that when any one file loads, the others are still initialising, and exports are not yet defined.

This is a circular dependency. It looks small in three files. It is a quiet disaster at scale.

Why cycles break things

Initialisation order becomes undefined. In languages with module-level code (JavaScript, Python), the runtime loads modules in some order. When a cycle exists, the order matters and is not always obvious. Depending on resolution strategy, some exports may not exist at the time the code expects them.

Testing breaks. To unit test a.ts, you want to mock b.ts. But b.ts imports c.ts, which imports a.ts. Mocking one side of the cycle without touching the others is often impossible. Tests get tangled.

Refactoring becomes dangerous. Any change to one file in the cycle potentially affects all of them. There is no "local" change within a cycle.

Tree-shaking fails. Bundlers need an acyclic import graph to safely remove unused code. A cycle forces all members to be included together.

Dead code hides. If A, B and C reference each other, a module that imports A brings the whole cycle, even if only one function is actually called.

How cycles sneak in

Cycles rarely arrive as a deliberate choice. They arrive through growth.

Shared utilities that accrue upward responsibilities. A utils/auth.ts starts as a helper, gains a dependency on a user service, and the user service imports utilities from utils/auth.ts. Cycle created.

Event systems where both emitter and listener import each other for typing. Types are easy to circularise by accident.

Type-only cycles that the bundler allows but the linter still flags. Not always runtime-dangerous, but they indicate the modules are not cleanly separated.

Refactors that move a function into a module that already depended on its old home.

Each cycle is born small. Most codebases accumulate five to ten of them within the first year without anyone noticing.

How to detect them

Every major language has a tool.

  • JavaScript/TypeScript: madge, dpdm, dependency-cruiser. All fast. All output a list or a diagram.
  • Python: pycycle, pylint with the cyclic-import check, pydeps.
  • Go: the compiler refuses to build a program with import cycles, so you cannot have them. A rare example of language-enforced discipline.
  • Java/Kotlin: jdeps, IntelliJ's built-in analysis, spotbugs.
  • Rust: disallowed by the compiler for modules. Circular crate dependencies are possible but rare.

Run the tool. Add it to CI. Fail the build when a new cycle appears.

How to fix them

Four patterns, listed by preference.

Extract the shared contract. If A and B both depend on each other because they share types or constants, move those to a third module C. Now A to C and B to C. Cycle broken.

Dependency injection. If A depends on B because A needs B's behaviour, pass the behaviour in rather than importing it. A is now loosely coupled. B can reference A without creating a cycle because B does not need A's code, only a callable.

Split responsibilities. If A and B import each other because they are doing related work, merge them or split them differently. The fact that they need each other bidirectionally is evidence that the boundary is wrong.

Type-only imports in TypeScript. import type does not create a runtime cycle. If the cycle is purely for typing, switching to import type resolves it.

Each fix is cheap on its own. The hard part is finding the cycles and agreeing to fix them before they accumulate.

The compounding cost

One cycle is an annoyance. Five cycles are a structural problem. Twenty cycles mean the codebase cannot be refactored without a dedicated effort.

Every cycle makes every other cycle harder to remove. They interact. Fixing one may require fixing another first. At twenty cycles the team usually concludes "we cannot fix this" and stops trying.

The fix is to never get to twenty. Every new cycle fails CI. Existing cycles get tracked and paid down one at a time. A codebase that hits zero cycles and stays there is considerably easier to work with than one that has "only a few".

The bottom line

Circular dependencies are not a dramatic failure. They are a gradual tax. Each one makes the codebase slightly harder to understand, slightly harder to test and slightly harder to change.

Detecting them is trivial. Fixing them is easier than it looks. The only thing stopping most teams is that the cost of one more cycle never feels urgent enough to act on.

It adds up. Act early.

FAQ

Common questions

© 2026 Implera