Back to KB
Difficulty
Intermediate
Read Time
7 min

Feature Based Clean Architecture. Part 3: The Architectural Risk of Cycles in NestJS: ROI of Decisions on a Five-Year Horizon

By Codcompass Team··7 min read

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:

  1. Facade Incompatibility: forwardRef creates a proxy placeholder, not a real module instance. NestJS cannot re-export a forwardRef module. If you attempt to build a facade pattern where ModuleC imports ModuleA (which uses forwardRef to ModuleB) and tries to re-export ModuleB's services, the build fails. This forces abstraction leakage, where consumers must import internal modules directly.
  2. Runtime Fragility: Accumulated forwardRef usage increases the complexity of the DI graph. This often manifests as TypeError: Cannot read properties of undefined during 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.
  3. 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.

StrategyBuild StabilityFacade/Re-export SupportRefactor Effort (18mo)Risk of Runtime undefined
forwardRef AccumulationLowFailsHighHigh
Structural DecouplingHighWorksLowLow

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.

  • 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:

  1. 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;
    }
    
  2. Configure the Event Bus: Use @nestjs/event-emitter or 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: 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

  • Audit Cycles: Run madge --circular ./src to identify all existing circular dependencies.
  • Remove forwardRef: Search the codebase for forwardRef usage. 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

ScenarioRecommended ApproachWhyCost Impact
Modules need synchronous data exchangeInterface SegregationUse 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 changesDomain EventsDecouples modules completely. Allows independent scaling and testing. Best for workflows like Order -> Payment -> Notification.Medium
Modules share common data structuresShared Types ModuleMove DTOs and interfaces to a SharedModule or external package. Prevents modules from importing each other just for types.Low
Legacy code with deep cyclesStrangler Fig PatternGradually 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

  1. Install Detection Tool: Run npm install --save-dev madge.
  2. Scan Codebase: Execute npx madge --circular ./src. Review the output to map current cycles.
  3. Select Target: Pick the cycle with the highest forwardRef usage or the one blocking a facade.
  4. Refactor: Introduce an event for the interaction causing the cycle. Update services to emit/listen. Remove forwardRef from module imports.
  5. Verify: Run the scan again. Ensure the cycle is gone and the build passes without forwardRef.