Skip to main content

Domain Events

This page documents the current event architecture in the API, following the same style as the rest of the module docs: module-owned events, application-level publishing, adapter subscribers, and composition-time wiring.

Purpose

The events architecture:

  • keeps event ownership in each module domain (domain/events)
  • publishes from use-cases through an application port (DomainEventPublisherPort)
  • wires concrete subscribers in module composition
  • uses shared in-process event bus primitives from shared/kernel/event-bus.ts
  • allows infrastructure side effects (logging, SQS) without coupling use-cases to infra SDKs

Where events are defined

Current users module events are defined in:

  • apps/api/src/modules/users/domain/events/user-events.ts

Event union: UsersDomainEvent

Current event types:

  • users.user-registered
  • users.user-profile-updated
  • users.user-interests-set
  • users.user-soft-deleted
  • users.user-interests-read

Each event has:

  • type: discriminated string event name
  • occurredAt: ISO timestamp
  • payload: typed business data

Where events are published

Publishing is done in application use-cases via DomainEventPublisherPort (apps/api/src/modules/users/application/ports/domain-event-publisher.port.ts).

Current publishers:

  • register-user-by-identity-provider-id.ts -> publishes users.user-registered
  • update-profile.ts -> publishes pulled domain events from user.entity
  • set-user-interests.ts -> publishes pulled domain events from user.entity
  • soft-delete-by-user-id.ts -> publishes pulled domain events when soft delete succeeds
  • get-user-interests.ts -> publishes users.user-interests-read when data is found

createNoopDomainEventPublisher() is used as default dependency for use-cases, so use-cases stay runnable in tests without wiring full subscribers.

Composition and wiring

Wiring is centralized in apps/api/src/modules/users/composition.ts.

Composition creates:

  1. shared log handler (createLogDomainEventHandler)
  2. welcome-email subscriber (createPublishWelcomeEmailOnUserRegisteredSubscriber)
  3. typed event bus publisher (createEventBusPublisher<UsersDomainEvent>)
  4. handlersByType mapping for each event type
  5. use-cases injected with domainEventPublisher

This keeps use-cases unaware of SQS/logging details; they only call publish(event).

Event bus behavior

Shared implementation: apps/api/src/shared/kernel/event-bus.ts

createEventBusPublisher({ handlersByType }) behavior:

  • selects handlers by event.type
  • falls back to empty handler list when none are registered
  • executes handlers in parallel with Promise.all

Because handlers run in parallel, subscribers should be idempotent and safe for concurrent execution.

Current subscribers

1) Logging subscriber

  • factory: apps/api/src/shared/kernel/log-domain-event.handler.ts
  • wired for all current users events in users composition
  • logs { event, scope } via shared logger

2) Welcome email SQS subscriber

  • implementation: apps/api/src/modules/users/adapters/events/subscribers/publish-welcome-email-on-user-registered.subscriber.ts
  • listens only to users.user-registered
  • sends SQS message only when event.payload.isNewUser === true
  • sends message with:
    • kind: "welcome-email-request"
    • version: 1
    • payload: { userId, identityProviderUserId }
    • meta: { source, occurredAt }
  • wraps SQS errors with createSqsOperationError(...)

Runtime flow

  1. HTTP route/handler calls a use-case.
  2. Use-case performs domain + repository operations.
  3. Use-case publishes one or more domain events.
  4. Event bus routes by event.type.
  5. Subscribers run (log, SQS, etc.).

Adding a new event (current style)

  1. Add typed event in module domain/events/* and include it in module event union.
  2. Publish from the correct use-case (or from domain entity -> pullEvents() consumed by use-case).
  3. Implement subscribers in adapters (adapters/events/subscribers/*) when side effects are needed.
  4. Wire handlers in composition.ts under handlersByType.
  5. Keep handlers idempotent and failure-aware (wrap infra errors with shared kernel error helpers).