radation.
Core Solution
The robust approach to circular dependencies is to break the logical coupling, not just the DI coupling. This requires restructuring how modules communicate. The two primary patterns for this are Domain Events and Interface Segregation with Shared Abstractions.
Scenario: Order and Payment Modules
Consider a scenario where OrdersModule and PaymentsModule have a cycle.
OrdersService calls PaymentsService to initiate a charge.
PaymentsService calls OrdersService to retrieve order details for generating a receipt and validating order status.
The Cycle:
// OrdersModule
@Module({
imports: [forwardRef(() => PaymentsModule)], // Cycle
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}
// PaymentsModule
@Module({
imports: [forwardRef(() => OrdersModule)], // Cycle
providers: [PaymentsService],
exports: [PaymentsService],
})
export class PaymentsModule {}
Solution: Event-Driven Decoupling
Instead of direct service calls, modules emit and listen to domain events. This removes the dependency arrow entirely. OrdersModule emits an event; PaymentsModule reacts. PaymentsModule emits an event; OrdersModule reacts. No module imports the other.
Implementation:
-
Define the Event Contract:
Create a shared interface or DTO for the event payload. This can live in a shared types package or a neutral module.
export interface OrderCreatedEvent {
readonly orderId: string;
readonly amount: number;
readonly currency: string;
}
export interface PaymentCompletedEvent {
readonly paymentId: string;
readonly orderId: string;
readonly transactionRef: string;
}
-
Configure the Event Bus:
Use @nestjs/event-emitter or a custom implementation.
// app.module.ts
@Module({
imports: [
EventEmitterModule.forRoot(),
OrdersModule,
PaymentsModule,
],
})
export class AppModule {}
-
Refactor OrdersService:
Remove the dependency on PaymentsService. Emit the event.
@Injectable()
export class OrdersService {
constructor(private readonly eventEmitter: EventEmitter2) {}
async createOrder(dto: CreateOrderDto): Promise<Order> {
const order = await this.repository.save(dto);
// Decoupled communication
this.eventEmitter.emit(
'order.created',
new OrderCreatedEvent(order.id, order.total, order.currency),
);
return order;
}
}
-
Refactor PaymentsService:
Remove the dependency on OrdersService. Listen for events and emit results.
@Injectable()
export class PaymentsService implements OnModuleInit {
constructor(private readonly eventEmitter: EventEmitter2) {}
onModuleInit() {
this.eventEmitter.on(
'order.created',
(payload: OrderCreatedEvent) => this.processPayment(payload),
);
}
private async processPayment(payload: OrderCreatedEvent) {
// Payment logic using payload data
// No need to call OrdersService to fetch order; data is in payload
const result = await this.gateway.charge(payload);
this.eventEmitter.emit(
'payment.completed',
new PaymentCompletedEvent(result.id, payload.orderId, result.ref),
);
}
}
-
Refactor OrdersService to handle Payment Result:
Listen for the completion event to update order status.
@Injectable()
export class OrdersService implements OnModuleInit {
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly repository: OrderRepository,
) {}
onModuleInit() {
this.eventEmitter.on(
'payment.completed',
(payload: PaymentCompletedEvent) => this.markOrderPaid(payload),
);
}
private async markOrderPaid(payload: PaymentCompletedEvent) {
await this.repository.updateStatus(payload.orderId, 'PAID');
}
}
Architecture Rationale:
- Zero Imports:
OrdersModule and PaymentsModule have no import relationship. The cycle is eliminated.
- Facade Safe: Since there are no cycles, you can freely create a
CheckoutFacadeModule that imports both and re-exports services without forwardRef errors.
- Scalability: Adding a
NotificationsModule to send emails on payment completion requires only adding a listener. No changes to Orders or Payments modules.
Pitfall Guide
1. The "It Compiles" Fallacy
Explanation: Developers assume that because forwardRef resolves the DI error, the architectural problem is solved.
Fix: Treat forwardRef as a compiler error. If you must use it, it indicates a design flaw that must be refactored before merging. Use static analysis tools to flag forwardRef usage in CI.
2. Transitive Export Crash
Explanation: Attempting to re-export a service from a module that uses forwardRef to import the provider module. NestJS cannot resolve the proxy through an export boundary.
Fix: Never re-export services across a forwardRef boundary. If you need a facade, ensure the underlying modules are decoupled. Use the Event pattern to break the cycle before building the facade.
3. Service-Level Blindness
Explanation: Reviewers check service dependencies and see clean code, missing that the modules are cyclic.
Fix: Review module imports, not just service constructors. A service might inject an interface, but if the module providing that interface creates a cycle, the architecture is still compromised. Use madge to visualize module-level graphs.
4. Facade Leakage
Explanation: To work around the Transitive Export Crash, developers import the internal module directly into the consumer, bypassing the facade. This exposes internal implementation details.
Fix: Enforce module boundaries via linting rules. If a facade is broken, fix the cycle, don't bypass the facade. Bypassing leads to tight coupling where consumers depend on internal module structures.
5. Runtime undefined Errors
Explanation: TypeError: Cannot read properties of undefined occurs when the DI container fails to instantiate a service due to complex circular resolution chains, often exacerbated by forwardRef.
Fix: Eliminate cycles. If a cycle is unavoidable (rare), ensure the dependency is injected via a factory provider or a token that guarantees lazy resolution, but prefer structural decoupling via events.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Modules need synchronous data exchange | Interface Segregation | Use a shared interface in a neutral module. One module provides the implementation; the other consumes the interface. Breaks the cycle by inverting the dependency. | Low |
| Modules need async reaction to state changes | Domain Events | Decouples modules completely. Allows independent scaling and testing. Best for workflows like Order -> Payment -> Notification. | Medium |
| Modules share common data structures | Shared Types Module | Move DTOs and interfaces to a SharedModule or external package. Prevents modules from importing each other just for types. | Low |
| Legacy code with deep cycles | Strangler Fig Pattern | Gradually replace cyclic modules with event-driven or interface-based modules. Wrap old modules in adapters. | High |
Configuration Template
Use madge for automated cycle detection. Add this to your package.json:
{
"scripts": {
"check:circular": "madge --circular --extensions ts ./src",
"check:circular:json": "madge --circular --json ./src | jq '.[]' | xargs -I {} echo 'Cycle detected: {}'"
},
"devDependencies": {
"madge": "^7.0.0"
}
}
Integrate into CI:
# .github/workflows/ci.yml
- name: Check Circular Dependencies
run: npm run check:circular
Quick Start Guide
- Install Detection Tool: Run
npm install --save-dev madge.
- Scan Codebase: Execute
npx madge --circular ./src. Review the output to map current cycles.
- Select Target: Pick the cycle with the highest
forwardRef usage or the one blocking a facade.
- Refactor: Introduce an event for the interaction causing the cycle. Update services to emit/listen. Remove
forwardRef from module imports.
- Verify: Run the scan again. Ensure the cycle is gone and the build passes without
forwardRef.