Resolve circular dependencies in the Stellar Earn backend by implementing event-driven architecture to decouple modules.
- Problem Statement
- Solution Overview
- Implementation Details
- Files Changed
- How to Verify
- Architecture Improvements
- Future Enhancements
UsersModule ↔ JobsModule had a circular dependency:
UsersModule
├── imports JobsModule (to use JobsService)
└── exports DataExport entity
↑
└── imported by JobsModule (in DataExportProcessor)
This created a circular dependency that could cause:
- Module initialization failures
- Dependency injection errors
- Unpredictable behavior
- Difficult debugging
Replace direct module imports with event-based communication:
UsersModule JobsModule
↓ ↑
DataExportService DataExportListener
↓ ↑
EventEmitter.emit() ──────→ @OnEvent()
↓ ↓
'user.data-export.requested' JobsService.addJob()
- No Circular Dependencies - Modules don't import each other
- Loose Coupling - Modules communicate via events
- Scalability - Easy to add new listeners
- Testability - Can mock event emitters
- Maintainability - Clear separation of concerns
Created three new event DTOs for data export flow:
// src/events/dto/data-export-requested.event.ts
export class DataExportRequestedEvent extends BaseEvent {
constructor(
public readonly userId: string,
public readonly exportId: string,
public readonly exportType: string,
public readonly format: string,
) {
super();
}
}Purpose: Emitted when user requests a data export
// src/events/dto/data-export-completed.event.ts
export class DataExportCompletedEvent extends BaseEvent {
constructor(
public readonly userId: string,
public readonly exportId: string,
public readonly downloadUrl: string,
public readonly fileName: string,
public readonly recordCount: number,
) {
super();
}
}Purpose: Emitted when data export completes successfully
// src/events/dto/data-export-failed.event.ts
export class DataExportFailedEvent extends BaseEvent {
constructor(
public readonly userId: string,
public readonly exportId: string,
public readonly error: string,
) {
super();
}
}Purpose: Emitted when data export fails
Created a listener in JobsModule to handle export requests:
// src/modules/jobs/listeners/data-export.listener.ts
@Injectable()
export class DataExportListener {
constructor(private readonly jobsService: JobsService) {}
@OnEvent('user.data-export.requested', { async: true })
async handleDataExportRequested(event: DataExportRequestedEvent) {
await this.jobsService.addJob(QUEUES.EXPORTS, {
organizationId: null,
exportType: event.exportType,
format: event.format,
userId: event.userId,
exportId: event.exportId,
});
}
}Purpose: Listens for export requests and queues jobs
Modified to emit events instead of calling JobsService directly:
// Before
constructor(
private readonly dataExportRepo: Repository<DataExport>,
private readonly jobsService: JobsService, // ❌ Direct dependency
) {}
async requestExport(...) {
await this.jobsService.addJob(...); // ❌ Direct call
}
// After
constructor(
private readonly dataExportRepo: Repository<DataExport>,
private readonly eventEmitter: EventEmitter2, // ✅ Event emitter
) {}
async requestExport(...) {
this.eventEmitter.emit(
'user.data-export.requested',
new DataExportRequestedEvent(...) // ✅ Event emission
);
}Removed circular dependency:
// UsersModule - Before
@Module({
imports: [
TypeOrmModule.forFeature([...]),
CacheModule,
JobsModule, // ❌ Circular dependency
],
...
})
// UsersModule - After
@Module({
imports: [
TypeOrmModule.forFeature([...]),
CacheModule, // ✅ No JobsModule import
],
...
})// JobsModule - After
@Module({
imports: [
TypeOrmModule.forFeature([..., DataExport]), // ✅ Only entity import
],
providers: [
...,
DataExportListener, // ✅ New listener
],
...
})Added event emissions for completion and failure:
// On success
this.eventEmitter.emit(
'user.data-export.completed',
new DataExportCompletedEvent(...)
);
// On failure
this.eventEmitter.emit(
'user.data-export.failed',
new DataExportFailedEvent(...)
);Updated EventsModule to register all handlers:
@Global()
@Module({
imports: [
EventEmitterModule.forRoot({...}),
TypeOrmModule.forFeature([EventStore]),
],
providers: [
EventsService,
EventStoreService,
// Event Handlers
QuestEventsHandler,
SubmissionEventsHandler,
UserEventsHandler,
DeadLetterHandler,
// Event Listeners
EventAuditListener,
EventPersistenceListener,
UserListener,
QuestListener,
SubmissionListener,
PayoutListener,
],
exports: [EventsService, EventStoreService],
})
export class EventsModule {}| File | Purpose |
|---|---|
src/events/dto/data-export-requested.event.ts |
Event DTO for export requests |
src/events/dto/data-export-completed.event.ts |
Event DTO for export completion |
src/events/dto/data-export-failed.event.ts |
Event DTO for export failures |
src/modules/jobs/listeners/data-export.listener.ts |
Listener for export requests |
scripts/check-circular-deps.ts |
Script to verify no circular deps |
| File | Changes |
|---|---|
src/modules/users/data-export.service.ts |
Use EventEmitter2 instead of JobsService |
src/modules/users/users.module.ts |
Removed JobsModule import |
src/modules/jobs/jobs.module.ts |
Added DataExportListener |
src/modules/jobs/processors/export.processor.ts |
Emit completion/failure events |
src/events/events.module.ts |
Registered all event handlers |
This document is the canonical reference for the circular-dependency resolution. The loose root-level docs that previously covered this topic (CIRCULAR_DEPENDENCY_RESOLUTION.md, CIRCULAR_DEPS_SUMMARY.md, CIRCULAR_DEPS_INDEX.md, README_CIRCULAR_DEPS.md, ARCHITECTURE_DIAGRAM.md, IMPLEMENTATION_COMPLETE.md, FINAL_VERIFICATION.md, BUGS_FIXED.md, ANSWER_TO_YOUR_QUESTIONS.md, VERIFICATION_STEPS.md) were consolidated here.
# 1. Build the project
npm run build
# 2. Check for circular dependencies
npx ts-node scripts/check-circular-deps.ts
# 3. Start the application
npm run start:dev-
Check for Circular Dependencies
npm run build # Should complete without circular dependency errors -
Run the Circular Dependency Check Script
npx ts-node scripts/check-circular-deps.ts
-
Start the Application
npm run start:dev # Watch for any circular dependency errors in console -
Test the Data Export Flow
curl -X POST http://localhost:3000/api/users/me/export \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"exportType": "full", "format": "json"}' # Check logs for: # - "Emitted data export request event" # - "[JobsModule] Data export requested" # - "Successfully queued export job"
✅ Build completes without errors
✅ No "Circular dependency" warnings
✅ All modules load successfully
✅ Application starts without errors
✅ Data export flow works via events
┌─────────────┐
│ UsersModule │
│ │
│ ┌──────────┴──────┐
│ │ DataExportService│
│ │ │
│ │ jobsService. │
│ │ addJob() │
│ └──────────┬──────┘
│ │
└─────────────┘
│
↓ (imports)
┌─────────────┐
│ JobsModule │
│ │
│ ┌──────────┴──────┐
│ │DataExportProcessor│
│ │ │
│ │ uses DataExport │
│ │ entity │
│ └──────────┬──────┘
│ │
└─────────────┘
│
↑ (imports entity)
CIRCULAR!
┌─────────────┐
│ UsersModule │
│ │
│ ┌──────────┴──────┐
│ │ DataExportService│
│ │ │
│ │ eventEmitter. │
│ │ emit() │
│ └──────────┬──────┘
│ │
└─────────────┘
│
↓ (emits event)
┌─────────────────────┐
│ EventsModule │
│ (Global) │
└─────────────────────┘
│
↓ (delivers event)
┌─────────────┐
│ JobsModule │
│ │
│ ┌──────────┴──────┐
│ │DataExportListener│
│ │ │
│ │ @OnEvent() │
│ │ jobsService. │
│ │ addJob() │
│ └──────────┬──────┘
│ │
└─────────────┘
NO CIRCULAR!
Add listener for export completion:
@Injectable()
export class ExportNotificationListener {
@OnEvent('user.data-export.completed')
async handleExportCompleted(event: DataExportCompletedEvent) {
await this.emailService.sendExportReadyEmail(
event.userId,
event.downloadUrl
);
}
}Add webhook support for export events:
@Injectable()
export class ExportWebhookListener {
@OnEvent('user.data-export.completed')
async handleExportCompleted(event: DataExportCompletedEvent) {
await this.webhookService.sendWebhook(
'data-export.completed',
event
);
}
}Implement event replay for failed exports:
async replayFailedExports() {
const failedEvents = await this.eventStoreService.getFailedEvents();
for (const event of failedEvents) {
await this.eventEmitter.emitAsync(event.eventName, event.payload);
}
}Add monitoring for event processing:
@Injectable()
export class EventMetricsListener {
@OnEvent('**')
async trackEvent(event: any) {
await this.metricsService.incrementCounter('events.processed', {
eventName: event.constructor.name
});
}
}Support multiple event versions:
@OnEvent('user.data-export.requested.v2')
async handleExportRequestedV2(event: DataExportRequestedEventV2) {
// Handle new version with additional fields
}- NestJS Event Emitter Documentation
- Event-Driven Architecture Patterns
- Circular Dependency Resolution in NestJS
When adding new features that might create circular dependencies:
- Check for existing events - Use existing events if possible
- Create new events - If needed, follow the event DTO pattern
- Use event emitters - Prefer events over direct service calls
- Document events - Add event documentation to this file
- Test event flow - Verify events are emitted and received
- Run verification - Use
check-circular-deps.tsscript
- EventsModule is marked as
@Global()- available everywhere - Event names use dot notation:
module.entity.action - Events are async by default:
{ async: true } - Event emitters are injected via DI
- Event listeners are automatically registered
This implementation successfully resolves circular dependencies by:
- ✅ Removing direct module imports
- ✅ Implementing event-driven communication
- ✅ Maintaining all existing functionality
- ✅ Improving code maintainability
- ✅ Enabling future extensibility
Result: Clean, maintainable, and scalable architecture with no circular dependencies.
| Module A | Module B | Status | Notes |
|---|---|---|---|
| AuthModule | UsersModule | ✅ OK | One-way dependency |
| UsersModule | JobsModule | ✅ FIXED | Now uses events |
| EmailModule | JobsModule | ✅ OK | One-way dependency |
| QuestsModule | ModerationModule | ✅ OK | One-way dependency |
| SubmissionsModule | NotificationsModule | ✅ OK | One-way dependency |
Emitted when a user requests a data export.
| Field | Type |
|---|---|
userId |
string |
exportId |
string |
exportType |
string |
format |
string |
Emitter: DataExportService (UsersModule)
Listener: DataExportListener (JobsModule)
Emitted when a data export completes successfully.
| Field | Type |
|---|---|
userId |
string |
exportId |
string |
downloadUrl |
string |
fileName |
string |
recordCount |
number |
Emitter: DataExportProcessor (JobsModule)
Listener: Can be used by NotificationsModule, EmailModule, etc.
Emitted when a data export fails.
| Field | Type |
|---|---|
userId |
string |
exportId |
string |
error |
string |
Emitter: DataExportProcessor (JobsModule)
Listener: Can be used by NotificationsModule, EmailModule, etc.
User Request → UsersController → DataExportService → EventEmitter.emit('user.data-export.requested')
↓
DataExportListener (in JobsModule) → JobsService.addJob()
↓
DataExportProcessor → EventEmitter.emit('user.data-export.completed')
AppModule
├── EventsModule (Global)
├── AuthModule
│ └── UsersModule
├── UsersModule (no circular deps)
├── JobsModule (no circular deps)
├── EmailModule
│ └── JobsModule
├── QuestsModule
│ └── ModerationModule
├── SubmissionsModule
│ └── NotificationsModule
└── ... other modules
For the full backend documentation index, see INDEX.md.