Circular dependencies are one of the most insidious problems in software architecture. They creep in quietly, hide behind layers of abstraction, and reveal themselves only when your build breaks at 5 PM on a Friday. By then, untangling them feels like defusing a bomb with no manual.
This guide explains what circular dependencies are, why they matter far more than most developers realize, and how to detect them before they become a structural problem in your codebase.
What Is a Circular Dependency?
A circular dependency occurs when two or more modules depend on each other, directly or indirectly, forming a loop in the dependency graph.
The simplest case is a direct cycle between two files:
// userService.ts
import { sendWelcomeEmail } from './emailService';
export function createUser(name: string, email: string) {
const user = { id: generateId(), name, email };
sendWelcomeEmail(user);
return user;
}
export function getUserDisplayName(userId: string): string {
// ...lookup logic
return 'John Doe';
}
// emailService.ts
import { getUserDisplayName } from './userService';
export function sendWelcomeEmail(user: { id: string; email: string }) {
const displayName = getUserDisplayName(user.id);
// send email with displayName...
}
userService imports from emailService, and emailService imports from userService. This is a direct circular dependency. It might work — Node.js and bundlers have mechanisms to handle some cycles — but "it works" is not the same as "it's correct."
Indirect Cycles Are Harder to Spot
The more dangerous pattern involves three or more modules:
A → B → C → A
Module A imports B, B imports C, and C imports A. No single file looks wrong. The cycle only becomes visible when you trace the full import chain. In a codebase with hundreds of files, these indirect cycles can span five, six, or more modules deep.
Why Circular Dependencies Are Dangerous
1. Runtime Errors and Undefined Imports
In Node.js (CommonJS), when a circular dependency is detected, the runtime returns a partially loaded module. This means the imported value might be undefined at the time it's used:
// auth.ts
import { db } from './database';
export const AUTH_SECRET = 'my-secret';
export function authenticate(token: string) {
return db.query('SELECT * FROM sessions WHERE token = ?', [token]);
}
// database.ts
import { AUTH_SECRET } from './auth';
console.log(AUTH_SECRET); // undefined! auth.ts hasn't finished loading yet
export const db = {
query: (sql: string, params: string[]) => {
// uses AUTH_SECRET for connection...
}
};
When database.ts runs, auth.ts hasn't finished executing yet. AUTH_SECRET is undefined. No error is thrown — you just get a silent, confusing bug that manifests as "authentication suddenly stopped working."
With ES modules, the behavior is different (imports are live bindings), but the confusion and potential for bugs remains.
2. Build and Bundling Failures
Webpack, Vite, Rollup, and other bundlers handle circular dependencies with varying degrees of grace. Some emit warnings. Some silently produce broken bundles. Some crash outright during tree-shaking because they can't determine what's actually used.
A common symptom: your app works in development (where modules are loaded lazily) but crashes in production (where the bundler tries to resolve the full dependency tree statically).
3. Testing Becomes a Nightmare
If module A depends on B and B depends on A, you can't test either in isolation. Mocking one requires loading the other, which tries to load the first. Your test setup becomes a house of cards:
// Trying to test userService...
jest.mock('./emailService'); // emailService imports userService
// Which triggers loading userService again
// Which tries to load the real emailService
// Jest enters a recursive mock resolution loop
This is why teams with circular dependencies often have poor test coverage — it's not laziness, it's that the architecture makes testing genuinely difficult.
4. Refactoring Paralysis
Circular dependencies create a "you can't move one thing without moving everything" dynamic. Want to extract userService into a separate package? You can't, because it's entangled with emailService. Want to split your monolith into microservices? Every circular dependency is a boundary you can't cleanly cut.
5. Memory Leaks in Specific Contexts
In some environments — particularly older Node.js patterns and certain module systems — circular dependencies can prevent garbage collection. Module A holds a reference to B, which holds a reference to A. Neither can be freed. In long-running server processes, this leads to slow memory growth that's extremely hard to diagnose.
How to Detect Circular Dependencies
Manual Detection: Following the Import Chain
The brute-force approach: open a file, look at its imports, open each imported file, look at their imports, and repeat until you find a loop or exhaust the chain. This works for small codebases (under 20 files). Beyond that, it's impractical.
The human brain is not optimized for graph traversal. You will miss indirect cycles, guaranteed.
Using ESLint
The eslint-plugin-import package includes a rule specifically for circular dependencies:
npm install --save-dev eslint-plugin-import
{
"plugins": ["import"],
"rules": {
"import/no-cycle": ["error", { "maxDepth": 5 }]
}
}
This catches cycles up to the specified depth. It's a good first line of defense, but it has limitations:
- Performance: On large codebases (1000+ files),
import/no-cyclecan add minutes to your lint time. ThemaxDepthparameter exists specifically to limit the computational cost. - Dynamic imports: It doesn't catch
import()expressions orrequire()calls inside functions. - Cross-package: In monorepos, it typically only analyzes within a single package.
Using Madge
Madge is a dedicated tool for dependency analysis:
npx madge --circular --extensions ts src/
Output:
Circular dependencies found!
1) src/services/userService.ts → src/services/emailService.ts → src/services/userService.ts
2) src/modules/auth/index.ts → src/modules/database/index.ts → src/modules/auth/index.ts
3) src/utils/logger.ts → src/config/index.ts → src/utils/env.ts → src/utils/logger.ts
Madge can also generate visual dependency graphs:
npx madge --image graph.svg src/
This produces an SVG of your entire dependency tree, with circular dependencies highlighted. It's incredibly useful for understanding the structure of a codebase you've inherited.
Using dpdm
dpdm is another CLI tool focused on dependency analysis for TypeScript projects:
npx dpdm --circular --exit-code circular:1 src/index.ts
It's faster than Madge on large TypeScript projects because it uses the TypeScript compiler's own module resolution.
Using Dependency Graphs in CI
The most robust approach is to check for circular dependencies in your CI pipeline:
# .github/workflows/ci.yml
jobs:
check-architecture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Check for circular dependencies
run: npx madge --circular --extensions ts src/
# This command exits with code 1 if cycles are found
This prevents new circular dependencies from being introduced. But it doesn't help you fix the ones you already have, and it doesn't show you the full architectural picture.
Visualizing the Full Dependency Graph
CLI tools tell you that a cycle exists. But understanding why it exists — and how to fix it without breaking everything — requires seeing the bigger picture. When module A depends on B depends on C depends on A, the fix often involves moving a shared concern into a new module D that all three can depend on.
This is where visualization tools become essential. Seeing your dependency graph as an actual graph — nodes and edges, clusters and outliers — reveals structural problems that are invisible in terminal output.
Tools like ReposLens generate interactive dependency maps directly from your GitHub repository. You connect your repo, and within a minute you can see every module, every import relationship, and every cycle — color-coded and explorable. Instead of running CLI commands and parsing text output, you navigate a visual map where circular dependencies are immediately obvious.
How to Fix Circular Dependencies
Once you've identified cycles, here are the most effective resolution patterns:
1. Extract Shared Logic Into a New Module
The most common fix. If userService and emailService both need getUserDisplayName, create a userHelpers module that both can import from:
Before: userService ↔ emailService
After: userService → userHelpers ← emailService
2. Use Dependency Injection
Instead of importing directly, pass the dependency as a parameter:
// emailService.ts — no longer imports from userService
export function sendWelcomeEmail(
user: { id: string; email: string },
getDisplayName: (userId: string) => string
) {
const displayName = getDisplayName(user.id);
// send email...
}
// userService.ts
import { sendWelcomeEmail } from './emailService';
export function createUser(name: string, email: string) {
const user = { id: generateId(), name, email };
sendWelcomeEmail(user, getUserDisplayName); // inject the function
return user;
}
The cycle is broken. emailService no longer knows about userService.
3. Use Events or a Message Bus
For complex cases where many modules need to communicate without direct dependencies:
// eventBus.ts
type EventHandler = (data: unknown) => void;
const handlers = new Map<string, EventHandler[]>();
export function emit(event: string, data: unknown) {
handlers.get(event)?.forEach(handler => handler(data));
}
export function on(event: string, handler: EventHandler) {
if (!handlers.has(event)) handlers.set(event, []);
handlers.get(event)!.push(handler);
}
// userService.ts
import { emit } from './eventBus';
export function createUser(name: string, email: string) {
const user = { id: generateId(), name, email };
emit('user:created', user); // no direct dependency on emailService
return user;
}
// emailService.ts
import { on } from './eventBus';
on('user:created', (user) => {
// send welcome email...
});
Neither service knows about the other. They communicate through events.
4. Apply the Interface Segregation Principle
If two modules are tightly coupled because they share too much surface area, define a narrow interface that captures only what's needed:
// types/userLookup.ts
export interface UserLookup {
getDisplayName(userId: string): string;
}
Now emailService depends on the interface, not the concrete userService. This is especially powerful in TypeScript where interfaces have zero runtime cost.
Prevention: Architecture Rules That Stick
Fixing existing cycles is important. Preventing new ones is more important.
Define Layer Rules
Establish clear boundaries: services/ can import from repositories/ and utils/, but never from controllers/. controllers/ can import from services/, but never from other controllers/. Write these rules down. Enforce them with tooling.
Enforce Module Boundaries
In a monorepo, use tools like @nx/enforce-module-boundaries or create custom ESLint rules that prevent imports from crossing package boundaries.
Add Architectural Checks to PR Reviews
Make dependency analysis part of your pull request review process. Some teams add automated comments to PRs that show the dependency impact of the change — new imports added, modules newly connected, cycles created.
ReposLens provides this through its PR analysis feature: every pull request gets an automatic architecture check that flags new circular dependencies, increased coupling, or boundary violations before the code is merged.
Monitor Over Time
Architecture degrades gradually. A single new import doesn't look dangerous. But after fifty PRs, each adding "just one more import," your clean architecture has become a tangled graph. Track your dependency metrics over time — number of cycles, average coupling, dependency depth — the same way you track test coverage.
Conclusion
Circular dependencies are a structural problem, not a syntax problem. Linters won't catch them all. Code reviews won't catch them all. You need dedicated analysis — automated, visual, and continuous.
Start by running npx madge --circular src/ on your codebase today. You might be surprised by what you find. Then put a system in place — whether it's a CI check, a visualization tool, or both — to make sure the number goes down, not up.
Your future self, debugging a production issue at midnight, will thank you for it.