Feature Based Clean Architecture. Part 3: The Architectural Risk of Cycles in NestJS: ROI of Decisions on a Five-Year Horizon
The forwardRef Trap: Why Circular Dependencies Erode NestJS Scalability
Current Situation Analysis
In NestJS projects, circular dependencies between modules are rarely introduced maliciously. They emerge organically as feature requirements evolve. A team builds ModuleA and ModuleB with clean boundaries. Six months later, a new requirement demands ModuleA query state from ModuleB, while ModuleB simultaneously needs to validate that state against ModuleA.
The immediate reaction is often to apply forwardRef. This is a documented NestJS API that allows the dependency injection container to resolve circular references by deferring instantiation. It compiles. The tests pass. The PR is approved.
The misunderstanding lies in treating forwardRef as an architectural solution. It is merely a dependency injection workaround. It resolves the instantiation order but preserves the logical coupling. Over a multi-year horizon, this creates a compounding debt:
- Facade Incompatibility:
forwardRefcreates a proxy placeholder, not a real module instance. NestJS cannot re-export aforwardRefmodule. If you attempt to build a facade pattern whereModuleCimportsModuleA(which usesforwardReftoModuleB) and tries to re-exportModuleB's services, the build fails. This forces abstraction leakage, where consumers must import internal modules directly. - Runtime Fragility: Accumulated
forwardRefusage increases the complexity of the DI graph. This often manifests asTypeError: Cannot read properties of undefinedduring runtime, particularly when lazy loading or dynamic modules are involved. The error points to a service being undefined because the circular resolution chain broke at a specific instantiation step. - Refactor Paralysis: As cycles accumulate, the cost of breaking them grows non-linearly. A cycle between two modules is manageable. A cycle involving five modules, each wrapped in
forwardRef, requires a coordinated rewrite that teams defer indefinitely, leading to "big ball of mud" architecture.
WOW Moment: Key Findings
The following comparison illustrates the divergence between using forwardRef as a band-aid versus implementing structural decoupling. The metrics reflect the state of a codebase after 18 months of active development.
| Strategy | Build Stability | Facade/Re-export Support | Refactor Effort (18mo) | Risk of Runtime undefined |
|---|---|---|---|---|
forwardRef Accumulation | Low | Fails | High | High |
| Structural Decoupling | High | Works | Low | Low |
Why this matters:
The critical insight is the Facade/Re-export Support column. In production systems, you often need to aggregate services behind a unified interface to hide implementation details. forwardRef makes this impossible. If your architecture relies on facades to manage complexity, forwardRef will eventually block your ability to encapsulate logic, forcing you to expose internal module boundaries to the rest of the application. This violates the Principle of Least Knowledge and accelerates codebase degradation.
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.
OrdersServicecallsPaymentsServiceto initiate a charge.PaymentsServicecallsOrdersServiceto 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-emitteror a custom implementa
tion.
```typescript
// app.module.ts
@Module({
imports: [
EventEmitterModule.forRoot(),
OrdersModule,
PaymentsModule,
],
})
export class AppModule {}
```
3. Refactor OrdersService:
Remove the dependency on PaymentsService. Emit the event.
```typescript
@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;
}
}
```
4. Refactor PaymentsService:
Remove the dependency on OrdersService. Listen for events and emit results.
```typescript
@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),
);
}
}
```
5. Refactor OrdersService to handle Payment Result:
Listen for the completion event to update order status.
```typescript
@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:
OrdersModuleandPaymentsModulehave no import relationship. The cycle is eliminated. - Facade Safe: Since there are no cycles, you can freely create a
CheckoutFacadeModulethat imports both and re-exports services withoutforwardReferrors. - Scalability: Adding a
NotificationsModuleto send emails on payment completion requires only adding a listener. No changes toOrdersorPaymentsmodules.
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
- Audit Cycles: Run
madge --circular ./srcto identify all existing circular dependencies. - Remove
forwardRef: Search the codebase forforwardRefusage. Each instance is a candidate for refactoring. - Implement Events: For cross-module communication causing cycles, introduce domain events. Ensure event payloads contain all necessary data to avoid "chatty" callbacks.
- Validate Facades: Test that all facade modules can re-export services without build errors.
- Enforce Boundaries: Add a pre-commit hook or CI step that fails on new circular dependencies.
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
forwardRefusage or the one blocking a facade. - Refactor: Introduce an event for the interaction causing the cycle. Update services to emit/listen. Remove
forwardReffrom module imports. - Verify: Run the scan again. Ensure the cycle is gone and the build passes without
forwardRef.
