Skip to content

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.ts

Rule: 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-case with .integration-event.ts suffix
  • All fields are public readonly
  • metadata is always the last parameter, optional, defaults to null
  • Use primitive types and plain objects only — no domain entity classes
  • IIntegrationEventMetadata carries 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.ts

Publishers 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.ts

A 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

ConcernWhere
Event class definitionshared/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

EventTriggerPrimary consumer
MetaConnectionEstablishedIntegrationEventEmbedded Signup OAuth completes successfullycommunications — creates ChannelAccount rows
MetaConnectionRevokedIntegrationEventapp_deauthorized webhook or admin actioncommunications — marks ChannelAccounts error
MetaTokenInvalidatedIntegrationEventGraph API error 190 on outbound sendcommunications — marks ChannelAccounts error
MetaTokenReconnectedIntegrationEventReconnect flow completescommunications — marks ChannelAccounts active

From communications module

EventTriggerPrimary consumer
IncomingMetaMessageReceivedIntegrationEventInbound message persistedai/ 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(...));
  }
}