Home/Architecture/In-process event bus
Infrastructure

In-process event bus

Domain events decouple what happened from what happens next. The interface hides the delivery mechanism.

Context

After a user registers, a welcome email should eventually be sent. After login, an audit log entry should be written. These are side effects that shouldn't be the core operation's responsibility. Putting them directly in the handler couples unrelated concerns and makes the handler harder to test.

The decision

Domain events are dispatched via an `IEventBus` interface backed by an `EventBus` that persists every event to Postgres, then publishes to QStash for async handler execution. Events follow two ownership patterns: aggregate-raised events for state changes the aggregate owns, and handler-dispatched events for use-case-level facts that no single aggregate owns.

Rationale

  • 1

    Aggregate-raised events describe the aggregate's own state change (`user.addDomainEvent(new UserRegisteredEvent(...))`). The handler pulls them after persistence and dispatches them via the bus.

  • 2

    Handler-dispatched events describe use-case facts (login failures, logouts, account deletions) where no single aggregate owns the action. The handler dispatches directly via `eventBus.dispatch()`.

  • 3

    Events are pulled or dispatched after the operation succeeds, not before. This avoids dispatching events for operations that fail to persist.

  • 4

    The `IEventBus` interface in the domain layer means the domain has zero knowledge of how events are delivered. In tests, pass a mock or no-op implementation.

  • 5

    Handler registration (`eventBus.register(IdentityEvents.USER_REGISTERED, handler)`) is additive. New side effects don't touch existing code.

In the codebase

IEventBus interface. Defined in domain, no infrastructure dependency

type EventHandler<T extends DomainEvent = DomainEvent> = (event: T) => Promise<void>;

interface IEventBus {
  register<T extends DomainEvent>(eventType: string, handler: EventHandler<T>): void;
  dispatch(events: DomainEvent[]): Promise<void>;
}

Aggregate-raised event. The aggregate owns the state change

class User extends AggregateRoot {
  static register(id: UserId, email: Email, passwordHash: Password): User {
    const tier = UserTier.from(USER_TIERS.TRIAL);
    const user = new User(id, email, passwordHash, tier);
    user.addDomainEvent(new UserRegisteredEvent(id.value, email.value));
    return user;
  }
}

// Handler pulls after persistence
await this.userRepository.save(user);
const events = user.pullDomainEvents();
await this.eventBus.dispatch(events);

Aggregate-raised event. User owns the login event

// LoginUserHandler. Aggregate-raised via User.loggedIn()
user.loggedIn();
const events = user.pullDomainEvents();
await this.eventBus.dispatch(events);

Handler-dispatched event. No aggregate owns the action

// LogoutUserHandler. Handler-dispatched, no aggregate owns logout
await this.eventBus.dispatch([
  new UserLoggedOutEvent(userId),
]);

Tradeoffs

Handlers are focused. They don't know or care about side effects downstream.

Handler-dispatched events lack the compile-time traceability of aggregate-raised events. A developer has to read the handler to know which events it emits.

The IEventBus interface means swapping the delivery mechanism is an infrastructure change, not a domain change.

QStash adds an external dependency for async dispatch. If QStash is down, events are persisted but handler execution is delayed until the publish succeeds or is retried.