Solving NestJS Circular Dependencies in Microservices
A comprehensive guide to fixing the dreaded βCannot read properties of undefinedβ error when using
forwardRefin NestJS microservices with TCP transport.
Problem Overview
Section titled βProblem OverviewβThe Symptoms
Section titled βThe SymptomsβWhen calling a service via Microservice TCP transport that uses forwardRef, youβll encounter:
TypeError: Cannot read properties of undefined (reading 'find')The this.typeOrmRepository or other dependencies become undefined in the method being called.
Observable Behavior
Section titled βObservable Behaviorβ| Call Method | Result |
|---|---|
Direct HTTP API call (e.g., GET /data-owner-bc/v1/resources) | β Works perfectly |
| Via Microservice TCP (e.g., consumer β data-owner) | β Repository/Services are undefined |
Diagnostic Log Pattern
Section titled βDiagnostic Log PatternβController visitRepository ---> Repository { ... } # β
Controller has repodata ---> { ... }repo visitRepo ---> undefined # β Service has NO repoDEBUG CONSTRUCTOR Repo: Repository { ... } # π΄ Constructor called AFTER method!Root Cause Analysis
Section titled βRoot Cause AnalysisβThe Core Issue: forwardRef + Microservice Context
Section titled βThe Core Issue: forwardRef + Microservice ContextβThe problem occurs when using forwardRef() in service constructors called via Microservice transport.
Problematic Architecture
Section titled βProblematic Architectureββββββββββββββββββββ ββββββββββββββββββββ VisitModule ββββββββββΊβ PatientModule ββ βforwardRefβ βββββββββββ¬βββββββββ ββββββββββ¬βββββββββ β β βΌ βΌβββββββββββββββββββ ββββββββββββββββββββ VisitsService ββββββββββΊβ PatientsService ββ βforwardRefβ ββ + 4 more β ββββββββββββββββββββ forwardRef ββββββββββββββββββββ β βΌ π₯ Microservice message arrives π₯ Everything is undefined!Problematic Code Example
Section titled βProblematic Code Exampleβ@Injectable()export class VisitsService extends BaseServiceOperations<Visit, CreateVisitDTO, UpdateVisitDTO> { constructor( @InjectRepository(Visit, AppDatabases.APP_CORE) visitRepository: Repository<Visit>,
// β οΈ forwardRef causes undefined in Microservice context @Inject(forwardRef(() => VisitOrdersService)) private readonly visitOrdersService: VisitOrdersService, @Inject(forwardRef(() => PatientInsurancesService)) private readonly patientInsurancesService: PatientInsurancesService, @Inject(forwardRef(() => PatientsService)) private readonly patientsService: PatientsService, @Inject(forwardRef(() => AssessmentsService)) private readonly assessmentsService: AssessmentsService, ) { super(visitRepository, {...}); }}Why Only in Microservice Context?
Section titled βWhy Only in Microservice Context?βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
HTTP Context (Normal Behavior) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€β ββ [App Bootstrap] βββββββββββββββββββββββββββββββββββββββββββββββββββββββΊ ββ β ββ βββΊ Resolve all providers ββ βββΊ Resolve forwardRefs ββ βββΊ app.listen() β Everything ready 100% ββ β ββ βΌ ββ [HTTP Request arrives] ββ β ββ βββΊ Service ready 100% β
ββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β Microservice TCP Context (Problematic) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€β ββ [App Bootstrap] βββββββββββββββββββββββββββββββββββββββββββββββββββββββΊ ββ β ββ βββΊ Resolve providers (in progress...) ββ β β ββ β β [TCP Message arrives!] β Doesn't wait for bootstrap ββ β β β ββ β β βΌ ββ β β [NestJS needs Service] ββ β β β ββ β β βββΊ Instance exists (partially initialized) ββ β β β ββ β β βββΊ Uses that instance! β οΈ ββ β β β ββ β β βΌ ββ β β [Method called] ββ β β β ββ β β this.repo = undefined β ββ β β this.service = undefined β ββ β β (constructor not finished!) ββ β ββ βββΊ Still resolving forwardRefs... ββ βββΊ (Too late, method already called) ββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββSolution 1: Facade Pattern
Section titled βSolution 1: Facade PatternβThe Concept
Section titled βThe ConceptβInstead of Services talking directly to each other (requiring forwardRef), create an Orchestrator Service to coordinate complex workflows.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β Before (Problematic) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€β ββ ββββββββββββββββ forwardRef ββββββββββββββββ ββ βVisitsService ββββββββββββββββΊβPatientsServiceβ ββ β β β β ββ β + forwardRef β ββββββββββββββββ ββ β - VisitOrders ββ β - Assessments π₯ Circular Dependency ββ β - VitalSigns π₯ Repository = undefined ββ β - Patients π₯ Microservice unusable ββ ββββββββββββββββ ββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
After (Facade Pattern) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€β ββ βββββββββββββββββββββββββββββββββββ ββ β VisitOperationsService β ββ β (Facade / Orchestrator) β ββ β β ββ β β’ Complex workflows β ββ β β’ Transaction management β ββ β β’ Cross-service coordination β ββ βββββββββββββββββ¬ββββββββββββββββββ ββ β ββ β inject (No forwardRef needed!) ββ βΌ ββ ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ ββ βVisitsService β β Assessments β β Patients β ββ β(Pure CRUD) β β Service β β Service β ββ β β β (Pure CRUD) β β (Pure CRUD) β ββ βNo forwardRef β β β β β ββ ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ ββ ββ β
No Circular Dependency ββ β
One-way dependencies only ββ β
Microservice works 100% ββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββKey Principles
Section titled βKey Principlesβ| Principle | Description |
|---|---|
| Separation of Concerns | Each Service manages CRUD for its own Entity only |
| One-way Dependencies | Dependencies flow from Facade down to Services only |
| No forwardRef | Absolutely no forwardRef anywhere |
| Single Orchestrator | All complex workflows live in one Facade |
Refactoring Steps
Section titled βRefactoring StepsβStep 1: Create VisitOperationsService (Facade)
Section titled βStep 1: Create VisitOperationsService (Facade)β/** * Facade/Orchestrator Service for managing complex workflows. * * Acts as a "manager" that orchestrates multiple Services * without creating Circular Dependencies. */@Injectable()export class VisitOperationsService { constructor( private readonly dataSource: DataSource, // Inject all services β NO forwardRef needed! private readonly visitsService: VisitsService, private readonly assessmentsService: AssessmentsService, private readonly vitalSignsService: VitalSignsService, private readonly visitOrdersService: VisitOrdersService, private readonly visitRightsService: VisitRightsService, private readonly groupOrdersService: GroupOrdersService, private readonly patientsService: PatientsService, private readonly patientInsurancesService: PatientInsurancesService, ) {}
/** * Complex workflow requiring multiple services. * Uses a Transaction for data consistency. */ async upsertAssessmentExamination( data: CreateAssessmentExaminationDTO, currentUser: IUserSession, ): Promise<Assessment> { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction();
try { const visit = await this.getOrCreateVisit(queryRunner, data, currentUser); const assessment = await this.upsertAssessment(queryRunner, visit, data, currentUser); await this.createVitalSigns(queryRunner, assessment, visit, data, currentUser);
await queryRunner.commitTransaction(); return assessment; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } }
// ... private helper methods}Step 2: Simplify VisitsService (Pure CRUD)
Section titled βStep 2: Simplify VisitsService (Pure CRUD)β/** * VisitsService β Pure CRUD Service for Visit Entity. * * Handles CRUD operations for Visit entity only. * Complex workflows use VisitOperationsService instead. * * NO forwardRef anywhere! */@Injectable()export class VisitsService extends BaseServiceOperations<Visit, CreateVisitDTO, UpdateVisitDTO> { constructor( logger: LogsService, configService: ConfigService, @InjectRepository(Visit, AppDatabases.APP_CORE) visitRepository: Repository<Visit>, // External microservice clients only β NO forwardRef! @Inject(AppMicroservice.SystemAdmin.name) private readonly systemAdminService: ClientProxy, ) { super(visitRepository, { ... }); }
// Pure CRUD + simple queries only async findTodayVisit(resourceId: string, startOfDay: Date, endOfDay: Date): Promise<Visit | null> { return this.repo.findOne({ where: { resource_id: resourceId, visit_date: Between(startOfDay, endOfDay), is_deleted: false, }, }); }}Step 3: Update Controller
Section titled βStep 3: Update Controllerβ@Controller()export class VisitEventsController { constructor( // Use Facade instead of VisitsService directly private readonly visitOperationsService: VisitOperationsService, ) {}
@MessagePattern({ cmd: AppMicroservice.DataOwner.cmd.VisitEvents.UpsertAssessmentExamination }) async upsertAssessmentExamination(@Payload() payload: IMicroservicePayload<...>): Promise<Assessment> { return this.visitOperationsService.upsertAssessmentExamination( payload.payload.data, payload.payload.current_user, ); }}Step 4: Update Module
Section titled βStep 4: Update Moduleβ@Module({ imports: [ PatientModule, // No forwardRef needed anymore! TypeOrmModule.forFeature([Visit, ...], AppDatabases.APP_CORE), ], providers: [ // Pure CRUD Services β no dependencies between them VisitsService, AssessmentsService, VitalSignsService, VisitOrdersService, GroupOrdersService, VisitRightsService,
// Facade β injects all services VisitOperationsService, ], exports: [ VisitsService, AssessmentsService, VisitOperationsService, ],})export class VisitModule {}When to Use Facade Pattern
Section titled βWhen to Use Facade Patternβ| β Use Facade | β Donβt Use Facade |
|---|---|
| Workflow requires multiple services | Simple CRUD operations |
| Transactions across entities | Queries within single entity |
| Complex business logic | No cross-service communication |
| Data consistency is critical |
Deep Dive: Facade as a Transactional Boundary
Section titled βDeep Dive: Facade as a Transactional BoundaryβThe Facade Pattern isnβt just about fixing circular dependencies β it serves as a critical Transactional Boundary.
Consider a real-world scenario: Onboarding a new resource. This single action touches multiple domains:
- Resource Domain: Update status to βActiveβ, assign ID.
- Billing Domain: Open a new account.
- Records Domain: Create an initial record.
- Notification Domain: Alert the account manager.
Without a Facade, ResourceService would need to depend on BillingService, RecordsService, and NotificationService, creating a massive web of circular dependencies.
Solution with ResourceOnboardingFacade:
@Injectable()export class ResourceOnboardingFacade { constructor( private readonly dataSource: DataSource, private readonly resourceService: ResourceService, private readonly billingService: BillingService, private readonly recordsService: RecordsService, private readonly notificationService: NotificationService, ) {}
async onboardResource(resourceId: string, roomNumber: string, user: IUserSession) { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction();
try { // 1. Update Resource (pass QueryRunner for transaction) const resource = await this.resourceService.activate( queryRunner, resourceId, 'ACTIVE', );
// 2. Open Billing Account await this.billingService.openAccount(queryRunner, resourceId, user.id);
// 3. Create Initial Record await this.recordsService.createOnboardingRecord(queryRunner, resource, user.id);
// Commit only if all above succeed await queryRunner.commitTransaction();
// 4. Notifications (outside transaction β event-based) await this.notificationService.notifyAccountManager(resource);
return resource; } catch (err) { await queryRunner.rollbackTransaction(); throw err; } finally { await queryRunner.release(); } }}This keeps ResourceService completely ignorant of BillingService, eliminating circular dependencies while ensuring data integrity.
Solution 2: Event-Driven Architecture
Section titled βSolution 2: Event-Driven ArchitectureβAnother powerful approach is using Event-Driven Architecture to reduce coupling between Services.
Using NestJS EventEmitter (In-Process)
Section titled βUsing NestJS EventEmitter (In-Process)βBest for communication within the same application.
Installation
Section titled βInstallationβnpm install @nestjs/event-emitterSetup in AppModule
Section titled βSetup in AppModuleβimport { EventEmitterModule } from '@nestjs/event-emitter';
@Module({ imports: [ EventEmitterModule.forRoot({ wildcard: true, delimiter: '.', maxListeners: 10, verboseMemoryLeak: true, }), ],})export class AppModule {}Create Event DTOs
Section titled βCreate Event DTOsβexport class VisitCreatedEvent { constructor( public readonly visitId: string, public readonly resourceId: string, public readonly createdBy: string, public readonly createdAt: Date, ) {}}
// events/assessment-requested.event.tsexport class AssessmentRequestedEvent { constructor( public readonly visitId: string, public readonly resourceId: string, public readonly assessmentData: { chief_complaint: string; urgency_code: string; vital_signs: any[]; }, public readonly requestedBy: string, ) {}}Emit Events from Service
Section titled βEmit Events from Serviceβimport { EventEmitter2 } from '@nestjs/event-emitter';
@Injectable()export class VisitsService { constructor( private readonly eventEmitter: EventEmitter2, @InjectRepository(Visit, AppDatabases.APP_CORE) private readonly visitRepo: Repository<Visit>, // NO forwardRef! ) {}
async createVisit(data: CreateVisitDTO, currentUser: IUserSession): Promise<Visit> { const visit = this.visitRepo.create(data); const savedVisit = await this.visitRepo.save(visit);
// β
Emit event instead of calling other services directly this.eventEmitter.emit( 'visit.created', new VisitCreatedEvent( savedVisit.id, savedVisit.resource_id, currentUser.id, savedVisit.created_at, ), );
return savedVisit; }
async requestAssessment( visitId: string, assessmentData: any, currentUser: IUserSession, ): Promise<void> { // β
Emit event for AssessmentsService to handle this.eventEmitter.emit( 'assessment.requested', new AssessmentRequestedEvent( visitId, assessmentData.resource_id, { chief_complaint: assessmentData.chief_complaint, urgency_code: assessmentData.urgency_code, vital_signs: assessmentData.vital_signs, }, currentUser.id, ), ); }}Listen to Events in Other Services
Section titled βListen to Events in Other Servicesβimport { OnEvent } from '@nestjs/event-emitter';
@Injectable()export class AssessmentsService { constructor( @InjectRepository(Assessment, AppDatabases.APP_CORE) private readonly assessmentRepo: Repository<Assessment>, // NO forwardRef! ) {}
// β
Listen to event and process when received @OnEvent('visit.created') async handleVisitCreated(event: VisitCreatedEvent): Promise<void> { console.log(`Visit created: ${event.visitId} for resource: ${event.resourceId}`); // No need to inject VisitsService at all! }
@OnEvent('assessment.requested') async handleAssessmentRequested(event: AssessmentRequestedEvent): Promise<void> { const assessment = this.assessmentRepo.create({ visit_id: event.visitId, resource_id: event.resourceId, chief_complaint: event.assessmentData.chief_complaint, urgency_code: event.assessmentData.urgency_code, created_by: event.requestedBy, });
await this.assessmentRepo.save(assessment); }
// β
Using wildcard patterns @OnEvent('visit.*') async handleAllVisitEvents(event: any): Promise<void> { console.log('Visit event received:', event); }}Async Event Processing
Section titled βAsync Event Processingβ@OnEvent('assessment.requested', { async: true })async handleAssessmentRequestedAsync(event: AssessmentRequestedEvent): Promise<void> { // This task runs async without blocking the main thread await this.processLongRunningTask(event);}Using Message Queues (Cross-Process / Cross-Service)
Section titled βUsing Message Queues (Cross-Process / Cross-Service)βBest for communication between microservices in separate processes.
Example with Redis + Bull Queue
Section titled βExample with Redis + Bull Queueβnpm install @nestjs/bull bullnpm install @types/bull -Dimport { BullModule } from '@nestjs/bull';
@Module({ imports: [ BullModule.forRoot({ redis: { host: 'localhost', port: 6379, }, }), BullModule.registerQueue({ name: 'assessment-queue', }), ],})export class AppModule {}Producer (Enqueue Job)
Section titled βProducer (Enqueue Job)βimport { InjectQueue } from '@nestjs/bull';import { Queue } from 'bull';
@Injectable()export class VisitsService { constructor( @InjectQueue('assessment-queue') private readonly assessmentQueue: Queue, ) {}
async createVisitWithAssessment(data: CreateVisitDTO): Promise<Visit> { const visit = await this.createVisit(data);
// β
Enqueue job instead of calling service directly await this.assessmentQueue.add( 'create-assessment', { visitId: visit.id, resourceId: visit.resource_id, assessmentData: data.assessment, }, { attempts: 3, backoff: 5000, removeOnComplete: true, }, );
return visit; }}Consumer (Process Job)
Section titled βConsumer (Process Job)βimport { OnQueueFailed, Process, Processor } from '@nestjs/bull';import { Job } from 'bull';
/** * Assessment Queue Processor. * Processes jobs from the 'assessment-queue' independently. */@Processor('assessment-queue')export class AssessmentProcessor { constructor( @InjectRepository(Assessment, AppDatabases.APP_CORE) private readonly assessmentRepo: Repository<Assessment>, // NO forwardRef needed! ) {}
@Process('create-assessment') async handleCreateAssessment(job: Job): Promise<void> { const { visitId, resourceId, assessmentData } = job.data;
const assessment = this.assessmentRepo.create({ visit_id: visitId, resource_id: resourceId, ...assessmentData, });
await this.assessmentRepo.save(assessment); }
@OnQueueFailed() async handleFailed(job: Job, error: Error): Promise<void> { console.error(`Job ${job.id} failed:`, error.message); }}Comparison: Facade vs EventEmitter vs Message Queue
Section titled βComparison: Facade vs EventEmitter vs Message Queueβ| Feature | Facade Pattern | EventEmitter | Message Queue (Bull) |
|---|---|---|---|
| Scope | In-process | In-process | Cross-process |
| Transaction Support | β Excellent support | β Difficult to implement | β Difficult to implement |
| Persistence | N/A (synchronous) | β Lost on restart | β Persisted in Redis |
| Retry Mechanism | Manual implementation | β Manual implementation | β Built-in with backoff |
| Complexity | Low | Low | Medium |
| Performance | Fast (direct call) | Very fast (in-memory) | Slower (network + Redis) |
| Best Use Case | Complex workflows requiring transactions | Simple decoupling, fire-and-forget | Distributed systems, background jobs |
| Error Handling | Try-catch blocks | Custom error listeners | Built-in failed job handlers |
Event-Driven Architecture Pattern
Section titled βEvent-Driven Architecture Patternββββββββββββββββββββ emit βββββββββββββββββ VisitsService ββββββββββββββββΊβ Event Bus ββ β β (EventEmitterββ Creates Visit β β or Queue) ββββββββββββββββββββ ββββββββ¬ββββββββ β ββββββββββββββββββββββΌβββββββββββββββββββββ β β β βΌ βΌ βΌ ββββββββββββββββββ ββββββββββββββββββ ββββββββββββββββββ β Assessments β β Notifications β β Audit Logs β β Service β β Service β β Service β β @OnEvent() β β @OnEvent() β β @OnEvent() β ββββββββββββββββββ ββββββββββββββββββ ββββββββββββββββββBenefits:
- No circular dependencies β Services donβt know about each other
- Independent services β Each service operates autonomously
- Easy to extend β Add new listeners without modifying existing code
- Loose coupling β Producer doesnβt care who consumes the event
Solution 3: Cross-Module forwardRef with EventEmitter
Section titled βSolution 3: Cross-Module forwardRef with EventEmitterβCommon Scenario
Section titled βCommon ScenarioβWhen a Service in one Module needs to call a Service in another Module, creating a Circular Dependency:
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β Cross-Module Circular Dependency ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€β ββ βββββββββββββββββββββββ βββββββββββββββββββββββ ββ β VisitModule β forwardRef β PatientModule β ββ β βββββββββββββββΊβ β ββ β βββββββββββββββββ β β βββββββββββββββββ β ββ β β VisitsService ββββΌβββββββββββββββΌββΊβPatientsServiceβ β ββ β β Needs to call β β β β Needs to call β β ββ β βpatientsServiceβ β β β visitsService β β ββ β βββββββββββββββββ β β βββββββββββββββββ β ββ βββββββββββββββββββββββ βββββββββββββββββββββββ ββ ββ π₯ Requires forwardRef at BOTH Module AND Service level ββ π₯ Microservice call arrives β undefined immediately ββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββProblematic Code Example
Section titled βProblematic Code Exampleβ@Module({ imports: [ forwardRef(() => PatientModule), // β forwardRef at Module level ], providers: [VisitsService], exports: [VisitsService],})export class VisitModule {}
// visits.service.ts@Injectable()export class VisitsService { constructor( @Inject(forwardRef(() => PatientsService)) // β forwardRef at Service level private readonly patientsService: PatientsService, ) {}
async createVisit(data: CreateVisitDTO): Promise<Visit> { const patient = await this.patientsService.findById(data.patient_id); // β In microservice context: this.patientsService is undefined! }}Solution: Use EventEmitter Instead of forwardRef
Section titled βSolution: Use EventEmitter Instead of forwardRefβββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
Solution: Event-Driven Communication ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€β ββ βββββββββββββββββββββββ βββββββββββββββββββββββ ββ β VisitModule β β PatientModule β ββ β βββββββββββββββββ β emit β βββββββββββββββββ β ββ β β VisitsService ββββΌββββββββββββββΊβ βPatientsServiceβ β ββ β β emit: β β Event Bus β β @OnEvent: β β ββ β βpatient.validateβ β β βpatient.validateβ β ββ β βββββββββββββββββ β β βββββββββββββββββ β ββ β β β β ββ β βββββββββββββββββ β β βββββββββββββββββ β ββ β β @OnEvent: ββββΌβββββββββββββββΌβββ emit: β β ββ β βvisit.requestedβ β β βvisit.requestedβ β ββ β βββββββββββββββββ β β βββββββββββββββββ β ββ βββββββββββββββββββββββ βββββββββββββββββββββββ ββ ββ β
No forwardRef required ββ β
Modules are completely independent ββ β
Microservice works 100% reliably ββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββRefactoring Steps
Section titled βRefactoring StepsβStep 1: Create Shared Events
Section titled βStep 1: Create Shared Eventsβexport class ResourceValidateRequestEvent { constructor( public readonly resourceId: string, public readonly requestId: string, // For correlating response ) {}}
export class ResourceValidateResponseEvent { constructor( public readonly requestId: string, public readonly isValid: boolean, public readonly resource: Resource | null, public readonly error?: string, ) {}}
// libs/common/events/visit.events.tsexport class VisitsByResourceRequestEvent { constructor( public readonly resourceId: string, public readonly requestId: string, ) {}}
export class VisitsByResourceResponseEvent { constructor( public readonly requestId: string, public readonly visits: Visit[], ) {}}Step 2: Create Event Helper Service
Section titled βStep 2: Create Event Helper Serviceβimport { Injectable } from '@nestjs/common';import { EventEmitter2 } from '@nestjs/event-emitter';import { v4 as uuidv4 } from 'uuid';
/** * Helper service for request-response pattern using EventEmitter. * Simulates synchronous service calls using asynchronous events. */@Injectable()export class EventRequestService { private readonly pendingRequests = new Map< string, { resolve: (value: any) => void; reject: (error: any) => void; timeout: NodeJS.Timeout; } >();
constructor(private readonly eventEmitter: EventEmitter2) {}
/** * Emit an event and wait for response. * Implements request-response pattern via EventEmitter. */ async emitAndWait<TRequest, TResponse>( requestEvent: string, responseEvent: string, payload: TRequest, timeoutMs: number = 5000, ): Promise<TResponse> { const requestId = uuidv4();
return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pendingRequests.delete(requestId); reject(new Error(`Event request timeout: ${requestEvent}`)); }, timeoutMs);
this.pendingRequests.set(requestId, { resolve, reject, timeout });
const handler = (response: any) => { if (response.requestId === requestId) { clearTimeout(timeout); this.pendingRequests.delete(requestId); this.eventEmitter.off(responseEvent, handler); resolve(response); } }; this.eventEmitter.on(responseEvent, handler);
this.eventEmitter.emit(requestEvent, { ...payload, requestId }); }); }}Step 3: Refactor VisitsService (NO forwardRef)
Section titled βStep 3: Refactor VisitsService (NO forwardRef)β@Injectable()export class VisitsService { constructor( @InjectRepository(Visit, AppDatabases.APP_CORE) private readonly visitRepo: Repository<Visit>, private readonly eventEmitter: EventEmitter2, private readonly eventRequest: EventRequestService, // β
NO forwardRef(() => PatientsService) needed! ) {}
async createVisit(data: CreateVisitDTO, currentUser: IUserSession): Promise<Visit> { // β
Use Event instead of calling resourcesService.findById() directly const validateResponse = await this.eventRequest.emitAndWait< ResourceValidateRequestEvent, ResourceValidateResponseEvent >('resource.validate.request', 'resource.validate.response', { resourceId: data.resource_id });
if (!validateResponse.isValid) { throw new BadRequestException( validateResponse.error || `Resource ${data.resource_id} not found`, ); }
return this.visitRepo.save( this.visitRepo.create({ ...data, created_by: currentUser.id }), ); }
// β
Listen for "get visits by resource" requests from other modules @OnEvent('visits.by-resource.request') async handleVisitsByResourceRequest(event: VisitsByResourceRequestEvent): Promise<void> { const visits = await this.visitRepo.find({ where: { resource_id: event.resourceId, is_deleted: false }, });
this.eventEmitter.emit( 'visits.by-resource.response', new VisitsByResourceResponseEvent(event.requestId, visits), ); }}Step 4: Refactor ResourcesService (NO forwardRef)
Section titled βStep 4: Refactor ResourcesService (NO forwardRef)β@Injectable()export class ResourcesService { constructor( @InjectRepository(Resource, AppDatabases.APP_CORE) private readonly resourceRepo: Repository<Resource>, private readonly eventEmitter: EventEmitter2, private readonly eventRequest: EventRequestService, // β
NO forwardRef(() => VisitsService) needed! ) {}
@OnEvent('resource.validate.request') async handleResourceValidateRequest(event: ResourceValidateRequestEvent): Promise<void> { try { const resource = await this.resourceRepo.findOne({ where: { id: event.resourceId, is_deleted: false }, });
this.eventEmitter.emit( 'resource.validate.response', new ResourceValidateResponseEvent(event.requestId, !!resource, resource), ); } catch (error) { this.eventEmitter.emit( 'resource.validate.response', new ResourceValidateResponseEvent(event.requestId, false, null, error.message), ); } }
async getResourceWithVisits(resourceId: string) { const resource = await this.resourceRepo.findOne({ where: { id: resourceId, is_deleted: false }, });
if (!resource) { throw new NotFoundException(`Resource ${resourceId} not found`); }
// β
Use Event instead of calling visitsService.findByResourceId() directly const visitsResponse = await this.eventRequest.emitAndWait< VisitsByResourceRequestEvent, VisitsByResourceResponseEvent >('visits.by-resource.request', 'visits.by-resource.response', { resourceId });
return { ...resource, visits: visitsResponse.visits }; }}Step 5: Update Modules (NO forwardRef)
Section titled βStep 5: Update Modules (NO forwardRef)β@Module({ imports: [ CommonModule, // β
Provides EventEmitterModule and EventRequestService TypeOrmModule.forFeature([Visit], AppDatabases.APP_CORE), // β
NO forwardRef(() => ResourceModule) needed! ], providers: [VisitsService], exports: [VisitsService],})export class VisitModule {}
// resource.module.ts@Module({ imports: [ CommonModule, // β
Provides EventEmitterModule and EventRequestService TypeOrmModule.forFeature([Resource], AppDatabases.APP_CORE), // β
NO forwardRef(() => VisitModule) needed! ], providers: [ResourcesService], exports: [ResourcesService],})export class ResourceModule {}Simplified Version (Fire-and-Forget Pattern)
Section titled βSimplified Version (Fire-and-Forget Pattern)βIf you donβt need to wait for a response:
@Injectable()export class VisitsService { constructor( @InjectRepository(Visit) private readonly visitRepo: Repository<Visit>, private readonly eventEmitter: EventEmitter2, ) {}
async createVisit(data: CreateVisitDTO): Promise<Visit> { const visit = await this.visitRepo.save(this.visitRepo.create(data));
// β
Fire-and-forget: Notify other modules asynchronously this.eventEmitter.emit('visit.created', { visitId: visit.id, resourceId: visit.resource_id, });
return visit; }}
// resources.service.ts β listen for side effects@Injectable()export class ResourcesService { @OnEvent('visit.created') async handleVisitCreated(event: { visitId: string; resourceId: string }): Promise<void> { // Update resource's last activity date await this.resourceRepo.update(event.resourceId, { last_activity_at: new Date(), }); }}Comparison: forwardRef vs EventEmitter
Section titled βComparison: forwardRef vs EventEmitterβ| Aspect | forwardRef | EventEmitter |
|---|---|---|
| Microservice Compatibility | β Breaks immediately | β Works perfectly |
| Coupling | High (tight coupling) | Low (loose coupling) |
| Testing | Difficult (must mock many services) | Easy (mock EventEmitter only) |
| Debugging | Hard (circular dependencies obscure flow) | Easy (trace event flow) |
| Sync/Async | Synchronous | Asynchronous (requires handling) |
| Transaction Support | Easy (direct calls) | Requires careful design |
Best Practices
Section titled βBest Practicesβ1. Golden Rule: Never Use forwardRef in Services Called via Microservice
Section titled β1. Golden Rule: Never Use forwardRef in Services Called via Microserviceβ// β DON'T DO THIS β Will break when called via Microservice@Injectable()export class VisitsService { constructor( @Inject(forwardRef(() => PatientsService)) private readonly patientsService: PatientsService, // undefined in microservice context! ) {}}
// β
DO THIS INSTEAD β No forwardRef, inject only what you need@Injectable()export class VisitsService { constructor( @InjectRepository(Visit, AppDatabases.APP_CORE) private readonly visitRepo: Repository<Visit>, // Use EventEmitter or Facade Pattern for cross-module communication ) {}}2. Separate Services by Responsibility
Section titled β2. Separate Services by Responsibilityββ
GOOD β Single Responsibility Principle:βββ VisitsService (Pure CRUD for Visit)βββ AssessmentsService (Pure CRUD for Assessment)βββ ResourcesService (Pure CRUD for Resource)βββ VisitOperationsService (Facade for complex workflows within module)βββ EventRequestService (Cross-module communication)
β BAD β God Service Anti-pattern:βββ VisitsService (Does everything + filled with forwardRef)3. Choose Solution Based on Use Case
Section titled β3. Choose Solution Based on Use Caseβ| Situation | Solution |
|---|---|
| Within Same Module + Complex workflow + Needs Transaction | Facade Pattern |
| Cross-Module (different modules) | EventEmitter |
| Fire-and-forget, donβt need to wait for result | EventEmitter (async) |
| Cross-service communication (different microservices) | Message Queue |
| Background jobs | Message Queue |
4. Decision Tree
Section titled β4. Decision Treeβ βββββββββββββββββββββββββββββ β Need to call another β β Service? β βββββββββββββββ¬ββββββββββββββ β βββββββββββββββΌββββββββββββββ β Within the same Module? β βββββββββββββββ¬ββββββββββββββ β βββββββββββββββββββββΌββββββββββββββββββββ β YES β β NO βΌ β βΌ βββββββββββββββββββββ β βββββββββββββββββββββββ β Complex workflow? β β β Use EventEmitter β βββββββββββ¬ββββββββββ β βββββββββββββββββββββββ β β βββββββββββΌββββββββββ β β YES β β NO β βΌ β βΌ βββββββββββββ β ββββββββββββ ββ Facade β β β Direct β ββ Pattern β β β Inject β βββββββββββββ β ββββββββββββ β β (No forwardRef β β if no circular) β βββββββββββββββββββββ5. Debug Checklist
Section titled β5. Debug ChecklistβWhen you encounter Repository/Service undefined errors in Microservice context:
- Check for
forwardRefin Service constructors - If
forwardRefwithin same Module β Use Facade Pattern - If
forwardRefacross Modules β Use EventEmitter - Remove
forwardReffrom both Service and Module level - Verify no circular imports between Modules
- Ensure proper module imports in app.module.ts
- Check TypeORM feature imports match correct database
Summary
Section titled βSummaryβRoot Cause
Section titled βRoot Causeβ1. Service has forwardRef in constructor β2. Microservice TCP message arrives before dependencies fully resolve β3. NestJS sends partially initialized instance β4. All properties in instance = undefinedQuick Reference
Section titled βQuick Referenceβ| Scenario | Solution | Example |
|---|---|---|
| Within Module + Transaction | Facade Pattern | VisitsService + AssessmentsService β VisitOperationsService |
| Cross-Module | EventEmitter | VisitModule β ResourceModule |
| Cross-Microservice | Message Queue | data-owner-bc β data-consumer-bc |
| Fire-and-forget | EventEmitter | Audit logs, Notifications |