Domain-Events não são a sua API pública

Domain events, ou eventos de domínio, costumam marcar um momento importante na evolução de um sistema orientado a eventos, porque é quando a equipe abandona os eventos de dados e passa a publicar fatos com significado de negócio. No lugar de ShipmentUpdated ou ShipmentStatusChanged, começam a surgir nomes como DeliveryAttemptFailed, ETARecalculated e ShipmentDelayed.

A expectativa é que a dor acabe ali. Na prática, ela apenas muda de endereço.

Meses depois, alterar qualquer um desses eventos continua quebrando consumidores. Inclusive, alguns deles a equipe produtora nem sabia que existiam.

O diagnóstico é desconfortável, mas simples. Publicar domain events para o resto do sistema é a mesma coisa que publicar o esquema do banco de dados, apenas com nomes mais bonitos.

O que são domain events e onde termina a fronteira

A orientação que circula com mais frequência é que basta modelar bons eventos de domínio e publicá-los. Essa orientação está incompleta, porque ignora que tudo que atravessa a fronteira de um serviço se transforma automaticamente em contrato.

Um evento de domínio existe para expressar, de forma explícita, um efeito colateral dentro do próprio domínio. A documentação de arquitetura de microsserviços da Microsoft define o conceito exatamente assim: algo que aconteceu no domínio e que outras partes do mesmo domínio, em processo, precisam saber.

A expressão "mesmo domínio, em processo" carrega todo o peso da questão. Afinal, ela delimita o alcance dos domain events.

Já os eventos de integração existem para outra coisa. A finalidade deles é propagar transações confirmadas e atualizações para outros subsistemas, sejam outros microsserviços, contextos delimitados ou até aplicações externas.

Semanticamente, os dois são a mesma coisa: uma notificação de algo que já aconteceu. A própria documentação reconhece essa equivalência, mas deixa claro que a implementação precisa ser diferente.

Diferente não apenas no transporte. Na verdade, também no formato, no vocabulário e no ciclo de vida.

Domain events descrevem o interior da fronteira

Considere um sistema logístico responsável por despachar cargas. Dentro da fronteira de despacho, uma única operação de negócio pode gerar uma sequência longa de eventos: TruckReserved, CarrierAssigned, DispatchInstructionsSent, ETACalculated e ShipperNotified.

Todos esses domain events são úteis internamente. Afinal, eles descrevem o passo a passo de um fluxo de trabalho.

A pergunta relevante é outra. O restante do sistema precisa mesmo conhecer cada etapa desse fluxo?

O faturamento não toma decisão alguma porque uma transportadora foi atribuída. Da mesma forma, a frota não muda de comportamento porque o sistema enviou instruções de despacho.

O que interessa fora da fronteira é o resumo do comportamento: o pedido foi despachado. Portanto, OrderDispatched é o evento que outras partes do sistema realmente consomem.

Os cinco eventos anteriores são detalhes de como o despacho aconteceu. Expor esses cinco domain events significa transformar a implementação interna do fluxo em contrato público.

A partir daí, qualquer refatoração do fluxo quebra alguém.

A analogia com uma API HTTP

A forma mais direta de entender o problema é compará-lo com algo que todo desenvolvedor já enfrentou. Um sistema tem um modelo de banco de dados, com uma estrutura orientada a persistência.

Ele também tem um modelo de domínio, que representa os mesmos dados de outra maneira, porque carrega regras e comportamento. Essa diferença entre os dois modelos aparece com clareza no artigo sobre data driven e domain driven.

Quando esse sistema expõe uma API HTTP, ele devolve um terceiro modelo, normalmente uma composição de informações formatada em JSON. Esses três modelos não precisam ser iguais, e quem já tentou unificá-los conhece a consequência.

Expor o esquema do banco diretamente pela API faz com que os consumidores passem a depender daquele esquema. Assim, uma decisão de persistência vira contrato público por acidente.

A partir desse ponto, alterar uma coluna deixa de ser uma decisão técnica interna. Em vez disso, passa a ser uma negociação com terceiros.

Eventos funcionam exatamente da mesma forma, porque eventos publicados são contrato. O modelo de dados é uma coisa, o modelo de domínio é outra e o modelo de integração é uma terceira.

Esse terceiro modelo merece o mesmo cuidado de design que você dedica a uma API pública. Ou seja, domain events não substituem esse design.

O modelo de integração é um modelo próprio

Voltando ao exemplo da carga, internamente pode existir um conceito rico de embarque intermediado. Esse conceito reserva veículo, atribui transportadora, calcula custos e mantém regras específicas de negociação com parceiros.

A forma como o sistema persiste esse embarque pode ser bastante diferente do modelo de domínio. No entanto, nenhuma das duas representações deve atravessar a fronteira.

Outras partes do sistema não têm interesse em saber que uma transportadora foi atribuída. Elas querem saber que o pedido foi despachado e intermediado.

Esse é o papel do evento de integração: comunicar o que aconteceu em termos que façam sentido fora de casa.

Quando o sistema gera eventos diretamente a partir do modelo de dados, mudanças de esquema vazam para todos os consumidores. Consequentemente, o acoplamento resultante fica extremamente difícil de desfazer.

O mesmo vale para domain events expostos indiscriminadamente. O sintoma é idêntico: o sistema deixa de evoluir.

Eventos de integração comunicam significado

Pode existir sobreposição entre eventos de domínio e eventos de integração, e isso não é um problema. O ponto essencial é que a decisão seja explícita, porque alguém precisa escolher, conscientemente, o que sai da fronteira.

Retomando o fluxo logístico, o sistema publica OrderDispatched quando o despacho termina. Em seguida, o veículo chega ao embarcador e o sistema publica Arrived.

A partir daí, o inesperado acontece. A carga não está pronta, o estabelecimento está fechado ou o cliente desistiu do envio.

Diante disso, a saída fácil é registrar que o pedido foi cancelado. Contudo, essa também é a saída que destrói informação.

Se os consumidores precisam deduzir o que aconteceu observando uma sequência de mudanças genéricas de status, o conceito de negócio real se perdeu no caminho. O que aconteceu de fato tem nome próprio: o veículo foi até o local de coleta e a carga não estava disponível.

TruckOrderNotUsed descreve isso. Por outro lado, OrderCanceled não descreve.

O significado de negócio muda decisões

A diferença entre esses dois nomes não é estética. Na prática, ela altera o comportamento de vários subsistemas.

O faturamento se importa porque o cliente ainda precisa pagar uma taxa. Afinal, um veículo efetivamente se deslocou até o endereço.

A gestão de frota se importa porque aquele veículo voltou a ficar disponível. Assim, ela pode alocá-lo imediatamente em outro despacho.

Os relatórios se importam porque a situação é estatisticamente diferente de um cancelamento comum. Misturar as duas contamina qualquer indicador de eficiência operacional.

Os acertos financeiros com parceiros também se importam, porque a transportadora provavelmente precisa receber alguma coisa. O motorista não dirigiu até lá de graça.

Nenhuma dessas quatro áreas reage a uma mudança de status. Todas reagem a um conceito de negócio, e essa é a diferença entre um evento que informa e um evento que apenas registra.

Domain events e integração com esquemas diferentes

TruckOrderNotUsed também ilustra um ponto que costuma gerar confusão. O conceito pode existir simultaneamente entre os domain events e entre os eventos de integração, e isso é legítimo.

O que não decorre disso é que os dois precisem ter o mesmo esquema. Imagine que o evento interno carregue um campo ReasonCode com valor 789.

Esse código faz sentido dentro da fronteira de despacho, provavelmente amarrado a uma tabela de domínio ou a uma regra específica de operação. No entanto, no instante em que outro serviço passa a tomar decisões com base no valor 789, um detalhe interno virou contrato público.

O evento de integração deve expor um conjunto de motivos definido para consumo externo, com significado estável e documentado:

// Interno à fronteira de despacho
public sealed record TruckOrderNotUsedDomainEvent(
    Guid ShipmentId,
    Guid CarrierId,
    int ReasonCode,
    string OperatorNotes,
    DateTimeOffset OccurredOn) : IDomainEvent;

// Contrato público, publicado no barramento
public sealed record TruckOrderNotUsed(
    Guid ShipmentId,
    Guid CarrierId,
    NotUsedReason Reason,
    DateTimeOffset OccurredOn) : IntegrationEvent;

public enum NotUsedReason
{
    ShipmentNotReady,
    LocationClosed,
    ShipperRefused
}

Os dois representam o mesmo fato do negócio. Porém, apenas um deles é contrato.

A documentação da Microsoft traz uma recomendação explícita sobre esse tipo de contrato. Compartilhar uma biblioteca comum de eventos de integração entre vários microsserviços não é recomendado, porque isso acopla os serviços a uma única biblioteca de definição de eventos.

Em vez disso, cada microsserviço pode definir seus eventos de integração na camada de aplicação, de forma comparável aos ViewModels. Além disso, a mesma referência orienta que o design dos eventos busque o tamanho adequado às necessidades dos consumidores.

Quanta informação um evento de integração deve carregar

Essa é a pergunta seguinte, e ela tem dois extremos igualmente problemáticos. De um lado estão os eventos obesos, associados à técnica conhecida como event carried state transfer, em que o evento carrega praticamente toda a entidade.

O risco aqui é conhecido. O evento se torna uma projeção do modelo de dados e volta ao problema de acoplamento estrutural.

Do outro lado estão os eventos anêmicos, que carregam apenas identificadores. Nesse caso, o problema é previsível: o consumidor recebe o evento, percebe que não tem informação suficiente e faz uma chamada síncrona de volta ao produtor.

Em um sistema distribuído, isso é uma requisição HTTP. Já em um monólito modular, é uma chamada em processo, e a natureza do problema é a mesma.

A questão nem é apenas o acoplamento temporal que a chamada introduz. O ponto central é a semântica do dado obtido.

Quando o consumidor consulta o produtor, ele obtém o estado atual. O evento, no entanto, descreve um fato ocorrido no passado.

Se o produtor publicou o evento há cinco minutos e só agora alguém o processa, o estado retornado pode não ter relação alguma com o momento do fato. Reagir a um acontecimento de cinco minutos atrás usando dados de agora produz decisões erradas de forma silenciosa.

A conclusão razoável não é escolher entre eventos magros e gordos. Em vez disso, o evento deve carregar exatamente aquilo que o consumidor precisa para entender o que aconteceu e reagir.

Versionamento nem sempre é versionamento

O último ponto conecta contrato e evolução. Suponha que TruckOrderNotUsed tenha nascido pequeno, com pouco mais que o identificador da carga.

Com o tempo, os consumidores passam a precisar do motivo e a equipe adiciona o campo Reason ao contrato. Até aqui, é versionamento comum, resolvido com compatibilidade retroativa.

Existe, porém, uma situação que se disfarça de versionamento e não é. TruckOrderNotUsed significa que o veículo chegou ao local de coleta e a carga não estava disponível.

Isso é diferente de um pedido de transporte cancelado antes do veículo sair. Afinal, as implicações financeiras separam os dois casos.

Se o caminhão se deslocou, provavelmente há valor devido à transportadora e taxa a cobrar do cliente. Por outro lado, se o cancelamento ocorreu antes do deslocamento, possivelmente não há nem uma coisa nem outra.

Tentar acomodar as duas realidades no mesmo evento, com campos opcionais e flags, é uma armadilha comum. O contrato incha, e nenhum consumidor consegue interpretá-lo sem conhecer regras não escritas.

A alternativa é reconhecer que existe um segundo conceito de negócio, algo como TruckOrderCanceledBeforeShipment. A partir daí, os eventos passam a contar uma história coerente, em que cada nome significa uma coisa e apenas uma coisa.

Como separar domain events no .NET

Do ponto de vista de implementação, a separação é menos trabalhosa do que parece. Considerando o .NET 10, versão LTS atual da plataforma, com suporte previsto até novembro de 2028, a estrutura costuma seguir três camadas de responsabilidade.

As entidades e os agregados levantam os domain events, que ficam acumulados na raiz do agregado. Depois da persistência, um mediador em processo despacha esses eventos.

A aplicação de referência eShop, por exemplo, usa o MediatR para propagar domain events de forma síncrona entre agregados, dentro de uma única transação. Se quiser revisar esse padrão, o artigo sobre CQRS e Mediator no ASP.NET aprofunda o tema.

Traduzindo domain events no manipulador

O manipulador do evento de domínio pertence à camada de aplicação, e é justamente ali que a tradução acontece. Ou seja, um manipulador de evento de domínio pode publicar um evento de integração no barramento, atravessando as fronteiras do microsserviço:

public sealed class PublishTruckOrderNotUsedHandler
    : INotificationHandler<TruckOrderNotUsedDomainEvent>
{
    private readonly IIntegrationEventOutbox _outbox;

    public PublishTruckOrderNotUsedHandler(IIntegrationEventOutbox outbox)
        => _outbox = outbox;

    public Task Handle(TruckOrderNotUsedDomainEvent domainEvent, CancellationToken cancellationToken)
    {
        // Traduz o evento interno para o contrato público
        var contract = new TruckOrderNotUsed(
            domainEvent.ShipmentId,
            CarrierId: domainEvent.CarrierId,
            Reason: ReasonMap.ToPublic(domainEvent.ReasonCode),
            OccurredOn: domainEvent.OccurredOn);

        // Grava no outbox, dentro da mesma transação da mudança de estado
        return _outbox.EnqueueAsync(contract, cancellationToken);
    }
}

O método ReasonMap.ToPublic é o ponto onde o detalhe interno deixa de existir. Assim, ele funciona como uma fronteira explícita, versionável e testável.

Garantindo consistência com outbox

Há ainda uma preocupação de consistência que não pode ficar de fora. Eventos de integração devem ocorrer somente se a entidade for persistida com sucesso, caso contrário é como se a operação inteira nunca tivesse acontecido.

Para garantir isso, a documentação da Microsoft sobre assinatura de eventos descreve o padrão outbox. Nele, uma tabela transacional armazena os eventos de integração, estendendo a transação local.

O mecanismo é conhecido. A aplicação grava o evento de integração na mesma transação da mudança de estado, e um processo em segundo plano cuida da publicação efetiva no barramento.

No .NET, esse processo costuma ser um hosted service, como mostramos no artigo sobre background services em C# e .NET. Ele lê a tabela, publica os eventos pendentes e marca cada um como publicado.

Essa combinação cobre a maior parte dos cenários corporativos com previsibilidade: mediador em processo para domain events, e outbox mais barramento para eventos de integração.

Um roteiro prático para domain events

Algumas perguntas ajudam a decidir se um evento deve ou não atravessar a fronteira:

  1. O evento representa um fato de negócio ou uma etapa técnica de um fluxo interno?
  2. Se este evento mudar de formato no próximo trimestre, quantos times precisarão de aviso?
  3. Algum campo do payload só faz sentido para quem conhece a implementação interna do serviço?
  4. O consumidor consegue reagir apenas com o que o evento carrega, sem consultar o produtor?
  5. Duas situações de negócio diferentes estão usando o mesmo nome de evento?

Respostas honestas a essas cinco perguntas costumam revelar contratos que nasceram por acidente. Além disso, ajudam a identificar quais domain events vazaram para fora da fronteira sem que ninguém percebesse.

Conclusão

Eventos publicados são API pública, com todas as consequências que isso implica. Por isso, definir esse contrato de forma explícita devolve à equipe a liberdade de mudar tudo que existe atrás dele.

Um evento de domínio criado hoje não precisa carregar a maldição de nunca mais poder mudar. Basta que ele não vire contrato só porque algum consumidor desconhecido passou a depender dele.

Domain events são internos, e essa é a premissa que sustenta a capacidade de evolução do sistema. Ainda assim, nada impede que um conceito interno estável seja exposto externamente, inclusive com o mesmo nome.

Isso continua sendo uma decisão consciente, e não uma consequência automática. Afinal, o modelo de dados não é o modelo de integração, e o modelo de domínio também não é.

Escolher deliberadamente o que é público é o que permite mudar o interior do sistema sem arrastar todos os consumidores junto.

FAQ

Qual é a diferença prática entre evento de domínio e evento de integração?

O evento de domínio comunica um fato dentro da fronteira do serviço e normalmente segue em processo. O evento de integração é um contrato público e assíncrono, consumido por outros serviços ou sistemas.

Posso reaproveitar a mesma classe para os dois casos?

Tecnicamente sim, mas isso elimina a fronteira que separa o interno do público. A partir daí, qualquer mudança no evento interno vira mudança de contrato.

Um evento de domínio e um evento de integração podem ter o mesmo nome?

Podem, quando representam o mesmo conceito de negócio. Ainda assim, é recomendável manter esquemas distintos, porque as necessidades internas e externas evoluem em ritmos diferentes.

Devo colocar todos os dados da entidade no evento de integração?

Não. Carregue o suficiente para o consumidor entender o fato e reagir a ele, sem transformar o evento em uma projeção do modelo de dados.

E se o consumidor precisar de mais informação do que o evento carrega?

Consultar o produtor de volta retorna o estado atual, não o estado do momento do fato. Antes de adotar essa chamada, avalie se o dado faltante deveria fazer parte do contrato.

Como garantir que o evento só seja publicado se a transação for confirmada?

O padrão outbox resolve esse ponto. Ele grava o evento em uma tabela transacional junto com a mudança de estado e publica depois, em um processo separado.

Devo criar uma biblioteca compartilhada com todos os eventos de integração?

A documentação da Microsoft desaconselha essa prática, porque ela acopla todos os serviços a uma única definição de contrato.

Quando adicionar um campo e quando criar um novo evento?

Se o campo detalha o mesmo fato, adicione o campo. Se ele existe para distinguir duas situações de negócio diferentes, provavelmente falta um evento novo.

Glossário

Termo Significado
Evento de domínio Notificação de um fato ocorrido dentro de um domínio, destinada a outras partes do mesmo domínio, geralmente em processo
Evento de integração Contrato público publicado para fora da fronteira do serviço, consumido de forma assíncrona por outros sistemas
Contexto delimitado Fronteira explícita dentro da qual um modelo de domínio e sua linguagem têm significado consistente
Agregado Conjunto de entidades e objetos de valor tratado como uma unidade de consistência transacional
Raiz do agregado Entidade que serve como único ponto de acesso ao agregado e onde os eventos de domínio costumam ser acumulados
Barramento de eventos Abstração de infraestrutura responsável por publicar e assinar eventos entre serviços
Padrão outbox Técnica que grava o evento de integração em uma tabela transacional junto com a mudança de estado, publicando depois em processo separado
Event carried state transfer Estratégia em que o evento carrega grande parte do estado da entidade para evitar consultas do consumidor ao produtor
Consistência eventual Modelo em que partes do sistema convergem para o mesmo estado ao longo do tempo, sem transação distribuída
Acoplamento estrutural Dependência de consumidores em relação ao formato interno de dados do produtor
Acoplamento temporal Dependência que exige que produtor e consumidor estejam disponíveis no mesmo instante
Event sourcing Padrão em que o estado é derivado da sequência completa de eventos armazenados, em vez do estado atual persistido
CRUD events Eventos derivados de operações de dados, como criação, atualização e exclusão, sem significado de negócio explícito
LTS Long Term Support, classificação de versões do .NET com três anos de suporte gratuito

Fontes