Appearance
Cross-Module Integration Events
This document defines the contract for cross-module communication via integration events in apps/api.
It is the authoritative reference for hard-fail rule #4 in API Review Guidelines:
Cross-feature communication must go through shared integration events. Do not directly couple feature modules through repositories, services, or internal implementation classes.
Why integration events?
DaraMex uses a modular monolith architecture. Each module owns its own schema, entities, and repositories. Direct service injection across module boundaries creates tight coupling, schema bleed-through, and makes future module extraction impossible.
Integration events decouple modules: the producer publishes a plain class to the EventBus; one or more consumers handle it independently.
Directory layout
All integration event classes live in:
apps/api/src/shared/integration-events/
├── integration-event-metadata.ts # IIntegrationEventMetadata interface
├── index.ts # re-exports all sub-directories
├── identity/ # events from the identity module
├── connections/ # events from the connections module
│ ├── meta-connection-established.integration-event.ts
│ ├── meta-connection-revoked.integration-event.ts
│ ├── meta-token-invalidated.integration-event.ts
│ ├── meta-token-reconnected.integration-event.ts
│ └── index.ts
└── communications/ # events from the communications module
├── incoming-meta-message-received.integration-event.ts
└── index.tsRule: One sub-directory per producing module. Events are named after the producer, not the consumer.
Event class contract
Every integration event is a plain TypeScript class with public readonly fields. No decorators, no inheritance, no base class.
ts
// ✅ Correct pattern
import type { IIntegrationEventMetadata } from '../integration-event-metadata';
export class SomeThingHappenedIntegrationEvent {
constructor(
public readonly relevantId: string,
public readonly orgId: string,
// ... domain-specific fields
public readonly metadata: IIntegrationEventMetadata | null = null,
) {}
}Rules:
- Class name ends in
IntegrationEvent - File name matches class name in
kebab-casewith.integration-event.tssuffix - All fields are
public readonly metadatais always the last parameter, optional, defaults tonull- Use primitive types and plain objects only — no domain entity classes
IIntegrationEventMetadatacarries trace context for distributed tracing
Publishing events
Publishers live in the infrastructure layer of the producing module:
connections/infrastructure/publishers/meta-connection.publisher.ts
communications/infrastructure/publishers/incoming-meta-message.publisher.tsPublishers inject EventBus from @nestjs/cqrs and call eventBus.publish(new SomeEvent(...)).
Hard rule: Publishers are called from command handlers, NOT from domain entities or repositories.
Consuming events
Consumers live in the infrastructure/consumers/ directory of the consuming module:
communications/infrastructure/consumers/meta-connection-established.consumer.ts
communications/infrastructure/consumers/meta-token-invalidated.consumer.tsA consumer is a NestJS @EventsHandler(SomeEvent) class that dispatches a CQRS command or performs a single, idempotent operation.
ts
@EventsHandler(MetaConnectionEstablishedIntegrationEvent)
export class MetaConnectionEstablishedConsumer
implements IEventHandler<MetaConnectionEstablishedIntegrationEvent>
{
constructor(private readonly commandBus: CommandBus) {}
async handle(event: MetaConnectionEstablishedIntegrationEvent): Promise<void> {
await this.commandBus.execute(new CreateChannelAccountsCommand(event));
}
}Hard rule: Consumers must be registered in the consuming module's providers array. They MUST NOT be registered in the producing module.
What belongs here vs. in a module
| Concern | Where |
|---|---|
| Event class definition | shared/integration-events/{producer}/ |
| Publisher (calls EventBus.publish) | {producer-module}/infrastructure/publishers/ |
| Consumer (handles event) | {consumer-module}/infrastructure/consumers/ |
| Command dispatched from consumer | {consumer-module}/application/commands/ |
Current integration events inventory
From connections module
| Event | Trigger | Primary consumer |
|---|---|---|
MetaConnectionEstablishedIntegrationEvent | Embedded Signup OAuth completes successfully | communications — creates ChannelAccount rows |
MetaConnectionRevokedIntegrationEvent | app_deauthorized webhook or admin action | communications — marks ChannelAccounts error |
MetaTokenInvalidatedIntegrationEvent | Graph API error 190 on outbound send | communications — marks ChannelAccounts error |
MetaTokenReconnectedIntegrationEvent | Reconnect flow completes | communications — marks ChannelAccounts active |
From communications module
| Event | Trigger | Primary consumer |
|---|---|---|
IncomingMetaMessageReceivedIntegrationEvent | Inbound message persisted | ai/ module (future) |
Anti-patterns (hard-fail in PR review)
ts
// ❌ Direct service injection across module boundaries
@Injectable()
export class CommunicationsService {
constructor(
// NEVER inject a repository or service from another module
private readonly connectionRepository: ConnectionRepository,
) {}
}
// ❌ Calling command bus from inside a domain entity
class Connection extends BaseEntity {
revoke() {
commandBus.execute(new DisconnectChannelAccountsCommand(...)); // ❌
}
}
// ✅ Correct: publish an event from a command handler, let consumers react
@CommandHandler(RevokeConnectionCommand)
export class RevokeConnectionHandler {
async execute(command: RevokeConnectionCommand): Promise<void> {
// ... update Connection state ...
await this.eventBus.publish(new MetaConnectionRevokedIntegrationEvent(...));
}
}