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 quando | Use chamada direta quando |
|---|---|
| Múltiplos módulos poderiam reagir | Exatamente 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 adiada | O 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.