Domain events

Escopo: transversal. Exemplos em JavaScript puro para manter o foco no padrão de evento como peça do domínio. As regras aqui valem para qualquer linguagem orientada a objetos ou que aceite a abstração de event (evento) como contrato entre partes do sistema.

Um evento registra um fato que já aconteceu, e quem o registra é o agregado que executou a operação. A partir dessas duas frases sai o resto desta página: quando criar um evento; quem publica; o que mora no payload; como nomear; o que muda quando o consumidor vive em outro processo.

O que fica de fora: persistência específica (drivers, filas) vive em ../platform/messaging.md; o lado transacional (como gravar o evento sem perder no caminho) vive em transactions.md; a propagação operacional (worker, nova tentativa, DLQ) vive em backend-flow.md.

Conceitos fundamentais

ConceitoO que é
event (evento)Fato que aconteceu no passado, registrado pelo agregado como parte do estado da operação
domain event (evento de domínio)Evento que circula dentro do mesmo bounded context (contexto delimitado); contrato interno, que evolui junto com o código
integration event (evento de integração)Evento que atravessa o limite do sistema ou do contexto; contrato público, versionado, estável
aggregate root (raiz do agregado)Entidade que comanda o agregado; ponto que registra os eventos durante as operações de escrita
event handler (processador de evento)Função ou objeto que recebe um evento e executa a reação correspondente
event bus (barramento de eventos)Componente que entrega os eventos publicados aos handlers registrados; pode rodar no mesmo processo, em fila ou em broker externo
publish/subscribe (pub/sub · publicar e assinar)Padrão onde o produtor emite sem conhecer os consumidores, e cada consumidor assina o que lhe interessa
outbox (caixa de saída)Tabela no banco do agregado, gravada na mesma transação, que serve de fila persistente para a publicação
payload (carga útil)Dados que o evento carrega; IDs e campos essenciais bastam
schema versioning (versionamento de esquema)Disciplina de evoluir o formato do payload sem quebrar consumidores antigos (V1 e V2 coexistem)
at-least-once delivery (entrega ao menos uma vez)Garantia de que o evento chega pelo menos uma vez; ele pode chegar mais vezes, e por isso o handler é idempotente
idempotency (idempotência)Propriedade do handler de aplicar o mesmo evento várias vezes com o mesmo resultado
DLQ (Dead Letter Queue · fila de mensagens com falha persistente)Fila para eventos que falharam em todas as tentativas; permite inspeção manual sem travar o consumidor
eventual consistency (consistência eventual)O estado entre agregados converge com o tempo depois do evento, com um intervalo de propagação no meio
choreography (coreografia)Cada serviço reage a eventos sem coordenador central; o acoplamento acontece pelo contrato do evento
orchestration (orquestração)Um coordenador comanda os passos da operação; cada serviço recebe um comando e responde
event sourcing (registro por eventos)Padrão onde o estado do agregado é derivado da sequência de eventos; fora do escopo deste guia

Dois tipos de evento: interno e de integração

O consumidor decide o tipo. Quando ele vive no mesmo processo que o produtor, o evento é de domínio. Quando ele atravessa o limite do processo ou do contexto, o evento é de integração. Misturar os dois em um objeto só gera uma dor específica, e a seção mostra qual.

Domain event circula dentro do mesmo bounded context. O schema é interno, evolui junto com o domínio, e os consumidores são módulos da mesma aplicação. Quando o agregado Order publica OrderSettled, quem reage (estoque, e-mail, métrica) faz parte do mesmo modelo de domínio. Trocar um campo de OrderSettled obriga a tocar nos consumidores, e todos eles vivem no mesmo repositório, com o mesmo deploy.

Integration event atravessa o limite. O schema é contrato público, evolui com cuidado, e os consumidores podem ser outros serviços, parceiros ou jobs externos. Um OrderSettled que vai para o sistema de BI (Business Intelligence · inteligência de negócio) ou para um ERP (Enterprise Resource Planning · sistema integrado de gestão) parceiro vira contrato. Mudar o formato sem versionar quebra uma integração que talvez nem esteja no seu radar.

❌ Ruim: o mesmo evento atende uso interno e externo, schema vai engordando
class OrderSettled {
  constructor(order) {
    this.orderId = order.id;
    this.customerId = order.customerId;
    this.total = order.total;
    this.items = order.items;
    this.customer = order.customer;
    this.payment = order.payment;
    this.shippingAddress = order.shippingAddress;
    this.notes = order.notes;
    this.metadata = order.metadata;
  }
}

eventBus.publish(new OrderSettled(order));
analyticsBroker.publish(new OrderSettled(order));

O mesmo objeto serve de contrato interno (handler de e-mail, handler de estoque) e externo (BI, parceiro). Cada campo novo entra para atender a um dos consumidores e chega aos outros de carona. Quando o parceiro reclama de um payload de 80 KB, ninguém sabe dizer quem precisa de qual campo.

✅ Bom: domain event interno, integration event derivado e enxuto
class OrderSettled {
  constructor({ orderId, customerId, total, settledAt }) {
    this.orderId = orderId;
    this.customerId = customerId;
    this.total = total;
    this.settledAt = settledAt;
  }

  static from(order) {
    const event = new OrderSettled({
      orderId: order.id,
      customerId: order.customerId,
      total: order.total,
      settledAt: order.settledAt,
    });

    return event;
  }
}

class OrderSettledIntegrationV1 {
  constructor({ orderId, customerId, total, currency, settledAt }) {
    this.eventVersion = 1;
    this.orderId = orderId;
    this.customerId = customerId;
    this.total = total;
    this.currency = currency;
    this.settledAt = settledAt;
  }

  static from(order) {
    const event = new OrderSettledIntegrationV1({
      orderId: order.id,
      customerId: order.customerId,
      total: order.total,
      currency: order.currency,
      settledAt: order.settledAt,
    });

    return event;
  }
}

eventBus.publish(OrderSettled.from(order));
integrationBus.publish(OrderSettledIntegrationV1.from(order));

O evento de domínio fica curto e evolui livre. O evento de integração ganha o campo eventVersion, a moeda explícita e um contrato versionado. Cada um muda no seu próprio ritmo.

Na prática: o produtor publica o domain event durante a operação, e um handler interno traduz esse evento para o integration event e o publica no canal externo. Esse handler é a anti-corruption layer (camada anticorrupção: peça que protege o modelo interno do contrato público). Detalhes em patterns.md.

Quem dispara o evento é a aggregate root

O aggregate root é o único que registra eventos. O evento nasce no método do agregado que executa a operação, porque é ali que o estado muda e a invariante é verificada.

O agregado mantém uma lista interna de eventos pendentes (this.events). Cada método de domínio que altera estado adiciona o evento correspondente. Quando o caso de uso termina, o repositório lê essa lista, grava na outbox (ou no event bus, se ele roda no mesmo processo e de forma síncrona) e limpa a lista.

❌ Ruim: serviço publica evento direto, sem passar pelo agregado
class OrderService {
  async place(orderInput) {
    const order = new Order(orderInput);
    await this.orderRepository.save(order);

    await this.eventBus.publish(new OrderPlaced(order));
  }

  async cancel(orderId) {
    const order = await this.orderRepository.findById(orderId);
    order.status = "cancelled";
    await this.orderRepository.save(order);

    await this.eventBus.publish(new OrderCancelled(order));
  }
}

A regra "se cancelou, publica evento" mora no serviço, em paralelo com a regra "se cancelou, muda status". Toda nova ação que altera o pedido precisa lembrar de publicar. Quem fizer order.status = "cancelled" fora do serviço (em script de migração, em correção manual) deixa o evento órfão; quem publicar OrderCancelled por outro caminho deixa o estado divergente.

✅ Bom: agregado registra o evento na operação
class Order {
  constructor({ id, customerId, items = [], status = "pending" }) {
    this.id = id;
    this.customerId = customerId;
    this.items = items;
    this.status = status;
    this.events = [];
  }

  static place({ id, customerId, items }) {
    const order = new Order({ id, customerId, items, status: "pending" });
    order.events.push(OrderPlaced.from(order));

    return order;
  }

  cancel(reason) {
    if (this.status === "cancelled") {
      throw new Error("Order is already cancelled");
    }
    if (this.status === "shipped") {
      throw new Error("Cannot cancel a shipped order");
    }

    this.status = "cancelled";
    this.cancellationReason = reason;
    this.events.push(OrderCancelled.from(this));
  }
}

class OrderRepository {
  async save(order) {
    const transaction = await this.database.beginTransaction();

    try {
      await this.persistOrder(order, transaction);
      await this.persistEvents(order.events, transaction);

      await transaction.commit();

      order.events = [];
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  }
}

Mudança de status e registro do evento acontecem na mesma linha de código, dentro do agregado. As duas coisas passaram a ser uma regra só, garantida pelo construtor e pelos métodos de operação.

O ganho aparece nos casos de uso seguintes. Adicionar order.refund() já coloca OrderRefunded em events; o repositório persiste; o worker publica. O agregado é a única fonte de verdade, e o serviço fica livre de manter uma lista paralela.

Publicar só depois do commit

O evento vira público depois que a transação do agregado confirma. Publicar antes do commit aposta que a transação vai concluir. Publicar fora da transação aposta que o broker não vai falhar. As duas apostas falham em produção com frequência alta o bastante para doer.

O outbox resolve os dois casos: o evento é gravado no banco, na mesma transação que persiste o agregado. Um worker separado lê o outbox e publica no broker. Se o worker ou o broker falhar, o evento continua no outbox para a próxima tentativa. Se o consumer falhar, a entrega é at-least-once e a idempotência cobre a repetição.

❌ Ruim: publish síncrono dentro da transação
class OrderRepository {
  async save(order) {
    const transaction = await this.database.beginTransaction();

    try {
      await this.persistOrder(order, transaction);

      for (const event of order.events) {
        await this.eventBus.publish(event);
      }

      await transaction.commit();

      order.events = [];
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  }
}

O publish mora dentro do try. Se o broker estiver lento, a transação segura o registro bloqueado no banco enquanto espera a rede. Se o commit falhar depois do publish, o evento já saiu e o agregado não persistiu: o consumer recebe OrderPlaced de um pedido que não existe.

✅ Bom: evento na mesma transação, publicação assíncrona pelo worker
class OrderRepository {
  async save(order) {
    const transaction = await this.database.beginTransaction();

    try {
      await this.persistOrder(order, transaction);
      await this.persistEvents(order.events, transaction);

      await transaction.commit();

      order.events = [];
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  }

  async persistEvents(events, transaction) {
    for (const event of events) {
      await transaction.execute(
        `INSERT INTO outbox
         (
           id,
           type,
           payload,
           created_at,
           status
         )
         VALUES
           ($1, $2, $3, NOW(), 'pending')`,
        [event.id, event.type, JSON.stringify(event.payload)],
      );
    }
  }
}

class OutboxPublisher {
  async run() {
    const pendingEvents = await this.outboxRepository.findPending({ limit: 100 });

    for (const event of pendingEvents) {
      try {
        await this.eventBus.publish(event);
        await this.outboxRepository.markAsPublished(event.id);
      } catch (error) {
        await this.outboxRepository.recordFailure(event.id, error.message);
      }
    }
  }
}

A transação do agregado fecha rápido, sem depender do broker. O worker publica em outro processo, e a falha do broker fica isolada ali, longe da operação que disparou o evento. Detalhes do worker (intervalo de leitura, tamanho do lote, nova tentativa com espera progressiva) em backend-flow.md.

Em sistema pequeno, o event bus pode rodar no mesmo processo, de forma síncrona, com os handlers executando logo após o commit. A regra continua valendo: a publicação acontece depois do commit. Em Node.js, await transaction.commit(); for (const event of events) await eventBus.dispatch(event); resolve. Em sistema maior, o outbox vira necessário, porque cada handler vive em um processo separado e a fila precisa sobreviver a quedas.

O nome do evento é um fato no passado

O nome do evento descreve algo que aconteceu: verbo no passado, sem comando. OrderPlaced narra um fato. PlaceOrder expressa uma intenção, e vira command (comando). A distinção organiza o sistema inteiro, porque um comando pode ser rejeitado e um fato consumado não.

A regra prática:

  • Verbo no passado: Placed, Cancelled, Refunded, Shipped, Settled. Em inglês, particípio passado.
  • Sujeito do domínio: Order, Customer, Invoice. Evitar Entity, Record ou Item genérico.
  • Sem auxiliar técnico: evitar OrderUpdatedEvent, CustomerSavedEvent. O sufixo Event é ruído, porque o contexto já diz do que se trata.
  • Específico sempre que possível: OrderUpdated esconde o que mudou. Quebrar em OrderAddressChanged, OrderItemAdded, OrderItemRemoved. Cada um carrega uma regra distinta.
❌ Ruim: nomes genéricos no presente, com ruído técnico
class UpdateOrder {
  constructor(order) {
    this.order = order;
  }
}

class OrderDataChangedEvent {
  constructor(order) {
    this.order = order;
  }
}

class SaveOrderEvent {
  constructor(order) {
    this.order = order;
  }
}

eventBus.publish(new UpdateOrder(order));
eventBus.publish(new SaveOrderEvent(order));

UpdateOrder tem nome de comando. OrderDataChangedEvent carrega Data (vago) e Event (redundante). SaveOrderEvent mistura o vocabulário do banco (save) com o do domínio. Nenhum dos três diz o que aconteceu de fato.

✅ Bom: nomes no passado, sem ruído, descritivos
class OrderPlaced {
  constructor({ orderId, customerId, total, placedAt }) {
    this.orderId = orderId;
    this.customerId = customerId;
    this.total = total;
    this.placedAt = placedAt;
  }
}

class OrderAddressChanged {
  constructor({ orderId, previousAddress, currentAddress, changedAt }) {
    this.orderId = orderId;
    this.previousAddress = previousAddress;
    this.currentAddress = currentAddress;
    this.changedAt = changedAt;
  }
}

class OrderItemAdded {
  constructor({ orderId, productId, quantity, unitPrice, addedAt }) {
    this.orderId = orderId;
    this.productId = productId;
    this.quantity = quantity;
    this.unitPrice = unitPrice;
    this.addedAt = addedAt;
  }
}

Cada nome conta uma história: alguém colocou um pedido; alguém trocou o endereço; alguém adicionou um item. O log de eventos passa a narrar a operação de negócio em linguagem que o time de produto entende.

Em projeto que cresce, o nome do evento vira parte da ubiquitous language (linguagem ubíqua: o vocabulário que produto, suporte e engenharia usam para falar da mesma coisa). Todos dizem OrderPlaced e todos entendem o mesmo fato. Nomes técnicos como OrderRecordSaved fragmentam essa conversa.

O payload é pequeno, versionado e não muda depois

O payload é o contrato do evento. Três princípios ditam o desenho:

Pequeno. IDs e os campos essenciais para identificar o que aconteceu e quando. Quem precisar de mais detalhes resolve o ID e consulta o banco no momento de usar. Carregar o agregado completo cria três problemas: payload pesado no broker, schema acoplado à estrutura interna do agregado, e dado defasado, porque o evento foi gravado em um momento, o consumer lê em outro, e o agregado já mudou.

Versionado. Eventos de integração ganham o campo eventVersion. Uma mudança no payload gera uma versão nova (V2), e a versão antiga fica em circulação até os consumers migrarem. Eventos de domínio admitem menos rigor, porque a mudança é coordenada no mesmo deploy, e mesmo assim versionar cedo economiza trabalho no dia em que o evento interno virar contrato externo.

Não muda depois de publicado. Corrigir um evento antigo significa emitir um evento novo (OrderCorrected). O histórico preservado é o que torna possível auditar, reprocessar e depurar: ele conta o que aconteceu em cada instante, e o estado atual conta apenas onde tudo parou.

❌ Ruim: payload carrega o agregado inteiro
class OrderSettled {
  constructor(order) {
    this.order = order;
  }
}

eventBus.publish(new OrderSettled(order));

class StockReservationHandler {
  async on(event) {
    for (const item of event.order.items) {
      await this.stockRepository.reserve(item.productId, item.quantity);
    }
  }
}

O handler depende do formato interno de Order. Quando o agregado ganha um campo, o evento engorda. Quando o agregado é refatorado, o consumer quebra. O contrato implícito virou "tudo que Order tiver hoje", e esse contrato muda a cada sprint.

✅ Bom: payload com IDs e dados essenciais, schema explícito e versionado
class OrderSettledV1 {
  constructor({ eventId, eventVersion, orderId, customerId, total, currency, settledAt }) {
    this.eventId = eventId;
    this.eventVersion = eventVersion;
    this.orderId = orderId;
    this.customerId = customerId;
    this.total = total;
    this.currency = currency;
    this.settledAt = settledAt;
  }

  static from(order) {
    const event = new OrderSettledV1({
      eventId: OrderSettledV1.generateId(),
      eventVersion: 1,
      orderId: order.id,
      customerId: order.customerId,
      total: order.total,
      currency: order.currency,
      settledAt: order.settledAt,
    });

    return event;
  }
}

class StockReservationHandler {
  async on(event) {
    const orderItems = await this.orderRepository.findItemsByOrderId(event.orderId);

    for (const orderItem of orderItems) {
      await this.stockRepository.reserve(orderItem.productId, orderItem.quantity);
    }
  }
}

O payload tem só o que basta para o consumer reagir, e quem precisar dos itens consulta o agregado pelo ID. O contrato é o construtor de OrderSettledV1, e qualquer mudança passa por OrderSettledV2 ou por retrocompatibilidade explícita.

Migrar o schema sem quebrar consumers segue duas regras:

  • Adicionar campo é seguro. Consumers antigos ignoram o campo novo.
  • Remover ou renomear campo exige versão nova. OrderSettledV2 substitui OrderSettledV1; o produtor publica os dois durante a janela de migração; cada consumer escolhe qual escutar; quando todos migrarem, V1 é desligado.

Cada handler falha sozinho

Cada handler é uma unidade independente, com nova tentativa, falha e estado próprios. A falha de um handler não alcança os outros. Sem esse isolamento, o que existe é uma cadeia de chamadas acopladas com nome de evento.

Três regras concretas:

Idempotência. O handler recebe entrega at-least-once, então pode receber o mesmo evento duas vezes. Ele verifica antes de aplicar: o evento já foi processado? Se sim, ignora. Uma tabela processed_events(handler_name, event_id) ou uma flag no estado do consumer resolvem.

Sem efeito cruzado. O handler não chama outro handler nem escreve direto no agregado de outro handler. Quando a reação gera um fato novo do domínio, ele publica um evento novo e deixa outro handler reagir.

Falha isolada do produtor. Se o handler de e-mail falhar, o pedido continua pago. A falha do handler vai para nova tentativa e, depois de N tentativas, para a DLQ.

❌ Ruim: handler chama outro handler em cadeia síncrona
class OrderSettledHandler {
  async on(event) {
    await this.stockHandler.reserveItems(event.order);
    await this.shippingHandler.scheduleDelivery(event.order);
    await this.emailHandler.sendConfirmation(event.order);
    await this.analyticsHandler.trackRevenue(event.order);
  }
}

Quatro responsabilidades acopladas em um handler só. Se o e-mail falhar, estoque, entrega e analytics já rodaram, e mesmo assim o handler inteiro volta para nova tentativa: as quatro ações repetem. Sem idempotência forte em cada uma, o estoque é reservado em dobro.

✅ Bom: cada handler isolado, idempotente, com retry próprio
class StockReservationHandler {
  constructor(stockRepository, processedEventsRepository) {
    this.stockRepository = stockRepository;
    this.processedEventsRepository = processedEventsRepository;
  }

  async on(event) {
    const wasProcessed = await this.processedEventsRepository.exists(
      "StockReservationHandler",
      event.eventId,
    );

    if (wasProcessed) {
      return;
    }

    const orderItems = await this.orderRepository.findItemsByOrderId(event.orderId);

    for (const orderItem of orderItems) {
      await this.stockRepository.reserve(orderItem.productId, orderItem.quantity);
    }

    await this.processedEventsRepository.record(
      "StockReservationHandler",
      event.eventId,
    );
  }
}

class OrderConfirmationEmailHandler {
  async on(event) {
    const wasSent = await this.emailLog.exists("OrderConfirmation", event.orderId);

    if (wasSent) {
      return;
    }

    await this.emailService.send({
      template: "order-confirmation",
      to: event.customerEmail,
      data: { orderId: event.orderId, total: event.total },
    });

    await this.emailLog.record("OrderConfirmation", event.orderId);
  }
}

Cada handler tem a própria checagem de idempotência e falha por conta própria. O event bus reentrega apenas para quem falhou, e quem já passou fica de fora da repetição. A falha do e-mail deixa a reserva de estoque de pé.

Quando um handler precisa disparar outra ação do domínio, o caminho é ler o evento → emitir um comando → outro agregado executa → publicar o próximo evento. Um handler nunca chama outro handler direto.

O estado intermediário aparece na tela

Dois agregados que conversam por evento alcançam consistência com um intervalo entre as duas escritas. Esse intervalo existe, e o trabalho é modelá-lo de forma que o sistema conte a verdade durante a propagação.

O intervalo costuma ser de milissegundos a segundos em sistema saudável. Sob carga, pode chegar a minutos. Com falha, a horas. Em todos os casos, três abordagens cobrem o usuário:

  • Estado intermediário no agregado. status = "awaiting_confirmation" → "confirmed". A tela mostra "processando" enquanto o evento não chegou, e "confirmado" quando o handler atualizou.
  • Otimismo na UI. O frontend assume o resultado final e atualiza a tela de imediato; o backend corrige se algo der errado. Funciona quando o erro é raro e a correção é tolerável.
  • Consulta periódica. O frontend pergunta ao backend a cada N segundos. O custo é tráfego extra; o ganho é uma consistência percebida mais firme.
❌ Ruim: agregado finge consistência instantânea, UI engana o usuário
class Order {
  static place(input) {
    const order = new Order({ ...input, status: "confirmed" });
    order.events.push(OrderPlaced.from(order));

    return order;
  }
}

O status nasce como "confirmed" no momento do place. O usuário lê "pedido confirmado" na hora, e o estoque ainda nem foi verificado. Quando o handler de estoque rejeitar por falta de disponibilidade, o pedido vira cancelled segundos depois, e a tela mostrou uma confirmação que o sistema não tinha como dar.

✅ Bom: estado intermediário explícito, UI espelha a verdade
class Order {
  static place(input) {
    const order = new Order({ ...input, status: "awaiting_confirmation" });
    order.events.push(OrderPlaced.from(order));

    return order;
  }

  confirm() {
    if (this.status !== "awaiting_confirmation") {
      throw new Error(`Cannot confirm order in status ${this.status}`);
    }

    this.status = "confirmed";
    this.events.push(OrderConfirmed.from(this));
  }

  rejectDueToStockShortage() {
    if (this.status !== "awaiting_confirmation") {
      throw new Error(`Cannot reject order in status ${this.status}`);
    }

    this.status = "rejected";
    this.events.push(OrderRejected.from(this));
  }
}

O caso de uso devolve um pedido awaiting_confirmation, e a tela exibe "verificando disponibilidade". Quando o handler de estoque concluir, ele chama confirm() ou rejectDueToStockShortage(), e cada um publica o evento correspondente. O usuário vê em cada instante o estado que o sistema tem de fato.

Coordenação: cada um reage ou um coordenador comanda

Quando vários handlers reagem em sequência para completar uma operação maior, dois estilos de coordenação aparecem. A complexidade do fluxo decide qual usar.

Choreography. Cada serviço escuta o evento e reage, sem coordenador. O fluxo emerge da soma dos handlers. Serve para fluxos curtos (3 a 5 passos), estáveis, com regras de cancelamento simples. O acoplamento acontece pelo contrato do evento, e cada serviço é autônomo.

Orchestration. Um coordenador comanda os passos. Ele conhece o fluxo inteiro, mantém o estado e decide o próximo comando. Serve para fluxos longos (7 passos ou mais), com regras de compensação variadas e pontos de espera (aprovação humana, timeout, nova tentativa programada).

CritérioChoreographyOrchestration
Quem conhece o fluxoNinguém individualmente; está implícitoO orquestrador, explícito
Adicionar passo novoAdiciona handler que escuta evento existenteAdiciona passo no orquestrador, redeploy
DebuggingMais difícil (ver por que algo não rodou exige correlation ID)Mais fácil (orquestrador loga cada passo)
AcoplamentoPor contrato de eventoPor contrato de comando
Bom paraFluxos curtos e estáveisFluxos longos, com pontos de espera

Combinar os dois é válido: orquestração entre serviços e coreografia dentro de cada serviço. O correlation ID citado na tabela (identificador que acompanha a operação por todos os passos) é o que torna possível seguir um fluxo coreografado no log. Detalhes sobre saga, que aparece nos dois estilos, em transactions.md.

Anti-patterns

Naming imperativo. SendEmail, PlaceOrder, UpdateStock como nome de evento. Sintoma: um handler chamado SendEmailHandler recebe SendEmail, que é o nome do comando que ele executa. Tratamento: OrderPlaced nomeia o evento e SendOrderConfirmationEmail nomeia o comando que o handler executa em reação; os dois nomes contam histórias diferentes.

Payload pesado. O evento carrega o agregado inteiro. Sintoma: uma alteração interna do Order quebra os consumers do evento; o payload chega a 50 KB no broker. Tratamento: IDs e campos essenciais; o consumer resolve o ID e busca o resto.

Publish dentro da transação. Evento publicado antes do commit. Sintoma: o consumer recebe evento de um agregado que falhou no save; ou a transação segura o registro bloqueado esperando o broker. Tratamento: outbox mais worker.

Handler que muda o agregado de origem. OrderSettledHandler atualiza o próprio Order que disparou o evento. Sintoma: um evento OrderSettledUpdated vira ruído e o agregado nunca para de mudar. Tratamento: o agregado já gravou a mudança que disparou o evento, então os handlers reagem em outros agregados ou em sistemas externos.

Sem versionamento de schema. O payload muda em produção sem eventVersion. Sintoma: consumer antigo quebra; o deploy do produtor passa a exigir deploy coordenado de N consumers. Tratamento: versionar; publicar V1 e V2 durante a migração.

Replay sem idempotência. Reprocessar o histórico de eventos para reconstruir o estado sem checar duplicata. Sintoma: estoque reservado em dobro, e-mail enviado de novo, receita contada duas vezes no dashboard. Tratamento: idempotência em todo handler, com processed_events ou flag no consumer.

Domain event vazando como integration event. O evento interno vira contrato público sem passar por um tradutor. Sintoma: uma mudança rotineira do domínio quebra o parceiro externo; refatorar o agregado vira projeto. Tratamento: publicar o domain event no bus interno e deixar um handler dedicado traduzir e publicar o integration event versionado no canal externo.

Choreography em fluxo de 12 passos. Operação longa modelada como sucessão de eventos, sem coordenador. Sintoma: ninguém consegue dizer em que passo o fluxo está; depurar exige seguir 12 handlers em ordem; inserir um passo no meio dói. Tratamento: orchestration, com o estado da saga persistido pelo coordenador.

Handler que chama outro handler direto. OrderSettledHandler invoca StockReservationHandler.run() no código. Sintoma: falha em cascata, nova tentativa duplicada, nenhum isolamento. Tratamento: handlers se comunicam por evento.

Referências

Cross-links dentro do guia:

Bibliografia externa (livros, artigos, especificações): REFERENCES.md.

DoDocs v3.7.0 · Desenvolvido por @thiagocajadev · Baseado no trabalho de pmndrs/docs · Poimandres.