Skip to content

Latest commit

 

History

History
607 lines (490 loc) · 15.7 KB

CIRCULAR_DEPENDENCIES.md

File metadata and controls

607 lines (490 loc) · 15.7 KB

Circular Dependency Resolution - Implementation Guide

🎯 Objective

Resolve circular dependencies in the Stellar Earn backend by implementing event-driven architecture to decouple modules.

📋 Table of Contents

  1. Problem Statement
  2. Solution Overview
  3. Implementation Details
  4. Files Changed
  5. How to Verify
  6. Architecture Improvements
  7. Future Enhancements

🔴 Problem Statement

Circular Dependency Detected

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

✅ Solution Overview

Event-Driven Architecture

Replace direct module imports with event-based communication:

UsersModule                    JobsModule
    ↓                              ↑
DataExportService          DataExportListener
    ↓                              ↑
EventEmitter.emit() ──────→ @OnEvent()
    ↓                              ↓
'user.data-export.requested'  JobsService.addJob()

Key Benefits

  1. No Circular Dependencies - Modules don't import each other
  2. Loose Coupling - Modules communicate via events
  3. Scalability - Easy to add new listeners
  4. Testability - Can mock event emitters
  5. Maintainability - Clear separation of concerns

🔧 Implementation Details

Step 1: Create Event DTOs

Created three new event DTOs for data export flow:

1. DataExportRequestedEvent

// 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

2. DataExportCompletedEvent

// 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

3. DataExportFailedEvent

// 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

Step 2: Create Event Listener

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

Step 3: Update DataExportService

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
  );
}

Step 4: Update Module Imports

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
  ],
  ...
})

Step 5: Update DataExportProcessor

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(...)
);

Step 6: Register Event Handlers

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 {}

📁 Files Changed

New Files (5)

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

Modified Files (5)

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.

✅ How to Verify

Quick Verification

# 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

Detailed Verification

  1. Check for Circular Dependencies

    npm run build
    # Should complete without circular dependency errors
  2. Run the Circular Dependency Check Script

    npx ts-node scripts/check-circular-deps.ts
  3. Start the Application

    npm run start:dev
    # Watch for any circular dependency errors in console
  4. 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"

Expected Results

✅ Build completes without errors
✅ No "Circular dependency" warnings
✅ All modules load successfully
✅ Application starts without errors
✅ Data export flow works via events

🏗️ Architecture Improvements

Before: Tight Coupling

┌─────────────┐
│ UsersModule │
│             │
│  ┌──────────┴──────┐
│  │ DataExportService│
│  │                  │
│  │  jobsService.   │
│  │  addJob()       │
│  └──────────┬──────┘
│             │
└─────────────┘
      │
      ↓ (imports)
┌─────────────┐
│ JobsModule  │
│             │
│  ┌──────────┴──────┐
│  │DataExportProcessor│
│  │                  │
│  │  uses DataExport │
│  │  entity          │
│  └──────────┬──────┘
│             │
└─────────────┘
      │
      ↑ (imports entity)
   CIRCULAR!

After: Loose Coupling

┌─────────────┐
│ UsersModule │
│             │
│  ┌──────────┴──────┐
│  │ DataExportService│
│  │                  │
│  │  eventEmitter.  │
│  │  emit()         │
│  └──────────┬──────┘
│             │
└─────────────┘
      │
      ↓ (emits event)
┌─────────────────────┐
│   EventsModule      │
│   (Global)          │
└─────────────────────┘
      │
      ↓ (delivers event)
┌─────────────┐
│ JobsModule  │
│             │
│  ┌──────────┴──────┐
│  │DataExportListener│
│  │                  │
│  │  @OnEvent()     │
│  │  jobsService.   │
│  │  addJob()       │
│  └──────────┬──────┘
│             │
└─────────────┘
   NO CIRCULAR!

🚀 Future Enhancements

1. Email Notifications

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
    );
  }
}

2. Webhook Notifications

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
    );
  }
}

3. Event Replay

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);
  }
}

4. Event Metrics

Add monitoring for event processing:

@Injectable()
export class EventMetricsListener {
  @OnEvent('**')
  async trackEvent(event: any) {
    await this.metricsService.incrementCounter('events.processed', {
      eventName: event.constructor.name
    });
  }
}

5. Event Versioning

Support multiple event versions:

@OnEvent('user.data-export.requested.v2')
async handleExportRequestedV2(event: DataExportRequestedEventV2) {
  // Handle new version with additional fields
}

📚 Additional Resources

🤝 Contributing

When adding new features that might create circular dependencies:

  1. Check for existing events - Use existing events if possible
  2. Create new events - If needed, follow the event DTO pattern
  3. Use event emitters - Prefer events over direct service calls
  4. Document events - Add event documentation to this file
  5. Test event flow - Verify events are emitted and received
  6. Run verification - Use check-circular-deps.ts script

📝 Notes

  • 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

✨ Summary

This implementation successfully resolves circular dependencies by:

  1. ✅ Removing direct module imports
  2. ✅ Implementing event-driven communication
  3. ✅ Maintaining all existing functionality
  4. ✅ Improving code maintainability
  5. ✅ Enabling future extensibility

Result: Clean, maintainable, and scalable architecture with no circular dependencies.


📋 Module Dependency Audit

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

🔀 Event Definitions

user.data-export.requested

Emitted when a user requests a data export.

Field Type
userId string
exportId string
exportType string
format string

Emitter: DataExportService (UsersModule) Listener: DataExportListener (JobsModule)

user.data-export.completed

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.

user.data-export.failed

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.

📡 Event Flow (After)

User Request → UsersController → DataExportService → EventEmitter.emit('user.data-export.requested')
                                                    ↓
                                              DataExportListener (in JobsModule) → JobsService.addJob()
                                                    ↓
                                              DataExportProcessor → EventEmitter.emit('user.data-export.completed')

🗺️ Module Dependency Graph (After Fix)

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.