Domain Events no Node.js são uma forma de representar acontecimentos importantes do domínio como objetos explícitos. Em vez de espalhar chamadas para e-mail, filas, auditoria e integrações dentro das entidades ou controllers, o modelo registra que algo aconteceu e permite que outros componentes reajam depois.
Um evento de domínio pode representar fatos como PedidoAprovado, PagamentoConfirmado, UsuarioSuspenso ou EstoqueReservado. O nome deve expressar um fato no passado, porque o evento descreve algo que já ocorreu dentro da regra de negócio.
Neste guia, você aprenderá a modelar Domain Events em Node.js com TypeScript, agregados, handlers, Unit of Work, outbox, idempotência, testes e observabilidade. A implementação será simples o bastante para aplicações reais, mas evitará acoplamentos comuns que tornam sistemas orientados a eventos difíceis de manter.
O que são Domain Events?
Domain Events são mensagens imutáveis produzidas pelo domínio quando uma mudança relevante acontece. Eles não são comandos. Um comando pede que algo seja feito; um evento informa que algo já aconteceu.
type OrderApproved = {
eventId: string;
type: 'order.approved';
aggregateId: string;
occurredAt: string;
data: {
customerId: string;
total: number;
};
};O evento carrega apenas informações necessárias para consumidores. Evite incluir entidades inteiras, objetos de infraestrutura ou dados sensíveis.
Por que usar eventos de domínio?
Sem eventos, um caso de uso tende a acumular responsabilidades:
await orders.save(order);
await email.sendApproval(order);
await analytics.track(order);
await audit.log(order);
await queue.publish(order);Esse fluxo mistura persistência, comunicação e infraestrutura. Também fica difícil decidir o que precisa ser transacional, o que pode falhar depois e o que deve ser repetido.
Com Domain Events, a entidade registra o fato e o caso de uso coordena sua persistência:
order.approve();
await tx.orders.save(order);
await tx.outbox.addMany(order.pullEvents());O envio para filas e integrações acontece depois, por handlers ou por um relay da outbox.
Evento dentro do agregado
O agregado pode manter uma coleção privada de eventos pendentes:
interface DomainEvent {
eventId: string;
type: string;
aggregateId: string;
occurredAt: Date;
data: Record<string, unknown>;
}
abstract class AggregateRoot {
private events: DomainEvent[] = [];
protected addEvent(event: DomainEvent) {
this.events.push(event);
}
pullEvents(): DomainEvent[] {
const pending = [...this.events];
this.events = [];
return pending;
}
}A entidade não publica diretamente. Ela apenas registra o fato. Isso mantém o domínio independente de Kafka, RabbitMQ, HTTP ou qualquer biblioteca.
Exemplo de pedido
class Order extends AggregateRoot {
constructor(
readonly id: string,
private status: 'pending' | 'approved'
) {
super();
}
approve(customerId: string, total: number) {
if (this.status !== 'pending') {
throw new Error('Order is not pending');
}
this.status = 'approved';
this.addEvent({
eventId: crypto.randomUUID(),
type: 'order.approved',
aggregateId: this.id,
occurredAt: new Date(),
data: { customerId, total }
});
}
}A regra e o evento são criados no mesmo método. Assim, não existe risco de alterar o estado sem registrar o acontecimento correspondente.
Domain Event não é Integration Event
Um Domain Event pertence ao modelo interno. Um Integration Event é o contrato publicado para outros sistemas. Em projetos pequenos, ambos podem ser iguais, mas em sistemas maiores é melhor separá-los.
O evento interno pode conter detalhes do domínio, enquanto o evento externo precisa de versionamento, estabilidade e compatibilidade. Um mapper transforma um no outro antes da publicação.
Handlers locais
Um dispatcher simples pode executar handlers dentro do processo:
type Handler = (event: DomainEvent) => Promise<void>;
class EventDispatcher {
private handlers = new Map<string, Handler[]>();
register(type: string, handler: Handler) {
const current = this.handlers.get(type) ?? [];
current.push(handler);
this.handlers.set(type, current);
}
async dispatch(event: DomainEvent) {
for (const handler of this.handlers.get(event.type) ?? []) {
await handler(event);
}
}
}Essa abordagem serve para efeitos locais não críticos, mas não garante durabilidade. Se o processo cair depois do commit e antes do handler, o evento pode ser perdido.
O problema do dual write
Salvar no banco e publicar na fila são duas operações independentes:
await orders.save(order);
await broker.publish(event);Se o banco confirmar e o broker falhar, o estado muda sem notificação. Se publicar primeiro e o banco falhar, consumidores recebem um evento sobre algo que não existe.
Esse problema é conhecido como dual write. A solução mais comum é o padrão Outbox.
Outbox transacional
O evento é gravado na mesma transação do agregado:
await unitOfWork.run(async tx => {
order.approve(input.customerId, input.total);
await tx.orders.save(order);
await tx.outbox.addMany(order.pullEvents());
});Depois, um worker lê a tabela outbox e publica no broker. Consulte também Outbox Pattern no Node.js e Unit of Work no Node.js.
Estrutura da tabela outbox
CREATE TABLE outbox_events (
id uuid PRIMARY KEY,
event_type text NOT NULL,
aggregate_id text NOT NULL,
payload jsonb NOT NULL,
occurred_at timestamptz NOT NULL,
published_at timestamptz
);O campo published_at permite identificar itens pendentes. Em alto volume, use índices parciais para linhas ainda não publicadas.
Relay da outbox
const rows = await db.query(`
SELECT * FROM outbox_events
WHERE published_at IS NULL
ORDER BY occurred_at
FOR UPDATE SKIP LOCKED
LIMIT 100
`);
for (const row of rows) {
await broker.publish(row.event_type, row.payload);
await markAsPublished(row.id);
}SKIP LOCKED permite múltiplos workers sem processar a mesma linha simultaneamente. Ainda assim, a publicação pode ocorrer mais de uma vez se o processo cair depois do broker aceitar e antes de marcar a linha.
Consumidores idempotentes
Como duplicatas são possíveis, consumidores devem ser idempotentes. Uma estratégia é registrar o eventId processado:
await db.transaction(async tx => {
const inserted = await tx.processedEvents.insertIfAbsent(event.eventId);
if (!inserted) return;
await tx.projections.apply(event);
});Uma constraint única no ID do evento transforma a deduplicação em garantia do banco.
Ordenação
Não presuma ordenação global. Se a ordem for importante, preserve-a por agregado ou partição. Use uma versão incremental:
{
type: 'order.approved',
aggregateId: 'order-123',
aggregateVersion: 7
}O consumidor pode detectar lacunas e eventos atrasados.
Versionamento de eventos
Contratos publicados evoluem. Inclua uma versão explícita:
{
type: 'order.approved',
version: 2,
data: { ... }
}Prefira mudanças compatíveis, adicionando campos opcionais. Para alterações incompatíveis, publique uma nova versão e mantenha consumidores antigos durante a migração.
Não use eventos para consultas simples
Domain Events não substituem repositories, queries ou chamadas diretas. Se um caso de uso precisa validar algo imediatamente, uma dependência explícita costuma ser mais clara. Eventos são melhores para reações desacopladas e posteriores.
Falhas de handlers
Handlers em memória podem usar uma política simples:
- falha crítica aborta a transação;
- efeito externo vai para outbox;
- efeito opcional registra erro e segue;
- retry só ocorre para falhas transitórias.
Não repita indefinidamente. Use limites, backoff e dead-letter queue quando houver broker.
Eventos e Service Layer
O Service Layer no Node.js coordena a operação. A entidade produz eventos, o repository persiste o agregado e a Unit of Work salva a outbox. Cada camada possui uma responsabilidade clara.
Eventos e Repository Pattern
O repository não deve publicar eventos escondidos. Ele pode salvar o agregado, mas a coordenação da outbox deve ficar explícita no caso de uso ou na Unit of Work. Veja Repository Pattern no Node.js.
Eventos e CQRS
Domain Events podem atualizar modelos de leitura em uma arquitetura CQRS. O lado de escrita confirma a regra; handlers atualizam projeções otimizadas para consulta. Consulte CQRS no Node.js.
Observabilidade
Registre dados úteis sem expor payload sensível:
- event ID;
- tipo e versão;
- aggregate ID;
- tempo na outbox;
- tentativas de publicação;
- duração do handler;
- duplicatas detectadas;
- falhas e dead letters.
Propague correlation ID e trace context quando necessário. Para instrumentação, consulte OpenTelemetry no Node.js.
Teste unitário do agregado
test('aprovação gera evento', () => {
const order = new Order('order-1', 'pending');
order.approve('customer-1', 120);
const [event] = order.pullEvents();
assert.equal(event.type, 'order.approved');
assert.equal(event.aggregateId, 'order-1');
});O teste verifica regra e evento sem banco ou broker.
Teste de rollback
Em integração, provoque falha ao inserir a outbox e confirme que o agregado também foi revertido. Depois provoque falha no repository e confirme que nenhum evento ficou salvo.
Teste de duplicação
Entregue o mesmo evento duas vezes ao consumidor e confirme que o estado final é igual ao de uma única entrega.
Teste de compatibilidade
Salve exemplos reais de eventos como fixtures e valide consumidores contra versões antigas. Isso evita quebrar integrações silenciosamente.
Erros comuns
- Publicar dentro da entidade: acopla domínio ao broker.
- Evento sem ID: dificulta deduplicação.
- Payload gigante: aumenta custo e exposição.
- Sem versão: evolução fica arriscada.
- Publicar após commit sem outbox: cria dual write.
- Consumidor não idempotente: duplica efeitos.
- Evento usado como comando: semântica fica confusa.
- Handler escondendo regra crítica: consistência fica eventual sem intenção.
Boas práticas
- Nomeie eventos como fatos no passado.
- Mantenha eventos imutáveis.
- Use IDs únicos.
- Inclua data e versão.
- Evite dados sensíveis.
- Separe evento interno e contrato externo quando necessário.
- Use outbox para publicação confiável.
- Faça consumidores idempotentes.
- Monitore atraso e falhas.
- Teste rollback, duplicação e compatibilidade.
Referências externas
Para aprofundar o padrão, consulte a descrição de Domain Event por Martin Fowler e a documentação do módulo Events do Node.js. O módulo nativo é útil para dispatch local, mas não substitui uma outbox quando durabilidade é necessária.
Conclusão
Domain Events no Node.js tornam acontecimentos do negócio explícitos e reduzem o acoplamento entre regras e efeitos secundários. Entidades registram fatos, o Service Layer coordena a transação e a outbox garante publicação confiável.
Uma implementação sólida combina eventos imutáveis, IDs únicos, versionamento, consumidores idempotentes e observabilidade. Com essas práticas, eventos deixam de ser callbacks espalhados e se tornam contratos claros para evolução do sistema.




