MÓDULO 09 · CONCEITO 10 DE 14

Padrões de Mensageria Assíncrona

Saga, Outbox, Event Sourcing, CQRS — os padrões que tornam sistemas distribuídos assíncronos corretos

Tempo de leitura ~28 min Pré-requisito 08 · Message Queues · 09 · Apache Kafka Próximo 11 · Idempotência e at-least-once delivery

Ter uma fila ou Kafka na arquitetura não torna o sistema correto — apenas assincrono. Os bugs mais sutis de sistemas distribuídos aparecem depois que a mensageria é adotada: eventos publicados mas não persistidos, transações distribuídas que ficam parcialmente concluídas, duplicatas processadas duas vezes, estado reconstruído de forma inconsistente. Os padrões deste conceito existem para resolver exatamente esses problemas.

A distinção fundamental antes de qualquer padrão: eventos, comandos e queries são tipos de mensagem com semânticas diferentes. Um evento é um fato passado imutável — OrderPlaced, PaymentFailed. Um comando é uma intenção de ação — PlaceOrder, ReserveInventory. Uma query é uma pergunta sobre estado — GetOrderStatus. Misturar os três no mesmo canal ou tratar eventos como comandos é uma fonte comum de acoplamento inadvertido e semântica confusa. Saga, Outbox e Event Sourcing trabalham especificamente com eventos e comandos. CQRS separa queries do resto.

Problema raiz: atomicidade entre banco de dados e mensageria

O problema mais fundamental de sistemas assíncronos não é desempenho nem escala — é a impossibilidade de escrever em dois sistemas distintos atomicamente sem um protocolo de duas fases. Considere o cenário clássico:

// CÓDIGO ERRADO — dual write sem garantia
async Task PlaceOrder(Order order) {
    await _db.SaveAsync(order);            // persiste no banco
    await _queue.PublishAsync(new OrderPlaced(order.Id)); // publica na fila
}

Entre o SaveAsync e o PublishAsync, o processo pode morrer. O pedido foi salvo mas o evento nunca foi publicado. Os consumidores nunca souberam do pedido. Ou, em outro cenário: o banco falha depois da publicação — o evento foi para a fila mas o pedido não existe no banco. O sistema está inconsistente em ambos os casos, e não há nenhuma transação global que corrija isso.

Duas soluções para esse problema: o Outbox Pattern (escreve evento no banco junto com os dados na mesma transação, publica depois) e o Event Sourcing (o evento é o estado — salvar o evento é a operação primária, não existe banco de domínio separado).

Outbox Pattern

O Outbox resolve o dual-write problem usando a própria transação do banco para persistir eventos antes de publicá-los. A tabela de outbox funciona como buffer transacional: na mesma transação que modifica os dados de negócio, você insere o evento na tabela outbox_events. Um processo separado (relay) lê a outbox e publica no broker. Se a publicação falha, o relay tenta novamente — o evento está salvo e nunca é perdido.

-- Esquema da outbox
CREATE TABLE outbox_events (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_type  TEXT NOT NULL,   -- 'Order', 'Payment'
    aggregate_id    TEXT NOT NULL,   -- ID do agregado
    event_type      TEXT NOT NULL,   -- 'OrderPlaced', 'PaymentFailed'
    payload         JSONB NOT NULL,
    occurred_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    published_at    TIMESTAMPTZ,     -- NULL = pendente
    sequence_nr     BIGSERIAL        -- para ordenação por agregado
);

CREATE INDEX outbox_pending ON outbox_events (occurred_at)
    WHERE published_at IS NULL;

O relay pode ser implementado de duas formas: polling (consulta periódica de eventos pendentes) ou CDC — Change Data Capture (lê o transaction log do banco via Debezium ou similar). CDC é preferível em produção porque reage imediatamente às mudanças sem polling e sem carga extra no banco.

nota O Outbox não elimina duplicatas — se o relay falhar após publicar mas antes de marcar como publicado, vai publicar de novo. Por isso o Outbox é combinado com idempotência nos consumidores (conceito 11). O Outbox garante at-least-once; a idempotência garante que duplicatas não causem efeito duplo.

Variante: Inbox Pattern

O Inbox é o espelho do Outbox, aplicado ao consumidor. Quando uma mensagem chega, você insere na tabela inbox_events e processa em transação separada. A inserção no inbox serve como deduplicação: se a mesma mensagem chegar duas vezes (broker entrega em duplicata), a segunda inserção viola a constraint de unicidade e é ignorada. O processamento acontece exatamente uma vez.

CREATE TABLE inbox_events (
    message_id    TEXT PRIMARY KEY,  -- ID único fornecido pelo broker
    event_type    TEXT NOT NULL,
    payload       JSONB NOT NULL,
    received_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    processed_at  TIMESTAMPTZ
);

Saga Pattern

Uma Saga é uma sequência de transações locais coordenadas por mensagens, onde cada etapa publica eventos/comandos que disparam a próxima etapa. Se uma etapa falha, a saga executa transações compensatórias que desfazem o efeito das etapas anteriores. Sagas são a alternativa a transações distribuídas (2PC) em sistemas assíncronos — em vez de bloquear recursos através de serviços, cada serviço confirma localmente e o sistema converge para consistência eventual.

Existem dois estilos de coordenação:

Coreografia (choreography)

Cada serviço reage a eventos publicados por outros serviços sem um coordenador central. A saga emerge do comportamento coletivo dos participantes.

Order Service                Payment Service           Inventory Service
     │                              │                          │
     │ OrderPlaced ─────────────────►                          │
     │                              │ PaymentProcessed ────────►
     │                              │                          │ InventoryReserved
     │◄─────────────────────────────────────────────────────────
     │ OrderConfirmed               │                          │
     │                              │                          │
     │ (falha: estoque insuficiente)│                          │
     │                              │◄──────── InventoryFailed
     │◄──────── PaymentRefunded     │                          │
     │ OrderCancelled               │                          │

Prós: baixo acoplamento, sem SPOF central, cada serviço é autônomo. Contras: difícil de rastrear o fluxo completo da saga, lógica de compensação espalhada, hard to debug quando algo quebra no meio.

Orquestração (orchestration)

Um orquestrador central (saga orchestrator ou process manager) coordena os participantes enviando comandos e recebendo respostas. O orquestrador sabe em qual estado a saga está e o que fazer a seguir.

OrderSagaOrchestrator
     │
     ├── Envia: ReserveInventory → Inventory Service
     │   └── Recebe: InventoryReserved ou InventoryFailed
     │
     ├── Envia: ProcessPayment → Payment Service
     │   └── Recebe: PaymentProcessed ou PaymentFailed
     │
     ├── Envia: ConfirmOrder → Order Service
     │   └── Recebe: OrderConfirmed
     │
     └── Em falha: Envia compensações na ordem inversa
         ├── ReleaseInventory → Inventory Service
         └── RefundPayment → Payment Service

Prós: fluxo visível em um lugar, fácil de adicionar steps, rastreamento natural, lógica de compensação centralizada. Contras: o orquestrador se torna um componente crítico, pode virar um anti-pattern "God orchestrator" se absorver lógica de negócio dos participantes.

atenção Compensação não é rollback. Rollback desfaz uma operação como se nunca tivesse acontecido. Compensação é uma operação de negócio que nega o efeito — um estorno de pagamento, uma liberação de estoque. A janela entre a operação original e a compensação é real: um email de confirmação pode ter sido enviado, um relatório pode ter sido gerado, um terceiro pode ter sido notificado. Projete compensações com consciência desses efeitos colaterais.

Event Sourcing

Event Sourcing inverte o modelo tradicional de persistência. Em vez de armazenar o estado atual de uma entidade (a linha na tabela orders reflete o estado mais recente), você armazena a sequência de eventos que levou a esse estado. O estado atual é derivado — reconstruído aplicando os eventos em ordem. O event store é a fonte de verdade; o estado atual é uma projeção.

-- Modelo tradicional (state-based)
orders: { id, status: "confirmed", total: 150.00, ... }

-- Event Sourcing (event-based)
order_events:
  { order_id, seq: 1, type: "OrderPlaced",    data: {items: [...], total: 150.00} }
  { order_id, seq: 2, type: "PaymentApplied", data: {method: "credit", amount: 150.00} }
  { order_id, seq: 3, type: "OrderConfirmed", data: {confirmed_at: "..."} }

-- Estado atual: reduce(events, applyEvent, initialState)

Propriedades que emergem do Event Sourcing: audit log completo (cada mudança de estado é rastreada, com quem/quando/por quê), time travel (reconstituir o estado em qualquer ponto no tempo), retroactive corrections (adicionar novos eventos que corrigem interpretações antigas sem destruir histórico), e múltiplas projeções (o mesmo stream de eventos pode gerar leituras otimizadas para diferentes casos de uso).

O preço é real: reconstruir estado de uma sequência longa de eventos é caro. A solução são snapshots — periodicamente persistir o estado atual como checkpoint, e ao reconstruir, partir do snapshot mais recente em vez do início do stream. Um snapshot do evento 1000 permite reconstruir o estado atual carregando apenas os eventos de 1001 em diante.

nota Event Sourcing não é Event-Driven Architecture. EDA é sobre comunicação entre serviços via eventos. Event Sourcing é sobre persistência dentro de um bounded context. Os dois se combinam bem — um serviço event-sourced publica eventos do store como eventos de integração — mas são soluções independentes para problemas independentes.

CQRS — Command Query Responsibility Segregation

CQRS separa o modelo de escrita (commands) do modelo de leitura (queries). Em vez de uma única API que tanto lê quanto escreve usando o mesmo modelo de domínio, você tem dois lados: o command side processa mutações e produz eventos; o query side mantém projeções otimizadas para leitura, construídas a partir desses eventos.

┌─────────────────────────────────────────────────────────┐
│                       Cliente                           │
│                          │                              │
│          ┌───────────────┴───────────────┐              │
│          ▼                               ▼              │
│   Command Handler                  Query Handler        │
│   (PlaceOrder, etc.)               (GetOrder, etc.)     │
│          │                               │              │
│          ▼                               ▼              │
│   Event Store / DB            Read Models (Redis,       │
│   (escrita normalizada)        Elasticsearch, views)    │
│          │                               ▲              │
│          └───── Event Projector ─────────┘              │
│                 (consome eventos, atualiza read models)  │
└─────────────────────────────────────────────────────────┘

O benefício prático: o modelo de leitura é uma projeção desnormalizada, construída especificamente para as consultas que o cliente faz. Não há joins, não há mapeamento ORM complexo. O modelo de escrita é normalizado e reflete o domínio com precisão. Os dois evoluem independentemente. Se o padrão de leitura muda (novo relatório, nova tela), você cria uma nova projeção sem tocar no modelo de escrita.

CQRS tem graus. No mínimo, é só separar os métodos de leitura dos de escrita no mesmo repositório. No máximo, são bancos de dados físicos separados, stores diferentes, e consistência eventual entre o lado de escrita e as projeções de leitura. A consistência eventual é o preço do CQRS completo: após um comando, a projeção de leitura pode estar alguns milissegundos (ou mais) desatualizada.

atenção CQRS é frequentemente superdimensionado. Em sistemas que não têm pressão de escala separada em leitura e escrita, o custo de manter dois modelos supera o benefício. Aplique CQRS quando o modelo de leitura e escrita genuinamente divergem, quando você precisa de projeções especializadas, ou quando vai combinar com Event Sourcing. Não aplique por padrão.

Event-carried State Transfer

Quando serviços precisam de dados de outros serviços, existem duas opções: perguntar ao serviço dono na hora da necessidade (query síncrona ou assíncrona) ou receber os dados junto com o evento que notifica sobre mudanças. O segundo padrão é Event-Carried State Transfer (ECST).

// Sem ECST: Order Service precisa consultar Customer Service para obter o endereço
{
  "type": "OrderPlaced",
  "order_id": "ord-123",
  "customer_id": "cust-456"  // receptor precisa buscar o endereço separadamente
}

// Com ECST: dados relevantes viajam junto com o evento
{
  "type": "OrderPlaced",
  "order_id": "ord-123",
  "customer_id": "cust-456",
  "shipping_address": {     // receptor tem tudo que precisa
    "street": "Av. Paulista, 1000",
    "city": "São Paulo",
    "postal_code": "01310-100"
  }
}

ECST elimina chamadas síncronas para enriquecer eventos — o receptor é autossuficiente com os dados do evento. O trade-off: os eventos ficam maiores, e se o schema dos dados embutidos mudar, os consumidores podem precisar de migração. Use ECST para dados que raramente mudam e que múltiplos consumidores precisarão — endereços, nomes de usuário, dados de produto no momento da compra (importante: snapshot do dado no momento do evento, não referência mutável).

Transactional Messaging

Transactional messaging é o conjunto de práticas que garante que a publicação de mensagens seja atômica com as operações de negócio. O Outbox é a implementação mais comum, mas existem outras abordagens:

CDC (Change Data Capture): Debezium captura mudanças no transaction log do Postgres/MySQL e as transforma em eventos Kafka. O banco é a fonte de verdade; o Kafka é derivado. Não requer alteração no código de aplicação — mas o schema do banco vira o contrato do evento, o que é perigoso.

Exactly-once com Kafka transactions: Kafka suporta transações nativas desde 0.11 — o produtor pode abrir uma transação, publicar em múltiplos tópicos, e commitar ou abortar atomicamente. Combinado com read_committed no consumidor, garante exactly-once dentro do ecossistema Kafka. A limitação: funciona apenas Kafka-to-Kafka. Se a operação envolve um banco de dados externo, volta ao problema do dual write.

Two-Phase Commit (2PC): Coordina uma transação distribuída através de múltiplos recursos. Funciona, mas tem locking de longa duração, é blocking se o coordenador falha, e poucos brokers de mensagens modernos suportam XA transactions. Evite em sistemas de alta escala.

Comparativo dos padrões

Padrão Problema resolvido Complexidade Quando usar
Outbox Dual write problem Baixa-média Sempre que publicar eventos em transação com banco
Inbox Deduplicação de consumidores Baixa Consumidores que precisam de exactly-once
Saga (coreografia) Transações distribuídas sem 2PC Média Fluxos simples entre poucos serviços autônomos
Saga (orquestração) Transações distribuídas com visibilidade Alta Fluxos complexos, múltiplas compensações, rastreamento
Event Sourcing Persistência com histórico completo Alta Domínios com auditoria, time travel, múltiplas projeções
CQRS Modelos de leitura/escrita divergentes Média-alta Relatórios complexos, alta carga de leitura, ES
ECST Dependência síncrona para enriquecimento Baixa Dados estáveis que múltiplos consumidores precisam

Comparativo entre linguagens — Outbox Pattern

O Outbox é o padrão mais universal — aparece em quase todo sistema que combina banco de dados com mensageria. A implementação difere em como cada ecossistema lida com transações e polling.

C# — MassTransit Outbox / Marten
// C# — Outbox com EF Core e MassTransit Outbox integrado
// MassTransit tem suporte nativo ao Outbox — sem polling manual

// Program.cs
services.AddMassTransit(x => {
    x.AddEntityFrameworkOutbox<AppDbContext>(o => {
        o.UsePostgres();          // dialeto do banco
        o.UseBusOutbox();         // relay integrado ao bus
        o.QueryDelay = TimeSpan.FromSeconds(5);
        o.QueryTimeout = TimeSpan.FromSeconds(30);
    });
    x.AddConsumer<OrderPlacedConsumer>();
    x.UsingRabbitMq((ctx, cfg) => cfg.ConfigureEndpoints(ctx));
});

// Entidade de outbox gerada pelo EF Core (tabela outbox_message)
// Criada automaticamente via migration

// Handler: a publicação acontece dentro da transação do EF Core
public class PlaceOrderHandler(AppDbContext db, IPublishEndpoint bus) {
    public async Task Handle(PlaceOrderCommand cmd, CancellationToken ct) {
        var order = Order.Place(cmd.CustomerId, cmd.Items);

        db.Orders.Add(order);

        // Publicação vai para a tabela de outbox, não para o RabbitMQ diretamente
        await bus.Publish(new OrderPlaced(order.Id, order.CustomerId), ct);

        // SaveChanges persiste tanto o Order quanto a mensagem do outbox
        // na mesma transação — atomicidade garantida
        await db.SaveChangesAsync(ct);

        // O relay do MassTransit lê a outbox e publica no RabbitMQ em background
    }
}

// Para Event Sourcing com Marten (PostgreSQL event store)
services.AddMarten(opts => {
    opts.Connection(connectionString);
    opts.Events.AddEventType<OrderPlaced>();
    opts.Events.AddEventType<PaymentApplied>();
}).AddAsyncDaemon(DaemonMode.HotCold); // daemon de projeção

public class OrderProjection : MultiStreamProjection<OrderReadModel, Guid> {
    public OrderProjection() {
        Identity<OrderPlaced>(e => e.OrderId);
        Identity<PaymentApplied>(e => e.OrderId);
    }

    public void Apply(OrderReadModel model, OrderPlaced evt) {
        model.Status = "placed";
        model.CustomerId = evt.CustomerId;
    }

    public void Apply(OrderReadModel model, PaymentApplied evt) {
        model.Status = "paid";
        model.PaidAt = evt.PaidAt;
    }
}

MassTransit Outbox é a implementação mais robusta para o ecossistema .NET — integra diretamente com EF Core, gerencia o relay automaticamente e suporta múltiplos bancos. Marten é a solução de event sourcing mais madura para Postgres em C#, com daemon de projeção built-in.

Python — SQLAlchemy + polling relay
# Python — Outbox com SQLAlchemy e polling manual
# Não existe biblioteca equivalente ao MassTransit; polling é o padrão

import asyncio
import json
import uuid
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
import aio_pika

# Tabela de outbox (SQLAlchemy Core ou ORM)
# CREATE TABLE outbox_events (
#     id UUID PRIMARY KEY,
#     aggregate_type TEXT,
#     aggregate_id TEXT,
#     event_type TEXT,
#     payload JSONB,
#     occurred_at TIMESTAMPTZ,
#     published_at TIMESTAMPTZ
# )

async def place_order(session: AsyncSession, command: dict) -> str:
    order_id = str(uuid.uuid4())

    # Operação de negócio
    await session.execute(
        text("INSERT INTO orders (id, customer_id, status) VALUES (:id, :cid, 'placed')"),
        {"id": order_id, "cid": command["customer_id"]}
    )

    # Inserção no outbox NA MESMA transação
    event_payload = json.dumps({
        "order_id": order_id,
        "customer_id": command["customer_id"],
        "items": command["items"],
    })
    await session.execute(
        text("""
            INSERT INTO outbox_events (id, aggregate_type, aggregate_id, event_type, payload, occurred_at)
            VALUES (:id, 'Order', :agg_id, 'OrderPlaced', :payload::jsonb, :now)
        """),
        {
            "id": str(uuid.uuid4()),
            "agg_id": order_id,
            "payload": event_payload,
            "now": datetime.now(timezone.utc),
        }
    )

    await session.commit()  # banco + outbox atomicamente
    return order_id


# Relay: polling da outbox e publicação no broker
async def outbox_relay(session_factory, amqp_url: str):
    connection = await aio_pika.connect_robust(amqp_url)
    channel = await connection.channel()
    exchange = await channel.declare_exchange("domain-events", aio_pika.ExchangeType.TOPIC, durable=True)

    while True:
        async with session_factory() as session:
            rows = await session.execute(
                text("""
                    SELECT id, event_type, payload
                    FROM outbox_events
                    WHERE published_at IS NULL
                    ORDER BY occurred_at
                    LIMIT 100
                    FOR UPDATE SKIP LOCKED  -- evita conflito com outras instâncias do relay
                """)
            )
            events = rows.fetchall()

            for event_id, event_type, payload in events:
                routing_key = event_type.lower().replace(".", "_")
                await exchange.publish(
                    aio_pika.Message(
                        body=json.dumps(payload).encode(),
                        content_type="application/json",
                        message_id=str(event_id),
                        delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
                    ),
                    routing_key=routing_key,
                )
                await session.execute(
                    text("UPDATE outbox_events SET published_at = NOW() WHERE id = :id"),
                    {"id": event_id}
                )

            await session.commit()

        await asyncio.sleep(1)  # intervalo de polling

FOR UPDATE SKIP LOCKED é essencial para rodar múltiplas instâncias do relay sem conflito — cada instância pega um lote diferente de eventos. O intervalo de polling de 1s é conservador; em produção ajuste para 100-500ms dependendo da latência tolerável.

Go — watermill Outbox
// Go — Outbox com pgx e watermill como event bus
// watermill tem suporte a Outbox com Postgres publisher

package outbox

import (
    "context"
    "encoding/json"
    "time"

    "github.com/ThreeDotsLabs/watermill"
    "github.com/ThreeDotsLabs/watermill-sql/v2/pkg/sql"
    "github.com/ThreeDotsLabs/watermill/message"
    "github.com/jackc/pgx/v5"
)

type OrderEvent struct {
    OrderID    string `json:"order_id"`
    CustomerID string `json:"customer_id"`
}

// PlaceOrder: persiste pedido e evento no outbox na mesma transação
func PlaceOrder(ctx context.Context, tx pgx.Tx, pub message.Publisher, cmd PlaceOrderCmd) (string, error) {
    orderID := uuid.New().String()

    _, err := tx.Exec(ctx,
        "INSERT INTO orders (id, customer_id, status) VALUES ($1, $2, 'placed')",
        orderID, cmd.CustomerID,
    )
    if err != nil {
        return "", fmt.Errorf("inserir pedido: %w", err)
    }

    payload, _ := json.Marshal(OrderEvent{OrderID: orderID, CustomerID: cmd.CustomerID})
    msg := message.NewMessage(watermill.NewUUID(), payload)

    // Publisher SQL do watermill escreve na tabela de outbox
    // usando a transação pgx existente
    if err := pub.Publish("OrderPlaced", msg); err != nil {
        return "", fmt.Errorf("outbox publish: %w", err)
    }

    return orderID, nil // caller faz tx.Commit()
}

// Setup do publisher outbox (watermill-sql)
func NewOutboxPublisher(db *sql.DB, logger watermill.LoggerAdapter) (message.Publisher, error) {
    return sql.NewPublisher(db, sql.PublisherConfig{
        SchemaAdapter:        sql.DefaultPostgreSQLSchema{},
        AutoInitializeSchema: true,
    }, logger)
}

// Subscriber: relay que lê outbox e publica no AMQP/Kafka
func StartRelay(ctx context.Context, db *sql.DB, amqpPublisher message.Publisher, logger watermill.LoggerAdapter) error {
    subscriber, err := sql.NewSubscriber(db, sql.SubscriberConfig{
        SchemaAdapter:    sql.DefaultPostgreSQLSchema{},
        OffsetsAdapter:   sql.DefaultPostgreSQLOffsetsAdapter{},
        InitializeSchema: true,
    }, logger)
    if err != nil {
        return err
    }

    router, err := message.NewRouter(message.RouterConfig{}, logger)
    if err != nil {
        return err
    }

    // Rota: outbox → AMQP
    router.AddHandler(
        "outbox-relay",
        "OrderPlaced",        // tópico SQL (outbox)
        subscriber,
        "order.placed",       // tópico AMQP de destino
        amqpPublisher,
        func(msg *message.Message) ([]*message.Message, error) {
            return message.Messages{msg}, nil // passthrough
        },
    )

    return router.Run(ctx)
}

watermill é a biblioteca de mensageria mais completa para Go — suporta múltiplos backends (RabbitMQ, Kafka, SQL, Redis) com a mesma interface Publisher/Subscriber. O watermill-sql implementa o Outbox Pattern nativamente com Postgres, incluindo relay automático via subscriber.

Decisões de engenharia

Outbox Pattern vs Dual Write direto

Use Outbox Pattern quando consistência entre o banco de dados e o broker é requisito: o evento não pode ser perdido nem publicado sem que o dado de negócio tenha sido confirmado. O Outbox garante atomicidade local usando a própria transação do banco. O custo é a latência adicional do relay e a necessidade de processo separado rodando (seja polling ou CDC via Debezium).

Dual write direto pode ser aceitável apenas quando a mensagem é best-effort e perda é tolerável — por exemplo, notificações de UI onde o usuário pode recarregar. Se perder a mensagem tem efeito de negócio (pedido não processado, pagamento não comunicado), Outbox é obrigatório. A pergunta não é "qual é mais simples" mas "o sistema fica correto quando o processo morre entre as duas escritas?"

Saga: Coreografia vs Orquestração

Coreografia favorece independência de deploy e baixo acoplamento: cada serviço reage a eventos sem saber de outros serviços. É mais fácil de escalar e não tem ponto central de falha. Mas é difícil de debugar — a lógica da saga fica distribuída, e rastrear o estado de um fluxo requer correlacionar eventos em múltiplos tópicos.

Orquestração coloca a lógica de coordenação em um único lugar (o orquestrador), facilitando visibilidade e diagnóstico. O orquestrador sabe o estado exato da saga a qualquer momento. O custo é acoplamento: o orquestrador conhece todos os participantes. Prefira orquestração quando o fluxo tem muitas ramificações condicionais ou quando rastreabilidade é crítica para operações.

Event Sourcing vs CRUD com estado atual

Event Sourcing é adequado quando o histórico de mudanças tem valor de negócio intrínseco (auditoria financeira, histórico médico, carrinho de compras com análise de abandono), quando você precisa reconstruir o estado em qualquer ponto no tempo, ou quando múltiplas projeções derivadas do mesmo agregado são necessárias. O custo é complexidade: event store, snapshots, versioning de eventos, e eventual consistency das projeções.

CRUD tradicional é simples, familiar e adequado para a maioria dos domínios. A decisão por Event Sourcing deve ser guiada por requisitos concretos — auditoria, time-travel queries, múltiplas projeções — não por hype. Adotar Event Sourcing sem necessidade real transforma um problema simples de CRUD em um problema complexo de gerenciamento de eventos.

CQRS puro vs CQRS + Event Sourcing

CQRS puro (sem Event Sourcing) separa o modelo de leitura do modelo de escrita, mas ambos usam estado atual. É útil quando queries têm requisitos de performance muito diferentes das escritas — por exemplo, um agregado com lógica de negócio complexa no write side e uma view desnormalizada no read side. É incremental: você adiciona ao sistema existente sem mudar o core.

CQRS + Event Sourcing faz sentido quando você já precisa do log de eventos por outras razões. O Event Sourcing naturalmente suporta CQRS — você re-processa eventos para gerar qualquer projeção. Mas a combinação dobra a complexidade. Não adote os dois apenas porque são frequentemente mencionados juntos: avalie se você realmente precisa do histórico completo antes de comprometer com Event Sourcing.

Exercícios práticos

  1. Implemente o Outbox Pattern do zero sem bibliotecas: crie a tabela outbox_events, escreva o handler que insere evento junto com dados de negócio na mesma transação, e implemente o relay com FOR UPDATE SKIP LOCKED. Simule falha do processo entre o commit e a publicação (kill -9) e valide que o relay recupera e publica todos os eventos pendentes ao reiniciar.
    Critério: nenhum evento é perdido após kill do processo entre commit e publicação; relay reprocessa sem duplicar eventos já publicados.
  2. Implemente uma Saga de criação de pedido com orquestração: Order Service → Reserve Inventory → Process Payment → Confirm Order. Inclua compensação para cada etapa (cancelar reserva, estornar pagamento). Use uma máquina de estados explícita no orquestrador com estados PENDING | INVENTORY_RESERVED | PAYMENT_PROCESSED | CONFIRMED | COMPENSATING | FAILED.
    Critério: Quando Payment falha, InventoryReservation é cancelada automaticamente; o estado final da saga é FAILED e o Order não aparece como confirmado.
  3. Compare coreografia vs orquestração para o mesmo fluxo de confirmação de pedido. Depois de implementar os dois, adicione o requisito de envio de email após confirmação. Documente: quantos serviços foram modificados em cada abordagem, onde a lógica da saga é visível, e como você rastrearia um fluxo específico em produção.
    Critério: análise escrita com métricas concretas — número de arquivos modificados, onde está a lógica de negócio do fluxo em cada abordagem.
  4. Implemente Event Sourcing para o agregado BankAccount com eventos AccountOpened, MoneyDeposited, MoneyWithdrawn. Adicione snapshots a cada 50 eventos. Implemente GetBalanceAt(DateTime point) que reconstrói o saldo exato em qualquer ponto no tempo. Crie duas projeções separadas: saldo atual e histórico do último mês.
    Critério: GetBalanceAt retorna saldo correto para timestamps arbitrários; a projeção de saldo atual é consistente com a reconstrução completa do evento store.
  5. Combine CQRS com Event Sourcing: o command side usa event store (Postgres + tabela de eventos), o query side mantém uma projeção Redis com saldo atual atualizada por um projector assíncrono. Meça a latência de consistência entre escrita e leitura. Implemente "read your own writes" usando o número de sequência do último evento do usuário para esperar pela projeção antes de retornar.
    Critério: p99 de consistência da projeção medido e documentado; "read your own writes" funciona corretamente sem polling incondicional.

Perguntas de entrevista

    Por que dual write é inseguro e como o Outbox Pattern resolve esse problema?

    Dual write significa escrever em dois sistemas independentes (banco + broker) sequencialmente sem garantia de atomicidade. O processo pode morrer entre as duas operações, resultando em estado inconsistente: dado salvo mas evento não publicado, ou evento publicado mas dado não salvo.

    Outbox Pattern usa a transação do banco como mecanismo de coordenação. O evento é escrito na tabela outbox_events na mesma transação que modifica os dados de negócio. A atomicidade é garantida pelo banco. Um relay separado lê eventos pendentes e os publica no broker — se o relay falhar, tenta novamente (at-least-once). O banco é a fonte de verdade; o broker recebe eventualmente.

    Variante CDC: em vez de relay por polling, usa-se Change Data Capture (Debezium) que lê o transaction log do banco diretamente, eliminando o polling e reacionando imediatamente a mudanças sem carga extra no banco.

    O Outbox não elimina duplicatas — garante at-least-once. Por isso é combinado com idempotência no consumidor (Inbox Pattern ou deduplicação por message ID).

    Qual a diferença entre coreografia e orquestração de Sagas? Quando você prefere cada uma?

    Coreografia: não há coordenador central. Cada serviço reage a eventos e publica os seus. A saga é o comportamento emergente. Vantagens: baixo acoplamento, cada serviço é independente, sem SPOF de coordenação. Desvantagens: lógica de negócio distribuída em múltiplos serviços, difícil rastrear estado global da saga, difícil lidar com timeouts.

    Orquestração: um orquestrador central emite comandos e reage a respostas. Conhece o estado completo da saga. Vantagens: lógica centralizada e visível, fácil auditar estado, naturalmente suporta timeout e compensação condicional. Desvantagens: acoplamento ao orquestrador, SPOF se mal implementado.

    Regra prática: prefiro orquestração quando o fluxo tem mais de 3-4 participantes, quando há lógica condicional de compensação complexa, ou quando operações precisam de timeout explícito. Prefiro coreografia quando os serviços são verdadeiramente independentes e o fluxo é simples e linear.

    O que é Event Sourcing e qual a diferença entre evento de domínio e evento de integração?

    Event Sourcing: em vez de persistir o estado atual de um agregado, você persiste a sequência de eventos que levou a esse estado. O estado atual é derivado re-aplicando os eventos. O event store é append-only e imutável — nunca se apaga nem modifica eventos.

    Evento de domínio: interno ao bounded context, captura uma mudança significativa no estado de negócio. Contém todos os dados necessários para reconstruir o estado. Ex: OrderPlaced { orderId, customerId, items[], totalAmount, placedAt }. Altamente coeso com o modelo de domínio.

    Evento de integração: publicado para outros bounded contexts via broker. Deve ser estável e retrocompatível — não pode mudar livremente porque outros serviços dependem dele. Geralmente contém menos dados que o evento de domínio (apenas o necessário para integrações) e tem versionamento explícito.

    Um fluxo típico: evento de domínio é salvo no event store → handler publica evento de integração no Kafka/RabbitMQ → outros serviços consomem. As duas estruturas podem ser idênticas ou diferentes dependendo das necessidades.

    O que é CQRS e quando ele faz sentido versus quando é over-engineering?

    CQRS (Command Query Responsibility Segregation): separa o modelo de escrita (commands) do modelo de leitura (queries). O write side tem um modelo rico de domínio que garante invariantes de negócio. O read side tem modelos desnormalizados otimizados para consultas específicas. Os dois podem usar tecnologias diferentes: write side em Postgres com ORM rico, read side em Redis ou Elasticsearch.

    Quando faz sentido: quando queries têm requisitos muito diferentes das escritas (ex: relatórios complexos sobre dados cujas escritas são simples transações), quando a carga de leitura é ordens de magnitude maior que a de escrita (escalando os dois independentemente), ou quando diferentes projeções do mesmo dado são necessárias para diferentes casos de uso.

    Quando é over-engineering: para a maioria dos sistemas CRUD padrão, CQRS adiciona complexidade sem benefício — você mantém dois modelos, lidera com eventual consistency entre eles, e adiciona infraestrutura. Se o mesmo banco serve reads e writes adequadamente, CQRS é prematuro. Comece simples; aplique CQRS quando a separação tiver valor concreto e mensurável.

    Como Outbox + idempotência formam um sistema at-least-once semanticamente seguro?

    O problema: nenhum sistema distribuído real consegue exactly-once delivery end-to-end. Brokers garantem at-least-once (mensagem entregue pelo menos uma vez, podendo ser duplicada em falhas). O Outbox garante que o evento será publicado, mas o relay pode publicar mais de uma vez se falhar após publicar mas antes de marcar como publicado.

    Outbox resolve o problema do produtor: garante que eventos não são perdidos, mas aceita duplicatas. O relay é idempotente por design — re-publicar o mesmo evento é aceitável porque o consumidor vai tratar.

    Idempotência no consumidor resolve o problema da duplicata: antes de processar uma mensagem, verifica se já foi processada (via message ID único). Se sim, ignora. Se não, processa e registra o ID. O Inbox Pattern formaliza isso: insere o message ID em uma tabela de inbox com constraint de unicidade; a segunda chegada da mesma mensagem falha na inserção e não é processada.

    Resultado: o sistema como um todo tem semântica effectively-once — cada evento tem efeito de negócio exatamente uma vez, mesmo que seja entregue múltiplas vezes. Isso é o máximo que se consegue em sistemas distribuídos sem 2PC.

Referências

  1. livro Designing Data-Intensive Applications — Martin Kleppmann (O'Reilly, 2017). Capítulos 11 e 12 cobrem stream processing, event sourcing e os trade-offs de consistency em sistemas distribuídos com profundidade inigualável. Leitura obrigatória para entender por que esses padrões existem.
  2. artigo Pattern: Saga — Chris Richardson, microservices.io. microservices.io/patterns/data/saga.html — A referência canônica para Sagas em microsserviços, com exemplos de coreografia e orquestração e discussão detalhada sobre compensações.
  3. artigo Pattern: Transactional Outbox — Chris Richardson, microservices.io. microservices.io/patterns/data/transactional-outbox.html — Descrição precisa do Outbox Pattern com variantes de relay (polling vs CDC) e discussão sobre trade-offs.
  4. artigo CQRS Documents — Greg Young (2010). cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf — O documento original de Greg Young introduzindo CQRS e Event Sourcing como padrões. Mais direto e técnico do que a maioria dos artigos posteriores sobre o assunto.
  5. docs MassTransit Outbox Documentation — MassTransit. masstransit.io/documentation/patterns/transactional-outbox — Implementação de referência do Outbox para .NET com EF Core, MongoDB e outros backends. Inclui detalhes sobre o relay, retry e monitoramento.
  6. livro Enterprise Integration Patterns — Gregor Hohpe & Bobby Woolf (Addison-Wesley, 2003). O catálogo definitivo de padrões de integração com mensagens: Message Channel, Message Router, Correlation ID, Dead Letter Channel, Competing Consumers e mais de 60 outros. A linguagem usada em quase toda discussão de mensageria moderna vem deste livro.
  7. artigo Pattern: Event Sourcing — Chris Richardson, microservices.io. microservices.io/patterns/data/event-sourcing.html — Descrição estruturada do padrão com contexto de aplicação, trade-offs, e problemas que resolve e que cria. Parte do catálogo de padrões de microsserviços de referência.
  8. artigo Pattern: CQRS — Chris Richardson, microservices.io. microservices.io/patterns/data/cqrs.html — Definição precisa de CQRS com discussão de quando aplicar, benefícios de escalabilidade independente de reads e writes, e os desafios de manter os dois modelos sincronizados.
  9. blog Outbox Pattern with Debezium — Change Data Capture for Microservices — Gunnar Morling (Debezium, 2019). debezium.io/blog/2019/02/19/reliable-microservices-data-exchange-with-the-outbox-pattern — Explica como usar CDC via Debezium como relay do Outbox, eliminando o polling. Inclui configuração do Kafka Connect com o Outbox Event Router SMT para roteamento de eventos por tipo.
  10. vídeo CQRS and Event Sourcing — Greg Young (GOTO 2014). youtube.com/watch?v=JHGkaShoyNs — A palestra original de Greg Young onde ele articula CQRS e Event Sourcing, desmistifica equívocos comuns (CQRS não requer Event Sourcing, Event Sourcing não requer CQRS), e explica quando cada um faz sentido independentemente.
  11. artigo Distributed Sagas: A Protocol for Coordinating Microservices — Caitie McCaffrey (Strange Loop, 2015). Apresentação que formaliza Sagas como protocolo de coordenação distribuída, derivado do paper original de Garcia-Molina & Salem (1987). Cobre backward recovery (compensação) vs forward recovery (retry), e como implementar Sagas com garantias de progresso.
  12. docs MassTransit Saga Documentation — MassTransit. masstransit.io/documentation/patterns/saga — Implementação de Sagas com state machine em .NET. Inclui exemplos de Saga Orchestrator com compensação, configuração de persistence (Entity Framework, Redis, MongoDB) e observabilidade do estado da saga.