Skip to content

Module Architecture (Clean Architecture)

Each module follows this structure:

module/
├── domain/                # Business logic (no framework deps)
│   ├── entities/         # Domain entities
│   ├── value-objects/    # Immutable value objects
│   ├── services/         # Domain services
│   ├── events/           # Domain events
│   └── interfaces/       # Port definitions (repositories, services)

├── application/          # Use cases
│   ├── commands/         # Write operations (CQRS)
│   ├── queries/          # Read operations
│   ├── dtos/             # Data transfer objects
│   └── handlers/         # Event handlers

├── infrastructure/       # Adapters
│   ├── repositories/     # Database implementations
│   ├── services/         # External service implementations
│   └── mappers/          # Entity <-> DTO mappers

└── presentation/         # API layer
    ├── controllers/      # HTTP controllers
    ├── guards/           # Auth guards
    └── validators/       # Request validators

Layer Responsibilities

LayerResponsibilityDependencies
DomainBusiness rules, entities, interfacesNone (pure)
ApplicationUse case orchestration, DTOsDomain
InfrastructureExternal systems, DB, APIsDomain, Application
PresentationHTTP handling, validationApplication

Dependency Rule

Dependencies always point inward:

  • Presentation → Application → Domain
  • Infrastructure → Domain (implements interfaces)

Never:

  • Domain → Infrastructure
  • Domain → Presentation

Inter-Module Communication

Within the modular monolith, modules need to communicate. Two patterns are used depending on the coupling requirements:

Pattern 1: Domain Events (Preferred for Cross-Module)

Use when: Module A's action should trigger Module B's reaction, but A shouldn't know about B.

┌─────────────┐     publishes      ┌──────────────┐     handles      ┌─────────────┐
│   Order     │ ──────────────────→│  EventBus    │──────────────────→│    MLM      │
│   Module    │   OrderCompleted   │  (in-memory) │   OrderCompleted │   Module    │
└─────────────┘                    └──────────────┘                   └─────────────┘

Implementation:

typescript
// Domain event definition (in order module)
export class OrderCompletedEvent {
  constructor(
    public readonly orderId: string,
    public readonly userId: string,
    public readonly totalAmount: number,
    public readonly referringPartnerId: string | null,
  ) {}
}

// Publishing (in order module's application layer)
@Injectable()
export class CompleteOrderCommand {
  constructor(private readonly eventBus: EventBus) {}

  async execute(orderId: string): Promise<void> {
    // ... complete order logic ...

    // Publish event - order module doesn't know who listens
    this.eventBus.publish(new OrderCompletedEvent(
      order.id,
      order.userId,
      order.total,
      order.referringPartnerId,
    ));
  }
}

// Handling (in mlm module's application layer)
@Injectable()
export class OrderCompletedHandler {
  constructor(private readonly commissionService: CommissionCalculationService) {}

  @OnEvent(OrderCompletedEvent)
  async handle(event: OrderCompletedEvent): Promise<void> {
    if (event.referringPartnerId) {
      await this.commissionService.calculateForOrder(
        event.orderId,
        event.totalAmount,
        event.referringPartnerId,
      );
    }
  }
}

When to use:

  • Order completion → Commission calculation
  • Payment success → Order confirmation
  • Partner registration → Welcome notification
  • Rank achieved → Bonus payout trigger

Pattern 2: Direct Service Injection (For Tight Integration)

Use when: Module A needs data from Module B synchronously, and coupling is acceptable.

typescript
// Interface defined in the CONSUMING module (mlm)
// to avoid circular dependency
export interface IUserLookupService {
  getUserById(userId: string): Promise<UserBasicInfo | null>;
  getUserKycStatus(userId: string): Promise<KycStatus>;
}

// Implementation in the PROVIDING module (users)
@Injectable()
export class UserLookupService implements IUserLookupService {
  constructor(private readonly userRepository: UserRepository) {}

  async getUserById(userId: string): Promise<UserBasicInfo | null> {
    const user = await this.userRepository.findById(userId);
    return user ? { id: user.id, email: user.email, name: user.name } : null;
  }
}

// Registration in users module
@Module({
  providers: [
    {
      provide: 'IUserLookupService',
      useClass: UserLookupService,
    },
  ],
  exports: ['IUserLookupService'],
})
export class UsersModule {}

// Usage in mlm module
@Injectable()
export class PartnerService {
  constructor(
    @Inject('IUserLookupService')
    private readonly userLookup: IUserLookupService,
  ) {}

  async getPartnerWithUser(partnerId: string) {
    const partner = await this.partnerRepository.findById(partnerId);
    const user = await this.userLookup.getUserById(partner.userId);
    return { ...partner, user };
  }
}

When to use:

  • MLM module needs user profile data
  • Product module needs partner referral info
  • Investment module needs KYC status check

Communication Matrix

From ModuleTo ModulePatternEvent/Interface
OrderMLMEventOrderCompletedEvent
OrderNotificationEventOrderStatusChangedEvent
PaymentOrderEventPaymentSucceededEvent
MLMUsersDirectIUserLookupService
ProductMLMDirectIPartnerLookupService
InvestmentUsersDirectIKycStatusService
MLMNotificationEventCommissionPaidEvent
MLMNotificationEventRankAchievedEvent

Rules for Inter-Module Communication

  1. Events for side effects — When module A's action should trigger reactions in other modules
  2. Direct injection for data — When module A needs to read data owned by module B
  3. Never import entities — Only share DTOs or interfaces across module boundaries
  4. Define interfaces in consumer — Prevents circular dependencies
  5. Keep events immutable — Events are facts that happened, never modify them