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.
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.
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.
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.
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# — 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 — 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 — 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
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?"
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 é 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 (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
-
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 comFOR 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. -
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 éFAILEDe o Order não aparece como confirmado. -
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. -
Implemente Event Sourcing para o agregado
BankAccountcom eventosAccountOpened,MoneyDeposited,MoneyWithdrawn. Adicione snapshots a cada 50 eventos. ImplementeGetBalanceAt(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:GetBalanceAtretorna saldo correto para timestamps arbitrários; a projeção de saldo atual é consistente com a reconstrução completa do evento store. -
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
- livro Designing Data-Intensive Applications — Martin Kleppmann (O'Reilly, 2017).
- artigo Pattern: Saga — Chris Richardson, microservices.io.
- artigo Pattern: Transactional Outbox — Chris Richardson, microservices.io.
- artigo CQRS Documents — Greg Young (2010).
- docs MassTransit Outbox Documentation — MassTransit.
- livro Enterprise Integration Patterns — Gregor Hohpe & Bobby Woolf (Addison-Wesley, 2003).
- artigo Pattern: Event Sourcing — Chris Richardson, microservices.io.
- artigo Pattern: CQRS — Chris Richardson, microservices.io.
- blog Outbox Pattern with Debezium — Change Data Capture for Microservices — Gunnar Morling (Debezium, 2019).
- vídeo CQRS and Event Sourcing — Greg Young (GOTO 2014).
- artigo Distributed Sagas: A Protocol for Coordinating Microservices — Caitie McCaffrey (Strange Loop, 2015).
- docs MassTransit Saga Documentation — MassTransit.