SaaS Starter

Usar o event bus

Publicar um evento de domínio a partir de um aggregate, inscrever um listener que reage em outro módulo.

O event bus é in-process e em memória. Existe para manter os módulos desacoplados no nível de código — iam não importa notifications; em vez disso, ele levanta user.created e um listener em notifications reage.

Implementação

apps/server/src/infrastructure/events/event-bus.ts:

export interface IEventBus {
  publish(events: ReadonlyArray<DomainEvent>): Promise<void>;
  subscribe(eventName: string, handler: EventHandler): void;
}

export class InMemoryEventBus implements IEventBus { /* ... */ }

Um handler que dá throw é logado e engolido — um listener ruim não faz o aggregate publicador falhar.

1. Levantar um evento a partir de um aggregate

Dentro do método de domínio do aggregate:

this.addDomainEvent({
  name: 'user.created',
  aggregateId: this.id,
  occurredAt: new Date(),
  payload: { email: this.email.value, locale: this.locale },
});

Os eventos se acumulam no aggregate mas ainda não são publicados.

2. Publicar no use case

Depois de persistir o aggregate:

async execute(input: RegisterInput): Promise<Result<User, DomainError>> {
  const userResult = User.create(input);
  if (userResult.isErr()) return userResult;

  await this.users.save(userResult.value);
  await this.bus.publish(userResult.value.pullEvents());

  return userResult;
}

pullEvents() retorna os eventos acumulados e os limpa do aggregate, então re-salvar nunca re-publica.

3. Inscrever um listener

No bootstrap do módulo receptor (tipicamente chamado de bootstrap/container.ts ou de um register-listeners.ts dedicado):

// apps/server/src/modules/notifications/infrastructure/listeners.ts
export const registerNotificationListeners = (deps: {
  bus: IEventBus;
  jobs: JobScheduler;
}) => {
  deps.bus.subscribe('user.created', async (event) => {
    await deps.jobs.enqueue('emails', {
      kind: 'welcome',
      to: { email: event.payload.email },
      recipientName: event.payload.recipientName,
      appName: 'UseDeploy',
      locale: event.payload.locale,
    });
  });
};

O listener faz o trabalho síncrono mínimo — tipicamente: enfileirar um job BullMQ. Trabalho pesado (enviar o email, chamar um terceiro) fica no worker.

4. Tipe os nomes dos eventos

Adicione o nome do evento a uma union compartilhada se quiser que o sistema de tipos force você a se inscrever apenas em eventos existentes:

type ApplicationEvent =
  | { name: 'user.created'; payload: { /* ... */ } }
  | { name: 'subscription.activated'; payload: { /* ... */ } };

Quando usar eventos vs chamada direta

Use um evento quandoUse chamada direta quando
Múltiplos módulos poderiam reagirExatamente um módulo é dono do próximo passo
A reação é best-effort (sem garantia transacional necessária)O próximo passo precisa ter sucesso para a operação ser considerada feita
A reação é async / pode ser adiadaO resultado é necessário para a response HTTP

O bus não é uma message queue. Eventos disparam in-process; num crash do server no meio do publish, eventos in-flight são perdidos. Se você precisa de durabilidade, o listener deve enfileirar um job BullMQ e persistir lá. Para fan-out cross-instance, sobreponha Redis pub/sub — o bus em memória é o default porque a maioria dos listeners apenas enfileiram.

Testing

Injete InMemoryEventBus a partir do código de produção ou construa um double que grava:

const recorded: DomainEvent[] = [];
const bus: IEventBus = {
  publish: async (events) => { recorded.push(...events); },
  subscribe: () => {},
};

Faça asserts contra recorded depois que o use case roda. Se o use case esquecer de publicar, o teste falha.

Nesta página