Loading learning content...
Your application worked flawlessly in development. Then you deploy to production and encounter a cryptic error: Maximum call stack size exceeded or Cannot read property of undefined during startup. Hours of debugging reveal the cause: ServiceA needs ServiceB to be constructed, but ServiceB needs ServiceA to be constructed. Neither can exist without the other. The IoC container enters an infinite loop trying to resolve this paradox.
This is a circular dependency—a cycle in your dependency graph where A depends on B depends on C depends on A. Unlike constructor over-injection, which is immediately visible in the code, circular dependencies can hide across multiple classes and packages, emerging only at runtime as stack overflows or mysterious null references.
Circular dependencies violate the fundamental principle that dependencies should form a Directed Acyclic Graph (DAG). When cycles exist, there's no valid ordering for construction. IoC containers may fail loudly, silently corrupt state, or enter infinite loops—depending on implementation.
By the end of this page, you will understand how circular dependencies arise, detect them before they cause production failures, apply systematic strategies to break cycles, and recognize the design flaws that circular dependencies reveal.
A circular dependency exists when class A depends on class B, and class B (directly or indirectly) depends on class A. The cycle can involve any number of classes:
When using constructor injection exclusively, circular dependencies make instantiation impossible. To create A, you need B. To create B, you need A. Neither can be created first.
1234567891011121314151617181920212223242526272829303132333435
// 🚨 ANTI-PATTERN: Direct Circular Dependency// Neither class can be instantiated - each requires the other class AuthenticationService { constructor( private readonly userService: UserService ) {} authenticate(token: string): AuthenticatedUser { const userId = this.decodeToken(token); return this.userService.getUser(userId); // Needs UserService } private decodeToken(token: string): string { /* ... */ }} class UserService { constructor( private readonly authService: AuthenticationService ) {} getUser(userId: string): User { // ... lookup user } sendPasswordResetEmail(userId: string): void { const user = this.getUser(userId); const resetToken = this.authService./* ??? */; // Needs AuthenticationService // ... send email with token }} // ❌ Impossible to construct:// const auth = new AuthenticationService(???); // Need UserService first// const user = new UserService(???); // Need AuthenticationService firstThe Indirect Cycle—Harder to Detect:
More insidiously, cycles can span many classes. Consider this chain that seems reasonable in isolation:
123456789
OrderService → PaymentService → FraudDetectionService → UserRiskProfileService → OrderHistoryService → OrderService // CYCLE DETECTED! Each individual dependency seems reasonable. But together, they form anunresolvable cycle that may span multiple packages and teams.Indirect cycles are the most dangerous because no single developer sees the full picture. Team A adds OrderService → PaymentService. Team B adds FraudDetectionService → UserRiskProfileService. Team C closes the loop without realizing it. The system explodes in production.
Different IoC containers and runtime environments handle circular dependencies differently. Understanding these behaviors helps you diagnose issues faster:
| Environment/Container | Behavior | Symptoms |
|---|---|---|
| Node.js require() | Module returns partial exports | Undefined or incomplete objects at runtime; hard-to-trace bugs |
| Angular DI | Detection and error at bootstrap | Clear error message: 'Circular dependency detected' |
| Spring Framework | Attempts proxy-based resolution | May work but indicates design flaw; occasional cryptic failures |
| NestJS | Detection with clear error | Error message identifies the cycle path |
| Manual construction | Stack overflow or infinite loop | Recursive constructor calls until stack exhausted |
| Lazy resolution | Deferred construction allows startup | Null references when lazy property accessed before initialization |
Manifestation Example in Node.js:
Node.js has notoriously subtle circular dependency behavior. When modules circularly require each other, you get partially initialized exports:
123456789101112131415161718192021
// file: moduleA.tsimport { functionB } from './moduleB'; export function functionA() { console.log('A called'); functionB();} // This runs at import time, BEFORE moduleB finishes loadingfunctionA(); // file: moduleB.ts import { functionA } from './moduleA'; export function functionB() { console.log('B called'); functionA(); // 💥 UNDEFINED! moduleA hasn't finished exporting yet} // Runtime error: functionA is not a function// The error appears unrelated to circular deps, causing hours of confusionThe worst manifestation isn't an error—it's silent corruption. Some containers 'resolve' cycles by injecting partially constructed objects or null proxies. Your code runs but operates on invalid state. These bugs are nearly impossible to diagnose without understanding the underlying circular dependency.
Circular dependencies are always symptoms of deeper design problems. Understanding these root causes is essential for prevention and resolution:
Case Study: The Misplaced Responsibility
Revisiting our earlier example, the cycle exists because AuthenticationService has a method that should be in a different class:
AuthenticationService.authenticate() → needs user lookup → needs UserServiceUserService.sendPasswordResetEmail() → needs token generation → needs AuthenticationServiceBut wait—why does UserService send emails? That's not a user management concern. And why does it generate reset tokens? Token generation is an authentication concern. The method is misplaced.
12345678910111213141516171819
// The problem: sendPasswordResetEmail() doesn't belong in UserService// It combines authentication (token), notification (email), and user lookup // UserService should ONLY manage user data// AuthenticationService should ONLY handle security tokens// NotificationService should ONLY handle email/SMS/push// PasswordResetService should orchestrate the three // Current (broken):UserService ─────────────────→ AuthenticationService ↑ │ └───────────────────────────────┘ // Proper design eliminates the cycle:PasswordResetService → AuthenticationService → UserService → NotificationService // Each arrow is uni-directional. No cycles.Waiting for production failures to reveal circular dependencies is catastrophically expensive. Implement these detection strategies to catch cycles early:
123456789101112131415161718192021
# Using Madge to detect circular dependencies in a TypeScript project # Installnpm install -g madge # Detect circular dependenciesmadge --circular --extensions ts src/ # Output when cycle found:# Circular dependency found:# src/services/OrderService.ts -># src/services/PaymentService.ts -># src/services/FraudDetectionService.ts -># src/services/UserRiskProfileService.ts -># src/services/OrderHistoryService.ts -># src/services/OrderService.ts # Generate visual graphmadge --circular --image cycle-graph.svg src/ # This command should be in CI pipeline to fail builds with cyclesAdd circular dependency detection to your CI pipeline. The few seconds of analysis time prevents hours or days of production debugging. Most tools return non-zero exit codes when cycles are found, making CI integration trivial.
When you've identified a circular dependency, several strategies can break the cycle. Choose based on the root cause:
When two classes depend on each other, often both should depend on a shared interface that neither owns:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// 🚨 BEFORE: Circular - A and B depend on each otherclass OrderProcessor { constructor(private inventory: InventoryManager) {} processOrder(order: Order) { if (this.inventory.checkAvailability(order.items)) { this.inventory.reserve(order.items); } }} class InventoryManager { constructor(private orderProcessor: OrderProcessor) {} // CYCLE! onStockReplenished(items: Item[]) { // Need to process pending orders when stock arrives // This creates the cycle }} // ✅ AFTER: Both depend on abstraction, no cycle interface IStockObserver { onStockReplenished(items: Item[]): void;} interface IInventoryChecker { checkAvailability(items: OrderItem[]): boolean; reserve(items: OrderItem[]): void;} class OrderProcessor implements IStockObserver { constructor(private inventory: IInventoryChecker) {} processOrder(order: Order) { if (this.inventory.checkAvailability(order.items)) { this.inventory.reserve(order.items); } } onStockReplenished(items: Item[]) { // Handle pending orders }} class InventoryManager implements IInventoryChecker { private observers: IStockObserver[] = []; registerObserver(observer: IStockObserver) { this.observers.push(observer); } replenishStock(items: Item[]) { // ... update inventory this.observers.forEach(o => o.onStockReplenished(items)); } checkAvailability(items: OrderItem[]): boolean { /* ... */ } reserve(items: OrderItem[]): void { /* ... */ }} // Wiring at composition root:const inventory = new InventoryManager();const orderProcessor = new OrderProcessor(inventory);inventory.registerObserver(orderProcessor);// No circular constructor dependency - observer registered post-constructionWhen multiple classes need bidirectional communication, extract a mediator that knows about all parties:
12345678910111213141516171819202122232425262728293031323334353637383940
// ✅ GOOD: Mediator coordinates multiple participants interface IParticipant { setMediator(mediator: IOrderMediator): void;} interface IOrderMediator { notify(sender: IParticipant, event: OrderEvent): void;} class OrderMediator implements IOrderMediator { constructor( private orderService: OrderService, private paymentService: PaymentService, private inventoryService: InventoryService ) { // Wire participants to use mediator this.orderService.setMediator(this); this.paymentService.setMediator(this); this.inventoryService.setMediator(this); } notify(sender: IParticipant, event: OrderEvent) { if (event.type === 'ORDER_PLACED') { this.inventoryService.reserve(event.items); this.paymentService.processPayment(event.payment); } if (event.type === 'PAYMENT_COMPLETED') { this.inventoryService.confirmReservation(event.orderId); } if (event.type === 'PAYMENT_FAILED') { this.inventoryService.releaseReservation(event.orderId); this.orderService.cancelOrder(event.orderId); } }} // Each service NO LONGER depends on the others// All services depend only on IOrderMediator// The MediaTOR depends on all services (one-directional)Replace synchronous method calls with asynchronous events. Classes publish events; other classes subscribe without knowing who publishes:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// ✅ GOOD: Event-driven communication breaks coupling interface DomainEvent { type: string; timestamp: Date; payload: unknown;} class EventBus { private subscribers = new Map<string, Array<(event: DomainEvent) => void>>(); subscribe(eventType: string, handler: (event: DomainEvent) => void) { // Register handler } publish(event: DomainEvent) { // Dispatch to all subscribers }} class UserService { constructor(private eventBus: EventBus) { // Subscribe to events, not to concrete services this.eventBus.subscribe('PasswordResetRequested', this.handlePasswordReset.bind(this)); } private handlePasswordReset(event: DomainEvent) { // Handle event without knowing who published it }} class AuthenticationService { constructor(private eventBus: EventBus) {} requestPasswordReset(email: string) { const resetToken = this.generateResetToken(); // Publish event without knowing who handles it this.eventBus.publish({ type: 'PasswordResetRequested', timestamp: new Date(), payload: { email, resetToken } }); }} // UserService and AuthenticationService only depend on EventBus// No circular dependency possibleWhen a dependency is only needed for one method, pass it as a parameter instead of a constructor dependency:
123456789101112131415161718192021222324
// 🚨 BEFORE: Constructor injection of rarely-used dependency creates cycleclass ReportGenerator { constructor(private dataService: DataService) {} generateReport(reportType: string, formatter: IFormatter) { // dataService only used occasionally }} // ✅ AFTER: Pass dependency when needed, not at constructionclass ReportGenerator { // No DataService constructor dependency generateReport( reportType: string, dataService: IDataService, // Passed when called formatter: IFormatter ) { const data = dataService.getData(reportType); return formatter.format(data); }} // The caller provides dataService, which may break the cycleMany IoC containers offer lazy resolution features (providers, factories, lazy proxies) that can technically 'solve' circular dependencies by deferring construction. This is almost always the wrong approach.
123456789101112131415161718192021222324252627282930
// 🚨 ANTI-PATTERN: Using Lazy to mask circular dependency class ServiceA { constructor( // Inject a factory/provider instead of the actual service private serviceBProvider: () => ServiceB // Lazy! ) {} doSomething() { const serviceB = this.serviceBProvider(); // Resolved when needed serviceB.doSomethingElse(); }} class ServiceB { constructor( private serviceAProvider: () => ServiceA // Also lazy! ) {} doSomethingElse() { const serviceA = this.serviceAProvider(); serviceA.doAnother(); }} // This "works" but the fundamental design problem remains:// - A and B are still coupled// - Testing is still awkward// - Reasoning about the system is still difficult// - You've just hidden the cycle behind indirectionIf you use lazy resolution to break a cycle, treat it as technical debt. Document it. Create a ticket for proper refactoring. Set a deadline. Lazy resolution buys time; it doesn't solve problems.
The best circular dependency is one that never exists. These architectural practices prevent cycles from forming:
12345678910111213141516171819202122232425262728
Healthy Layered Architecture (Dependencies Flow Downward): ┌─────────────────────────────────────────────────────────────┐│ PRESENTATION LAYER ││ (Controllers, Views, View Models) │└─────────────────────────────────────────────────────────────┘ │ ▼ (depends on)┌─────────────────────────────────────────────────────────────┐│ APPLICATION LAYER ││ (Use Cases, Application Services) │└─────────────────────────────────────────────────────────────┘ │ ▼ (depends on)┌─────────────────────────────────────────────────────────────┐│ DOMAIN LAYER ││ (Entities, Value Objects, Domain Services) │└─────────────────────────────────────────────────────────────┘ │ ▼ (depends on)┌─────────────────────────────────────────────────────────────┐│ INFRASTRUCTURE LAYER ││ (Repositories Impl, External Services, Persistence) │└─────────────────────────────────────────────────────────────┘ 📌 Key principle: Arrows only point DOWN. Never up.📌 When you need to notify "up", use events or observer patterns.📌 Interfaces for infrastructure live in DOMAIN layer (Dependency Inversion)The Acyclic Dependencies Principle (ADP) states that the dependency graph of packages must have no cycles. This principle applies at all scales—classes, packages, modules, and services. When you design new code, consciously ensure arrows only point in valid directions.
Let's consolidate the key insights about circular dependencies:
What's Next:
Constructor over-injection and circular dependencies are the most obvious DI anti-patterns. Our next topic, captive dependencies, is subtler—it involves lifetime mismatches where a long-lived service captures a short-lived dependency, causing resource leaks and stale data. This anti-pattern is particularly insidious because systems can run for months before symptoms appear.
You now understand circular dependencies—the anti-pattern where classes create cycles in the dependency graph. You can detect them with tooling, resolve them with proper design patterns, and prevent them through architectural discipline. Next, we'll explore captive dependencies and lifetime management pitfalls.