Motivação do estudo
O objetivo do CritterStackStudy é entender, na prática, o que o Critter Stack — a dupla Marten (Postgres como event store e document database) e Wolverine (mediator in-process, message bus, outbox transacional), ambos da JasperFx — realmente tira do caminho quando se implementa CQRS via event sourcing.
A tese do estudo: nada é guardado como “estado atual” diretamente. Toda mudança é um evento imutável
(OrderItemAdded, StockReserved, …) anexado a uma stream no Postgres. As classes Order e
InventoryItem existem só pra dobrar (fold) esses eventos num formato em memória na hora de decidir algo
(“esse pedido ainda está Open?”). O lado de leitura (OrderSummary, InventoryItemView,
OperationsDashboard) é reconstruído a partir dos mesmos eventos, mas reformatado de propósito — sem
espelhar o modelo de escrita só porque compartilham banco. E tudo isso sem repositório, sem service layer,
sem broker de mensagens externo: um único Postgres guarda streams de evento, outbox e documentos de
projeção lado a lado.
O domínio escolhido — um sisteminha de pedidos e estoque — existe só pra tornar os conceitos do Critter
Stack concretos, não o contrário. Dois agregados (Order e InventoryItem) são o menor par que ainda
força uma interação genuinamente cross-aggregate: submeter um pedido precisa reservar estoque numa stream
diferente — exatamente o cenário em que o outbox transacional do Wolverine importa. E a etapa de
fulfillment (pagamento, envio, timeouts) é um processo de longa duração spaneando várias mensagens ao
longo do tempo — exatamente o cenário pra uma saga do Wolverine, porque um único handler de comando
não segura “espera algo que pode não acontecer por 20 segundos”.
O que foi feito
- Event sourcing como modelo de escrita:
OrdereInventoryItemsão agregados de escrita reconstruídos a partir de streams de eventos no Postgres via Marten — nenhuma tabela guarda “o pedido atual”, só o histórico de eventos que o compõe. - CQRS com formatos de leitura genuinamente diferentes:
OrderSummaryeInventoryItemViewsão documentos Marten projetados inline (síncrono, na mesma transação da escrita) — linhas de pedido achatadas com total corrente, trilha de auditoria de reservas — porque quem chama espera ler a própria escrita imediatamente. - Projeção assíncrona cross-stream:
OperationsDashboardProjectioné umaMultiStreamProjectionregistrada comoAsync, reconstruída fora de banda pelo async daemon do Marten — agrega eventos de ambas as streams (Order e InventoryItem) num único documento viaCustomGrouping, prova de que uma projeção não precisa espelhar um único agregado. - Outbox transacional: o handler de
SubmitOrderanexaOrderSubmittedna stream do pedido, publicaReserveStockpor item de linha, e publicaBeginOrderFulfillmentpra iniciar a saga — as três coisas na mesma transação Postgres, via a integração do Wolverine com o Marten. Se a transação fizer rollback, nenhuma das mensagens de saída foi “enviada” de verdade — a resposta do Wolverine pro clássico problema do dual-write. - Saga
OrderFulfillmentSaga: coordena reserva de estoque → pagamento → envio através de mensagens que chegam minutos (ou segundos) separadas, de fontes diferentes, persistindo seu próprio documento de estado no mesmo storage Marten que o outbox já usa. Se auto-cancela numa reserva rejeitada ou num timeout de pagamento. - Política de retry com cooldown:
ConfirmPaymentchama umIPaymentGatewaydeliberadamente instável que falha as duas primeiras tentativas de cobrança de um pedido. A políticaOnException<PaymentGatewayUnavailableException>().RetryWithCooldown(...)do Wolverine (emProgram.cs) reexecuta o handler inteiro — inclusive recarregando a saga — de forma transparente, com backoff, até funcionar ou esgotar as tentativas (quando o Wolverine manda a mensagem pra dead-letter automaticamente). Violação de regra de domínio (ex.: “pedido já estáShipped”) continua lançandoInvalidOperationExceptionpuro e falhando rápido — só o tipo de falha genuinamente transiente é retentado. - 21 testes unitários novos: agregados, handlers e a saga, tudo
dotnet testsem precisar de Postgres/Docker —[AggregateHandler]fazOrder/InventoryItem/OrderHandler/InventoryHandlerserem C# simples, e os métodosHandleda saga são métodos de instância comuns (comIMessageBussubstituído via NSubstitute). PRODUCTION-READINESS.md: documento específico do que este código exigiria pra rodar de verdade —DaemonMode.Solo→HotCold/worker dedicado, multi-tenancy no Marten, idempotência do lado consumidor, versionamento de evento e estratégia de rebuild de projeção, observability via OpenTelemetry, e a topologia de deploy (Kubernetes, outbox supervisionado).
Como funciona
Visão geral do sistema
flowchart TB
Client["🌐 curl / cliente HTTP"]
subgraph API["CritterStackStudy.Api — .NET 10 minimal API"]
Endpoints["Endpoints\nPOST /orders/* · /inventory/* · /orders/{id}/fulfillment/*\nGET /orders · /inventory · /dashboard"]
Bus["Wolverine IMessageBus\ndespacho de comando in-process + outbox"]
subgraph OrderSlice["Orders"]
OrderHandler["OrderHandler\nStartOrder · AddOrderItem · SubmitOrder\nCancelOrder · ShipOrder"]
OrderAgg["Order (agregado de escrita)"]
OrderProj["OrderSummaryProjection\ninline, na escrita"]
end
subgraph InventorySlice["Inventory"]
InvHandler["InventoryHandler\nRegisterInventoryItem · ReserveStock\nReplenishStock"]
InvAgg["InventoryItem (agregado de escrita)"]
InvProj["InventoryItemViewProjection\ninline, na escrita"]
end
subgraph FulfillmentSlice["Fulfillment"]
Saga["OrderFulfillmentSaga\nAwaitingReservation → AwaitingPayment\n→ AwaitingShipment → Completed/Cancelled"]
Gateway["IPaymentGateway\n(FlakyPaymentGateway, com retry)"]
end
Daemon["Marten Async Daemon\nreconstrói OperationsDashboard fora de banda"]
DashProj["OperationsDashboardProjection\nMultiStreamProjection, cruza os dois agregados"]
end
PG[("PostgreSQL\nstreams de evento · outbox · estado da saga · documentos de projeção\ntudo em um schema, um banco")]
Client -->|HTTP| Endpoints
Endpoints -->|InvokeAsync| Bus
Bus --> OrderHandler
Bus --> InvHandler
Bus --> Saga
OrderHandler <--> OrderAgg
InvHandler <--> InvAgg
Saga <--> Gateway
OrderHandler -->|"evento OrderSubmitted +\nReserveStock + BeginOrderFulfillment (outbox)"| Bus
InvHandler -->|"ReservationOutcome (outbox)"| Bus
Saga -->|"CancelOrder / ShipOrder (outbox)"| Bus
OrderAgg -.->|eventos| OrderProj
InvAgg -.->|eventos| InvProj
OrderAgg -.->|eventos| Daemon
InvAgg -.->|eventos| Daemon
Daemon --> DashProj
OrderHandler --> PG
InvHandler --> PG
Saga --> PG
OrderProj --> PG
InvProj --> PG
DashProj --> PG
Endpoints -->|"IQuerySession\nlê todos os documentos acima"| PG
Comando → evento → outbox → projeção
O caminho interessante: submeter um pedido decrementa estoque num agregado diferente, atomicamente, sem transação distribuída e sem message broker.
sequenceDiagram
actor C as Cliente
participant API as Minimal API
participant Bus as Wolverine IMessageBus
participant OH as OrderHandler (SubmitOrder)
participant PG as Postgres (eventos + outbox, 1 transação)
participant IH as InventoryHandler (ReserveStock)
participant Proj as Projeções inline
C->>API: POST /orders/{id}/submit
API->>Bus: InvokeAsync(SubmitOrder)
Bus->>OH: Handle(SubmitOrder, IDocumentSession, IMessageBus)
OH->>PG: FetchForWriting<Order>(id)
PG-->>OH: Order atual (dobrado a partir dos eventos)
OH->>PG: anexa OrderSubmitted
OH->>Bus: PublishAsync(ReserveStock) por item de linha
Bus->>PG: enfileira ReserveStock no outbox (mesma tx)
Note over OH,PG: SaveChangesAsync comita o append de evento
+ linhas do outbox em UMA transação
PG-->>Proj: OrderSubmitted aplicado inline
Proj->>PG: OrderSummary.Status = "Submitted"
PG-->>OH: commit OK
OH-->>API: 204 No Content
API-->>C: 204 No Content
Note over Bus,PG: depois do commit, o Wolverine despacha a mensagem do outbox
Bus->>IH: Handle(ReserveStock)
IH->>PG: FetchForWriting<InventoryItem>(id derivado do SKU)
alt estoque suficiente
IH->>PG: anexa StockReserved
PG-->>Proj: StockReserved aplicado inline
Proj->>PG: InventoryItemView.QuantityOnHand -= qty
else estoque insuficiente
IH->>PG: anexa StockReservationRejected
Note over Proj: sem Apply pra esse evento —
estoque intocado, rejeição é só fato de auditoria
end
Saga de fulfillment: reserva → pagamento → envio
Tudo depois de “a reserva de estoque foi decidida” é coordenado pela OrderFulfillmentSaga, não por um
único handler de comando, porque abrange mensagens que chegam em momentos diferentes, de origens
diferentes (um resultado de checagem de estoque, um webhook simulado de pagamento, um webhook simulado de
armazém, ou um timeout que dispara sozinho).
sequenceDiagram
participant OH as OrderHandler (SubmitOrder)
participant Bus as Wolverine IMessageBus
participant Saga as OrderFulfillmentSaga
participant IH as InventoryHandler
participant GW as IPaymentGateway
participant Cliente as curl / cliente HTTP
OH->>Bus: publica BeginOrderFulfillment (+ ReserveStock por linha, mesma tx)
Bus->>Saga: Start(BeginOrderFulfillment)
Note over Saga: persistida como documento Marten,
Status = AwaitingReservation
Bus->>IH: Handle(ReserveStock)
IH->>Bus: publica ReservationOutcome (outbox)
Bus->>Saga: Handle(ReservationOutcome)
alt toda linha reservada
Saga->>Saga: Status = AwaitingPayment
Saga->>Bus: publica PaymentTimeout (auto-agendado, delay de 20s)
Cliente->>Bus: POST /fulfillment/confirm-payment → ConfirmPayment
Bus->>Saga: Handle(ConfirmPayment)
Saga->>GW: ChargeAsync(orderId)
alt gateway falha transientemente (2 primeiras tentativas)
GW--xSaga: lança PaymentGatewayUnavailableException
Note over Bus,Saga: RetryWithCooldown do Program.cs reinvoca
o handler inteiro (saga recarregada a cada tentativa)
end
GW-->>Saga: sucesso (3ª tentativa)
Saga->>Saga: Status = AwaitingShipment
Cliente->>Bus: POST /fulfillment/confirm-shipment → ConfirmShipment
Bus->>Saga: Handle(ConfirmShipment)
Saga->>Bus: publica ShipOrder
Bus->>OH: Handle(ShipOrder) → anexa OrderShipped
Saga->>Saga: Status = Completed, MarkCompleted() (documento apagado)
else uma linha foi rejeitada, ou PaymentTimeout dispara antes
Saga->>Bus: publica CancelOrder
Bus->>OH: Handle(CancelOrder) → anexa OrderCancelled
Saga->>Saga: Status = Cancelled, MarkCompleted() (documento apagado)
end
Projeção assíncrona cross-stream: OperationsDashboard
Diferente das projeções inline acima, OperationsDashboard é reconstruído pelo async daemon do Marten —
um processo em background, fora do caminho de request/comando — agregando eventos de ambas as streams
num único documento.
sequenceDiagram
participant OH as OrderHandler / InventoryHandler
participant PG as Postgres (event store)
participant Daemon as Marten Async Daemon (background)
participant Dash as OperationsDashboardProjection
participant Cliente as curl / cliente HTTP
OH->>PG: anexa evento (OrderStarted, OrderShipped, StockReserved, ...)
Note over PG,Daemon: sem hand-off síncrono — o daemon faz polling/streaming
de eventos novos na própria cadência, independente da escrita
Daemon->>PG: lê eventos novos desde a última posição
Daemon->>Dash: CustomGrouping roteia todo evento pro
único documento de dashboard conhecido
Dash->>Dash: Apply(evento, dashboard) — ex.: OrdersShipped++,
RevenueShipped += (lookup de OrderSummary pelo id da stream)
Dash->>PG: grava o documento OperationsDashboard atualizado
Cliente->>PG: GET /dashboard (IQuerySession.LoadAsync)
Note over Cliente,PG: pode atrasar levemente em relação à última escrita —
consistência eventual esperada, não um bug
Modelo de dados: streams de evento → documentos de leitura
Nenhuma tabela guarda “o pedido atual” ou “o item de estoque atual” — só a stream de eventos imutáveis.
Os documentos de leitura (OrderSummary, InventoryItemView, OperationsDashboard) são derivados dela,
cada um reformatado pro que a UI/consumidor realmente precisa.
erDiagram
ORDER_STREAM ||--o{ ORDER_EVENT : contém
INVENTORY_STREAM ||--o{ INVENTORY_EVENT : contém
ORDER_EVENT }o--|| ORDER_SUMMARY : "projeta (inline)"
INVENTORY_EVENT }o--|| INVENTORY_ITEM_VIEW : "projeta (inline)"
ORDER_EVENT }o--|| OPERATIONS_DASHBOARD : "projeta (async daemon)"
INVENTORY_EVENT }o--|| OPERATIONS_DASHBOARD : "projeta (async daemon)"
ORDER_STREAM {
guid Id PK "id da stream = OrderId"
}
ORDER_EVENT {
string Type "OrderStarted · OrderItemAdded · OrderSubmitted · OrderShipped · OrderCancelled"
jsonb Data
int Version
}
ORDER_SUMMARY {
guid Id PK
string Status
decimal RunningTotal
jsonb Lines
}
INVENTORY_STREAM {
guid Id PK "derivado do SKU via MD5 (InventoryItemId)"
}
INVENTORY_EVENT {
string Type "InventoryItemRegistered · StockReserved · StockReservationRejected · StockReplenished"
jsonb Data
int Version
}
INVENTORY_ITEM_VIEW {
guid Id PK
string Sku
int QuantityOnHand
jsonb ReservationAudit
}
OPERATIONS_DASHBOARD {
guid Id PK "WellKnownId — um documento só"
int OrdersShipped
int OrdersCancelled
decimal RevenueShipped
int ReservationsConfirmed
int ReservationsRejected
}
Stack
- ◆ BACKEND: .NET 10 minimal API, C# 13.
- ⬢ CRITTER STACK: Marten (Postgres como event store + document DB), Wolverine (mediator in-process, message bus, outbox/inbox transacional, sagas, políticas de retry).
- ▤ BANCO: PostgreSQL — um único schema guarda streams de evento, outbox e documentos de projeção.
- ⌂ INFRA: Docker Compose (Postgres + API).
- ✓ TESTES: xUnit + NSubstitute — 21 testes unitários, sem Postgres/Docker.
Como rodar
# Sobe Postgres (porta 5439 → 5432) e a API (porta 5080).
# Marten cria o schema sozinho no primeiro boot (event tables, outbox, projeções).
docker compose up -d --build
# Registrar estoque
curl -s -X POST http://localhost:5080/inventory \
-H "Content-Type: application/json" \
-d '{"sku":"WIDGET-1","productName":"Widget","initialQuantity":10}'
# Criar e submeter um pedido
curl -s -X POST http://localhost:5080/orders \
-H "Content-Type: application/json" -d '{"customerName":"Ada Lovelace"}'
curl -s -X POST http://localhost:5080/orders/<id>/items \
-H "Content-Type: application/json" \
-d '{"sku":"WIDGET-1","productName":"Widget","quantity":3,"unitPrice":9.99}'
curl -s -X POST http://localhost:5080/orders/<id>/submit
# Fluxo completo via saga: pagamento (com retry automático) e envio
curl -s -X POST http://localhost:5080/orders/<id>/fulfillment/confirm-payment
curl -s -X POST http://localhost:5080/orders/<id>/fulfillment/confirm-shipment
# Dashboard cross-stream (async — pode atrasar um pouco a última escrita)
curl -s http://localhost:5080/dashboard
# Testes — 21 testes, sem Postgres/Docker
dotnet test # Código aberto
O CritterStackStudy é open source, feito pra estudar e servir de referência de arquitetura.