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 validatorsLayer Responsibilities
| Layer | Responsibility | Dependencies |
|---|---|---|
| Domain | Business rules, entities, interfaces | None (pure) |
| Application | Use case orchestration, DTOs | Domain |
| Infrastructure | External systems, DB, APIs | Domain, Application |
| Presentation | HTTP handling, validation | Application |
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 Module | To Module | Pattern | Event/Interface |
|---|---|---|---|
| Order | MLM | Event | OrderCompletedEvent |
| Order | Notification | Event | OrderStatusChangedEvent |
| Payment | Order | Event | PaymentSucceededEvent |
| MLM | Users | Direct | IUserLookupService |
| Product | MLM | Direct | IPartnerLookupService |
| Investment | Users | Direct | IKycStatusService |
| MLM | Notification | Event | CommissionPaidEvent |
| MLM | Notification | Event | RankAchievedEvent |
Rules for Inter-Module Communication
- Events for side effects — When module A's action should trigger reactions in other modules
- Direct injection for data — When module A needs to read data owned by module B
- Never import entities — Only share DTOs or interfaces across module boundaries
- Define interfaces in consumer — Prevents circular dependencies
- Keep events immutable — Events are facts that happened, never modify them