// ESTUDO · CQRS / EVENT SOURCING · OPEN SOURCE

CritterStackStudy

Prova de conceito de CQRS via event sourcing com o Critter Stack — Wolverine (mediator + outbox transacional) e Marten (Postgres como event store) em .NET 10 — sem banco de leitura separado, sem message broker, sem camada de repositório: um único Postgres faz tudo.

OPEN SOURCE .NET 10CQRSEVENT SOURCINGWOLVERINE + MARTENSAGA

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 span­eando 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

  1. Event sourcing como modelo de escrita: Order e InventoryItem sã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.
  2. CQRS com formatos de leitura genuinamente diferentes: OrderSummary e InventoryItemView sã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.
  3. Projeção assíncrona cross-stream: OperationsDashboardProjection é uma MultiStreamProjection registrada como Async, reconstruída fora de banda pelo async daemon do Marten — agrega eventos de ambas as streams (Order e InventoryItem) num único documento via CustomGrouping, prova de que uma projeção não precisa espelhar um único agregado.
  4. Outbox transacional: o handler de SubmitOrder anexa OrderSubmitted na stream do pedido, publica ReserveStock por item de linha, e publica BeginOrderFulfillment pra 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.
  5. 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.
  6. Política de retry com cooldown: ConfirmPayment chama um IPaymentGateway deliberadamente instável que falha as duas primeiras tentativas de cobrança de um pedido. A política OnException<PaymentGatewayUnavailableException>().RetryWithCooldown(...) do Wolverine (em Program.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çando InvalidOperationException puro e falhando rápido — só o tipo de falha genuinamente transiente é retentado.
  7. 21 testes unitários novos: agregados, handlers e a saga, tudo dotnet test sem precisar de Postgres/Docker — [AggregateHandler] faz Order/InventoryItem/OrderHandler/InventoryHandler serem C# simples, e os métodos Handle da saga são métodos de instância comuns (com IMessageBus substituído via NSubstitute).
  8. 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.