Skip to content

Integration Events Publisher/Consumer Pattern

Status

Active

Context

The notifications module was importing UserRegisteredEvent and ClientRegisteredEvent directly from identity/domain/events/. Domain events are internal to a bounded context. This cross-module coupling:

  • Prevents identity from ever being extracted into a microservice without also modifying notifications.
  • Violates the module self-containment principle established in ADR 004.
  • Places cross-module transport logic inside the domain layer, which should contain only business rules.

Additionally, the event handlers lived under notifications/application/events/, a conceptually incorrect location — they were reacting to another module's internals, not executing application logic internal to notifications.

Decision

Introduce a three-layer decoupling:

  1. Shared integration events — plain data contracts in shared/integration-events/identity/. These are the bounded-context boundary: identity publishes facts, notifications subscribes to them. Neither module owns these contracts; they live in shared/ and can be extracted to a workspace package when needed.

  2. IdentityPublisher — a service in identity/presentation/publishers/. The presentation layer is the right place for cross-context publishing: it faces outward, the application layer faces inward. The publisher wraps EventBus and exposes typed publishXxx() methods. Callers (RegisterUserHandler, RegisterClientHandler) inject IdentityPublisher instead of EventBus directly.

  3. NotificationsConsumers@EventsHandler classes in notifications/presentation/consumers/. The presentation layer of notifications is the right place for inbound integration: it faces outward and translates external facts into internal commands (SendEmailNotificationCommand). Template selection stays private to notifications.

  4. Base observability wrappers — shared tracing and metadata plumbing for this boundary is implemented in shared/infrastructure/observability/ (BasePublisher and BaseConsumer), then extended by concrete identity publisher and notifications consumers. This keeps the contract behavior identical while removing duplicated span/metadata code.

Consequences

Positive

  • No cross-module imports between identity and notifications.
  • identity can be extracted as a microservice by swapping EventBus.publish() with a Kafka emit in IdentityPublisher — no other identity code changes.
  • notifications can be converted to a Kafka consumer by replacing @EventsHandler with a Kafka listener decorator — no internal notification logic changes.
  • Template keys remain private to notifications; identity only publishes facts.
  • fire-and-forget semantics preserved: publishXxx() is void, EventBus.publish() is synchronous dispatch.

Neutral

  • shared/integration-events/ is a new shared boundary layer. It must be kept minimal — only plain data objects, no framework decorators.

Negative

  • One additional indirection layer (IdentityPublisher) vs. calling EventBus directly in the command handler.

File Map

RolePath
Integration event contractsshared/integration-events/identity/
Publishermodules/identity/presentation/publishers/identity.publisher.ts
Consumersmodules/notifications/presentation/consumers/

Deleted Files

PathReason
modules/identity/domain/events/user-registered.event.tsReplaced by UserRegisteredIntegrationEvent in shared/
modules/identity/domain/events/client-registered.event.tsReplaced by ClientRegisteredIntegrationEvent in shared/
modules/notifications/application/events/user-registered-notification.handler.tsMoved to presentation/consumers/
modules/notifications/application/events/client-registered-notification.handler.tsMoved to presentation/consumers/
modules/notifications/application/events/index.tsFolder emptied

Path to Microservices

Only these two files need to change per extraction:

  • IdentityPublisher.publishXxx() → emit to Kafka topic instead of EventBus.publish()
  • UserRegisteredConsumer / ClientRegisteredConsumer → listen to Kafka topic instead of @EventsHandler

All internal module logic, domain entities, use cases, and the shared contract classes remain unchanged.