Distributed Transactions with Wolverine: Saga Orchestration vs Choreography with RabbitMQ, Compensating Actions, and Timeout Handling

Mehdi Hadeli
@mehdihadeli
On this page
Table of contents
When you break a monolith into microservices, the hardest problem is data consistency across service boundaries. Service discovery and deployment are easy by comparison. A customer placing an order may need the Order service to create a record, the Payment service to process a charge, and the Inventory service to reserve stock. If payment fails after the order is created, we need to undo the order. If inventory runs out, we need to refund the payment.
In a monolith, this is a single database transaction. In microservices, each service owns its own data, and distributed transactions with Two-Phase Commit (2PC) are brittle, slow, and tightly couple services.
The Saga pattern solves this by breaking the transaction into a sequence of local steps, each with a compensating action for rollback. But there are two distinct ways to implement a Saga.
Saga Pattern Overview: Choreography vs Orchestration
The Saga pattern comes in two flavors. Both are valid Sagas. Both use compensating actions for rollback. The difference is who controls the flow.
Choreography-Based Saga
Each service produces events and listens to events from other services. When a service completes its local transaction, it publishes an event. Other services react. There is no central coordinator.
OrderCreated ──► Payment listens, processes ──► PaymentProcessed ──► Order listens, confirms
Pros: Looser coupling, simpler infrastructure, no single point of failure. Cons: Flow is implicit — hard to trace, test, and reason about in complex workflows. Timeout handling requires distributed agreement.
Orchestration-Based Saga
A central orchestrator manages the saga state, sends commands to participant services, and handles compensations. The orchestrator is a state machine that decides the next step based on responses.
Orchestrator ──► ProcessPayment ──► Payment responds ──► Orchestrator decides: confirm or compensate
Pros: Explicit flow, easy to trace and test, built-in timeout handling, clear state management. Cons: Central coordinator is a potential bottleneck and single point of failure (mitigated by durable state).
Both patterns solve the same problem: guaranteeing data consistency across services without 2PC. The choice depends on workflow complexity and operational preferences.
In this article, I'll implement both styles for the same e-commerce order-payment flow using Wolverine and RabbitMQ, with compensating transactions, timeout handling, and integration tests with TestContainers. This lets you see the tradeoffs side by side.
The Problem
Imagine an order flow with two microservices:
- OrderSaga (Orchestrator) - creates orders, tracks saga state
- Payment (Worker) - processes payments, reports success or failure
The happy path looks like this:
POST /orders → create order (Pending) → ProcessPayment → payment OK → Confirm order
But what about these failure cases?
- Payment declined: the order should be marked as cancelled, not left in limbo
- Payment never responds: the system should timeout the order after a grace period and revert to a safe state
- Service crashes mid-flow: after recovery, the saga should continue from where it stopped
These are the normal failure modes of distributed systems, and Wolverine Saga handles them without extra infrastructure.
Implementation 1: Orchestration-Based Saga (Wolverine Saga)
This implementation uses Wolverine's built-in Saga support. OrderSagaState is the central coordinator that manages the distributed transaction state, sends commands, and handles compensation:
- Creates an order (status =
Pending) - Sends
ProcessPaymentto the Payment service via RabbitMQ - Schedules an
OrderTimeoutfor the saga (fallback if Payment never responds)
Three outcomes can happen:
| Event | Action | Saga Status |
|---|---|---|
PaymentProcessed | order.Confirm() then mark saga complete | Completed (happy path) |
PaymentFailed | order.Cancel(reason) then mark saga complete | Completed (compensation) |
OrderTimeout | order.Cancel("timeout") then mark saga complete | Completed (compensation) |
The saga guarantees exactly one of these outcomes occurs. No order stays Pending forever. The endpoint creates the order entity before invoking the saga, so the saga handles orchestration, not data creation.
Saga State
Wolverine Saga stores the state in PostgreSQL. The saga state class is simple:
public sealed class OrderSagaState : Saga
{
public Guid Id { get; set; }
// ── Start: fire outgoing messages ──
public static (OrderSagaState, OutgoingMessages) Start(
StartOrder command)
{
var saga = new OrderSagaState { Id = command.OrderId };
var outgoing = new OutgoingMessages
{
new ProcessPayment(command.OrderId, 0),
new OrderTimeout(command.OrderId),
};
return (saga, outgoing);
}
// ── Happy path ──
public void Handle(PaymentProcessed processed, OrderDbContext dbContext)
{
var order = dbContext.Orders.Single(o => o.Id == processed.OrderId);
order.Confirm();
MarkCompleted();
}
// ── Compensation: payment failed ──
public void Handle(PaymentFailed failed, OrderDbContext dbContext)
{
var order = dbContext.Orders.Single(o => o.Id == failed.OrderId);
order.Cancel(failed.Reason);
MarkCompleted();
}
// ── Compensation: timeout ──
public void Handle(OrderTimeout timeout, OrderDbContext dbContext)
{
var order = dbContext.Orders.Single(o => o.Id == timeout.OrderId);
order.Cancel("timeout");
MarkCompleted();
}
}
The Start method builds the saga state and returns outgoing messages. The endpoint creates the order entity before the saga runs, so the saga sticks to orchestration. Wolverine sends the outgoing messages (ProcessPayment, OrderTimeout) only after persisting the saga state.
The Handle methods process incoming events and apply state transitions. MarkCompleted() tells Wolverine the saga is finished and can be removed from storage.
Order Domain Model
The Order entity enforces valid state transitions through explicit domain methods:
public sealed class Order
{
public Guid Id { get; private set; }
public string CustomerName { get; private set; } = null!;
public decimal Total { get; private set; }
public OrderStatus Status { get; private set; }
public DateTime CreatedAtUtc { get; private set; }
public static Order Create(string customerName, decimal total) => new()
{
Id = Guid.NewGuid(),
CustomerName = customerName,
Total = total,
Status = OrderStatus.Pending,
CreatedAtUtc = DateTime.UtcNow,
};
public void Confirm()
{
if (Status != OrderStatus.Pending)
throw new InvalidOperationException(
$"Cannot confirm order {Id}: current status is {Status}.");
Status = OrderStatus.Confirmed;
}
public void Cancel(string reason)
{
if (Status == OrderStatus.Confirmed)
throw new InvalidOperationException(
$"Cannot cancel order {Id}: already confirmed.");
Status = reason == "timeout" ? OrderStatus.TimedOut : OrderStatus.Cancelled;
}
}
Payment Handler
The Payment service receives ProcessPayment and simulates an async payment gateway call:
public sealed class ProcessPaymentHandler
{
private static readonly Random _random = new();
public async Task Handle(ProcessPayment command, IMessageBus bus)
{
await Task.Delay(TimeSpan.FromMilliseconds(500));
if (_random.NextDouble() < 0.8)
{
await bus.PublishAsync(new PaymentProcessed(
command.OrderId, $"TXN-{Guid.NewGuid():N}"[..12]));
}
else
{
await bus.PublishAsync(new PaymentFailed(
command.OrderId, "Insufficient funds"));
}
}
}
An 80% success rate means most runs hit the happy path, but failures show up often enough to test the compensating logic.
System Architecture
flowchart TD
classDef client fill:#a855f7,stroke:#7e22ce,color:#fff,font-size:14px
classDef orchestrator fill:#06b6d4,stroke:#0891b2,color:#fff,font-size:14px
classDef worker fill:#eab308,stroke:#ca8a04,color:#1e293b,font-size:14px
classDef broker fill:#22c55e,stroke:#16a34a,color:#fff,font-size:14px
classDef store fill:#f97316,stroke:#ea580c,color:#fff,font-size:14px
classDef timeout fill:#8b5cf6,stroke:#6d28d9,color:#fff,font-size:14px
subgraph Client["Client"]
C1["POST /api/v1/orders<br/>{customerName, total}"]
end
subgraph OrderSaga["OrderSaga Service"]
direction TB
OS1["CreateOrder Endpoint"]
OS2["Order Domain<br/>+ EF Core"]
OS3["OrderSagaState<br/>(Wolverine Saga)"]
OS4["Orchestrator"]
OS1 -->|"Order.Create()"| OS2
OS1 -->|"StartOrder"| OS3
end
subgraph Payment["Payment Service"]
direction TB
P1["ProcessPayment<br/>Handler"]
P2["Payment Gateway<br/>Simulation"]
P1 --> P2
end
subgraph Broker["RabbitMQ"]
RMQ1["payment-requests"]
RMQ2["order-payment-responses"]
end
subgraph PG["PostgreSQL"]
PG1["Orders Table"]
PG2["Saga State<br/>+ Outbox/Inbox"]
end
subgraph Timeout["Scheduled Delivery"]
T1["OrderTimeout<br/>Delayed 30s"]
end
C1 -->|"HTTP POST"| OS1
OS4 -->|"Publish ProcessPayment"| RMQ1
RMQ1 -->|"Consume"| P1
P2 -->|"80% PaymentProcessed"| RMQ2
P2 -->|"20% PaymentFailed"| RMQ2
RMQ2 -->|"Consume"| OS4
OS4 -.->|"Schedule"| T1
T1 -.->|"Timeout"| OS4
OS4 -->|"Save"| PG2
OS2 -->|"Save"| PG1
OS4 --x|"Order.Confirm()"| OS2
OS4 --x|"Order.Cancel()"| OS2
class C1 client
class OS1,OS3 orchestrator
class OS2,PG1,PG2 store
class P1,P2 worker
class RMQ1,RMQ2 broker
class T1 timeout
Saga Flow Details
%%{init: {'theme': 'base', 'themeVariables': {'actorBkg':'#a855f7', 'actorBorder':'#7e22ce', 'actorTextColor':'#fff', 'signalColor':'#475569', 'signalTextColor':'#1e293b', 'labelBoxBkgColor':'#06b6d4', 'labelBoxBorderColor':'#0891b2', 'labelTextColor':'#fff', 'loopTextColor':'#334155', 'noteBkgColor':'#f59e0b', 'noteBorderColor':'#d97706', 'noteTextColor':'#1e293b', 'activationBorderColor':'#a855f7', 'activationBkgColor':'#e9d5ff'}}}%%
sequenceDiagram
participant Client
participant OrderSaga
participant PostgreSQL
participant RabbitMQ
participant Payment
participant Timer
Client->>OrderSaga: POST /orders { customerName, total }
OrderSaga->>PostgreSQL: Create Order (Pending) [COMMIT]
OrderSaga->>OrderSaga: Start Saga with OrderId
OrderSaga->>PostgreSQL: Persist Saga State [COMMIT]
OrderSaga->>RabbitMQ: Publish ProcessPayment
OrderSaga->>Timer: Schedule OrderTimeout (30s)
alt Payment Succeeds (80%)
RabbitMQ->>Payment: Deliver ProcessPayment
Payment->>Gateway: Process Charge (500ms delay)
Payment->>RabbitMQ: Publish PaymentProcessed
RabbitMQ->>OrderSaga: Deliver PaymentProcessed
OrderSaga->>PostgreSQL: Order.Confirm() → Confirmed
OrderSaga->>OrderSaga: MarkCompleted()
Note over OrderSaga,Timer: Cancel scheduled timeout
OrderSaga-->>Client: 200 OK
else Payment Fails (20%)
RabbitMQ->>Payment: Deliver ProcessPayment
Payment->>Gateway: Process Charge (fails)
Payment->>RabbitMQ: Publish PaymentFailed
RabbitMQ->>OrderSaga: Deliver PaymentFailed
OrderSaga->>PostgreSQL: Order.Cancel(reason) → Cancelled
OrderSaga->>OrderSaga: MarkCompleted()
Note over OrderSaga,Timer: Cancel scheduled timeout
OrderSaga-->>Client: 200 OK (compensation)
else Timeout (No Response)
Timer->>OrderSaga: Fire OrderTimeout (30s elapsed)
OrderSaga->>PostgreSQL: Order.Cancel("timeout") → TimedOut
OrderSaga->>OrderSaga: MarkCompleted()
end
Implementation 2: Choreography-Based Saga (Event-Driven)
The second implementation uses pure event-driven choreography with Wolverine message handlers — no Saga class, no central state machine. Each service reacts to events and publishes its own events. The flow emerges from the chain of reactions.
How It Works
- Order service: creates order (Pending), publishes
OrderCreated, schedulesOrderTimeoutCheck - Payment service: listens to
OrderCreated, processes payment, publishesPaymentProcessedorPaymentFailed - Order service: listens to:
PaymentProcessed→ confirms order (compensation: forward action)PaymentFailed→ cancels order (compensation: rollback action)OrderTimeoutCheck→ if still Pending, cancels with "timeout" (compensation: timeout)
No saga state table in PostgreSQL. No Saga base class. Just handlers reacting to events.
Event Contracts
The same ProcessPayment, PaymentProcessed, and PaymentFailed messages work here as domain events:
public sealed record OrderCreated(
Guid OrderId, string CustomerName, decimal Total) : IMessage;
public sealed record ProcessPayment(
Guid OrderId, decimal Amount) : IMessage;
public sealed record PaymentProcessed(
Guid OrderId, string TransactionId) : IMessage;
public sealed record PaymentFailed(
Guid OrderId, string Reason) : IMessage;
A new OrderCreated event carries the full order details so the Payment service can process without querying the Order service's database.
Order Service Handlers (No Saga)
The CreateOrderEndpoint creates the order, then publishes events — no saga invocation:
public sealed class CreateOrderEndpoint
{
public static async Task<IResult> Handle(
CreateOrderRequest request,
OrderDbContext dbContext,
IMessageBus bus,
CancellationToken ct)
{
var order = Order.Create(request.CustomerName, request.Total);
dbContext.Orders.Add(order);
await dbContext.SaveChangesAsync(ct);
// Publish event — Payment service reacts
await bus.PublishAsync(new OrderCreated(
order.Id, order.CustomerName, order.Total));
// Schedule timeout check (30 seconds)
await bus.ScheduleAsync(
new OrderTimeoutCheck(order.Id),
TimeSpan.FromSeconds(30));
return Results.Ok(order.Id);
}
}
Then, separate handlers react to the Payment service's responses:
// ── Happy path ──
public sealed class PaymentProcessedHandler
{
public async Task Handle(
PaymentProcessed processed,
OrderDbContext dbContext)
{
var order = await dbContext.Orders.FindAsync(processed.OrderId);
if (order is null || order.Status != OrderStatus.Pending)
return; // Idempotent — already processed or cancelled
order.Confirm();
await dbContext.SaveChangesAsync();
}
}
// ── Compensation: payment failed ──
public sealed class PaymentFailedHandler
{
public async Task Handle(
PaymentFailed failed,
OrderDbContext dbContext)
{
var order = await dbContext.Orders.FindAsync(failed.OrderId);
if (order is null || order.Status != OrderStatus.Pending)
return;
order.Cancel(failed.Reason);
await dbContext.SaveChangesAsync();
}
}
// ── Compensation: timeout ──
public sealed class OrderTimeoutCheckHandler
{
public async Task Handle(
OrderTimeoutCheck timeout,
OrderDbContext dbContext)
{
var order = await dbContext.Orders.FindAsync(timeout.OrderId);
if (order is null || order.Status != OrderStatus.Pending)
return; // Already completed — noop
order.Cancel("timeout");
await dbContext.SaveChangesAsync();
}
}
Key difference from orchestration: each handler is self-contained. There is no shared saga state coordinating them. The OrderCreated event is published directly (not via saga outbox), and each response handler checks the current order status independently before acting. This makes the handlers idempotent — if a message is delivered twice, the second delivery is a no-op.
Payment Service Handler
The Payment service is nearly identical to the orchestration version. It listens to OrderCreated instead of ProcessPayment:
public sealed class OrderCreatedHandler
{
private static readonly Random _random = new();
public async Task Handle(OrderCreated created, IMessageBus bus)
{
await Task.Delay(TimeSpan.FromMilliseconds(500));
if (_random.NextDouble() < 0.8)
{
await bus.PublishAsync(new PaymentProcessed(
created.OrderId, $"TXN-{Guid.NewGuid():N}"[..12]));
}
else
{
await bus.PublishAsync(new PaymentFailed(
created.OrderId, "Insufficient funds"));
}
}
}
Timeout Handling in Choreography
Timeout handling is not built into Wolverine's Saga infrastructure here — it's just a scheduled message. The OrderTimeoutCheck is scheduled alongside the OrderCreated event. The handler checks the current state. If the order is already confirmed or cancelled, it does nothing. If it's still pending, it applies the compensating cancel.
This is more manual than the orchestration approach. The orchestration saga knows its lifecycle and can cancel scheduled messages automatically. In choreography, the timeout check handler must be defensive.
Choreography System Architecture
flowchart TD
classDef client fill:#a855f7,stroke:#7e22ce,color:#fff,font-size:14px
classDef service fill:#06b6d4,stroke:#0891b2,color:#fff,font-size:14px
classDef worker fill:#eab308,stroke:#ca8a04,color:#1e293b,font-size:14px
classDef broker fill:#22c55e,stroke:#16a34a,color:#fff,font-size:14px
classDef store fill:#f97316,stroke:#ea580c,color:#fff,font-size:14px
classDef timeout fill:#8b5cf6,stroke:#6d28d9,color:#fff,font-size:14px
subgraph Client["Client"]
C1["POST /api/v1/orders<br/>{customerName, total}"]
end
subgraph OrderSvc["Order Service"]
direction TB
OS1["CreateOrder Endpoint"]
OS2["Order Domain<br/>+ EF Core"]
OS3["PaymentProcessed<br/>Handler"]
OS4["PaymentFailed<br/>Handler"]
OS5["OrderTimeoutCheck<br/>Handler"]
OS1 -->|"Order.Create()"| OS2
end
subgraph PaymentSvc["Payment Service"]
direction TB
P1["OrderCreated<br/>Handler"]
P2["Payment Gateway<br/>Simulation"]
P1 --> P2
end
subgraph Broker["RabbitMQ"]
RMQ1["order-events"]
RMQ2["payment-events"]
end
subgraph PG["PostgreSQL"]
PG1["Orders Table"]
end
subgraph Timeout["Scheduled Delivery"]
T1["OrderTimeoutCheck<br/>Delayed 30s"]
end
C1 -->|"HTTP POST"| OS1
OS1 -->|"Publish OrderCreated"| RMQ1
OS1 -.->|"Schedule"| T1
RMQ1 -->|"Consume"| P1
P2 -->|"80% PaymentProcessed"| RMQ2
P2 -->|"20% PaymentFailed"| RMQ2
RMQ2 -->|"Consume"| OS3
RMQ2 -->|"Consume"| OS4
T1 -.->|"Timeout"| OS5
OS1 -->|"Save"| PG1
OS3 -->|"Order.Confirm()"| PG1
OS4 -->|"Order.Cancel()"| PG1
OS5 -->|"Order.Cancel()"| PG1
class C1 client
class OS1,OS3,OS4,OS5 service
class PG1 store
class P1,P2 worker
class RMQ1,RMQ2 broker
class T1 timeout
Choreography Flow Details
%%{init: {'theme': 'base', 'themeVariables': {'actorBkg':'#06b6d4', 'actorBorder':'#0891b2', 'actorTextColor':'#fff', 'signalColor':'#475569', 'signalTextColor':'#1e293b', 'labelBoxBkgColor':'#eab308', 'labelBoxBorderColor':'#ca8a04', 'labelTextColor':'#1e293b', 'loopTextColor':'#334155', 'noteBkgColor':'#f59e0b', 'noteBorderColor':'#d97706', 'noteTextColor':'#1e293b', 'activationBorderColor':'#06b6d4', 'activationBkgColor':'#cffafe'}}}%%
sequenceDiagram
participant Client
participant OrderSvc
participant PostgreSQL
participant RabbitMQ
participant PaymentSvc
participant Timer
Client->>OrderSvc: POST /orders { customerName, total }
OrderSvc->>PostgreSQL: Create Order (Pending) [COMMIT]
OrderSvc->>RabbitMQ: Publish OrderCreated
OrderSvc->>Timer: Schedule OrderTimeoutCheck (30s)
alt Payment Succeeds (80%)
RabbitMQ->>PaymentSvc: Deliver OrderCreated
PaymentSvc->>Gateway: Process Charge (500ms delay)
PaymentSvc->>RabbitMQ: Publish PaymentProcessed
RabbitMQ->>OrderSvc: Deliver PaymentProcessed
OrderSvc->>PostgreSQL: Order.Confirm() → Confirmed
Note over OrderSvc,Timer: Timeout fires but is noop (status != Pending)
OrderSvc-->>Client: 202 Accepted
else Payment Fails (20%)
RabbitMQ->>PaymentSvc: Deliver OrderCreated
PaymentSvc->>Gateway: Process Charge (fails)
PaymentSvc->>RabbitMQ: Publish PaymentFailed
RabbitMQ->>OrderSvc: Deliver PaymentFailed
OrderSvc->>PostgreSQL: Order.Cancel(reason) → Cancelled
Note over OrderSvc,Timer: Timeout fires but is noop
OrderSvc-->>Client: 202 Accepted (compensation)
else Timeout (No Response)
Timer->>OrderSvc: Fire OrderTimeoutCheck (30s elapsed)
OrderSvc->>PostgreSQL: Order.Cancel("timeout") → TimedOut
end
Choreography Configuration
The Order service publishes events and listens for responses — no saga tables needed:
public static WebApplicationBuilder AddApplicationServices(this WebApplicationBuilder builder)
{
var transport = builder.Configuration.GetMessagingTransport();
var connectionString = builder.Configuration.GetConnectionString("ordersdb")
?? throw new InvalidOperationException("Missing ConnectionStrings:ordersdb");
builder.AddTransactionalWolverine(transport, cfg =>
{
cfg.ConfigureWolverine(opts =>
{
// Publish OrderCreated event for Payment service
opts.PublishMessage<OrderCreated>()
.ToRabbitQueue(MessagingConstants.OrderEventsQueue);
// Listen for Payment responses
opts.ListenToRabbitQueue(MessagingConstants.PaymentEventsQueue)
.ListenerCount(1);
// Persist scheduled messages (timeout) via PostgreSQL
opts.PersistMessagesWithPostgresql(connectionString);
opts.UseEntityFrameworkCoreTransactions();
});
cfg.ScanHandlers(typeof(OrderModule).Assembly);
});
return builder;
}
The Payment service is the same as before, but subscribes to OrderCreated:
public static WebApplicationBuilder AddApplicationServices(this WebApplicationBuilder builder)
{
var transport = builder.Configuration.GetMessagingTransport();
builder.AddTransactionalWolverine(transport, cfg =>
{
cfg.ConfigureWolverine(opts =>
{
opts.ListenToRabbitQueue(MessagingConstants.OrderEventsQueue)
.ListenerCount(1);
opts.PublishMessage<PaymentProcessed>()
.ToRabbitQueue(MessagingConstants.PaymentEventsQueue);
opts.PublishMessage<PaymentFailed>()
.ToRabbitQueue(MessagingConstants.PaymentEventsQueue);
});
cfg.ScanHandlers(typeof(PaymentModule).Assembly);
});
return builder;
}
Comparison: Orchestration vs Choreography
| Aspect | Orchestration (Wolverine Saga) | Choreography (Event-Driven) |
|---|---|---|
| State management | Central saga state in DB | No saga state — each handler checks status independently |
| Flow visibility | Explicit — single saga class documents the whole flow | Implicit — flow is distributed across handlers |
| Timeout handling | Built-in via TimeoutMessage — saga MarkCompleted() auto-cancels | Manual OrderTimeoutCheck handler must be defensive |
| Idempotency | Saga Handle() methods execute once per state | Each handler checks order.Status before acting |
| Coupling | Tight — orchestrator knows all participants | Loose — services only know events, not each other |
| Testing | Single saga class can be unit tested easily | Must test chains of handlers together |
| Recovery after crash | Saga state persists — Wolverine resumes from last step | Messages replay — handlers are idempotent so safe |
| When to use | Complex workflows, long-running with timeouts, need audit trail | Simple linear flows, maximum decoupling, polyglot environments |
How to Choose
Orchestration wins when:
- The workflow has more than 2-3 steps
- You need guaranteed timeout handling with automatic cleanup
- You want the entire transaction visible in one place (audit, debugging)
- Failure modes are complex (multiple compensation paths)
Choreography wins when:
- The flow is simple (2-3 services, one event chain)
- Services are owned by different teams with different tech stacks
- You want maximum decoupling and no single point of coordination
- Event replay and eventual consistency are acceptable
The e-commerce flow in this article is simple enough for either approach. I show both so you can evaluate the tradeoffs in real code.
Solution Structure
Both implementations share the same top-level structure with Vertical Slice Architecture and Aspire orchestration. Both samples live under the same repository:
wolverine-distributed-transaction-sample/
├── orchestration/ # Orchestration-based saga
│ ├── src/
│ │ ├── Aspire/
│ │ │ ├── WolverineDistributed.AppHost/
│ │ │ └── WolverineDistributed.ServiceDefaults/
│ │ ├── BuildingBlocks/
│ │ │ └── BuildingBlocks.Integration.Wolverine/
│ │ ├── Shared/
│ │ │ └── Contracts/
│ │ └── Services/
│ │ ├── OrderSaga/ # Order + Saga service
│ │ │ ├── Orders/Features/
│ │ │ │ ├── CreatingOrder/v1/
│ │ │ │ ├── GettingOrderById/v1/
│ │ │ │ └── ProcessingOrderPayment/v1/
│ │ │ └── Shared/Data/
│ │ └── Payment/
│ │ └── Payments/Features/ProcessingPayment/v1/
│ └── tests/
│ ├── Shared/Tests.Shared/
│ └── Services/
│ ├── OrderSaga.IntegrationTests/
│ └── Payment.IntegrationTests/
│
└── choreography/ # Choreography-based saga
├── src/
│ ├── Aspire/
│ │ ├── Choreography.AppHost/
│ │ └── Choreography.ServiceDefaults/
│ ├── BuildingBlocks/
│ │ └── BuildingBlocks.Integration.Wolverine/
│ ├── Shared/
│ │ └── Contracts/
│ └── Services/
│ ├── Order/ # Order service (no saga)
│ │ ├── Orders/Features/
│ │ │ ├── CreatingOrder/v1/
│ │ │ ├── ConfirmingOrder/v1/
│ │ │ ├── CancellingOrder/v1/
│ │ │ └── HandlingOrderTimeout/v1/
│ │ └── Shared/Data/
│ └── Payment/
│ └── Payments/Features/ProcessingPayment/v1/
└── tests/
├── Shared/Tests.Shared/
└── Services/
├── Order.IntegrationTests/
└── Payment.IntegrationTests/
The orchestration implementation stores saga state in a dedicated PostgreSQL table. The choreography implementation stores only domain data — no saga-related tables.
Orchestration Configuration (Wolverine Saga)
The orchestration implementation configures Wolverine through the shared BuildingBlocks.Integration.Wolverine library. The OrderSaga module sets up RabbitMQ transport with saga storage:
public static WebApplicationBuilder AddApplicationServices(this WebApplicationBuilder builder)
{
builder.AddOrderSagaStorage();
var transport = builder.Configuration.GetMessagingTransport();
var connectionString = builder.Configuration.GetConnectionString("ordersdb")
?? throw new InvalidOperationException("Missing ConnectionStrings:ordersdb");
builder.AddTransactionalWolverine(transport, cfg =>
{
cfg.ConfigureWolverine(opts =>
{
// Publish ProcessPayment → Payment service queue
opts.PublishMessage<ProcessPayment>()
.ToRabbitQueue(MessagingConstants.PaymentRequestsQueue);
// Listen for Payment responses
opts.ListenToRabbitQueue(MessagingConstants.OrderPaymentResponsesQueue)
.ListenerCount(1);
// Durable saga storage via PostgreSQL
opts.PersistMessagesWithPostgresql(connectionString);
opts.UseEntityFrameworkCoreTransactions();
});
cfg.ScanHandlers(typeof(OrderSagaModule).Assembly);
});
return builder;
}
The Payment service is simpler — it only listens and responds:
public static WebApplicationBuilder AddApplicationServices(this WebApplicationBuilder builder)
{
var transport = builder.Configuration.GetMessagingTransport();
builder.AddTransactionalWolverine(transport, cfg =>
{
cfg.ConfigureWolverine(opts =>
{
opts.ListenToRabbitQueue(MessagingConstants.PaymentRequestsQueue)
.ListenerCount(1);
opts.PublishMessage<PaymentProcessed>()
.ToRabbitQueue(MessagingConstants.OrderPaymentResponsesQueue);
opts.PublishMessage<PaymentFailed>()
.ToRabbitQueue(MessagingConstants.OrderPaymentResponsesQueue);
});
cfg.ScanHandlers(typeof(PaymentModule).Assembly);
});
return builder;
}
PersistMessagesWithPostgresqlstores saga state, outbox, inbox, and scheduled messages in the same PostgreSQL databaseUseEntityFrameworkCoreTransactionsensures saga state changes and outgoing messages share one DB transactionScanHandlersauto-discovers saga handler methods and message consumersListenerCount(1)limits concurrent processing on response queues to preserve ordering
Orchestration Timeout Handling
In the orchestration saga, timeouts are built in. The OrderTimeout message is returned from Start() as part of OutgoingMessages. Wolverine treats it as a scheduled message with a 30-second delay:
public sealed record OrderTimeout(Guid OrderId) : TimeoutMessage(TimeSpan.FromSeconds(30));
The timeout handler is part of the saga itself — it cancels the order and marks the saga complete:
public void Handle(OrderTimeout timeout, OrderDbContext dbContext)
{
var order = dbContext.Orders.Single(o => o.Id == timeout.OrderId);
order.Cancel("timeout");
MarkCompleted();
}
Wolverine persists scheduled messages durably. If either service crashes mid-flow, the timeout survives restart.
Integration Tests with TestContainers
Both implementations include a full test suite using TestContainers for PostgreSQL and RabbitMQ, Respawn for database reseeding, and xUnit v3.
Shared Fixtures
The SharedFixture combines PostgresContainerFixture and RabbitMqContainerFixture:
public sealed class SharedFixture : IAsyncLifetime
{
public PostgresContainerFixture PostgresFixture { get; } = new();
public RabbitMqContainerFixture RabbitMqFixture { get; } = new();
public async Task ResetAsync()
{
await PostgresFixture.ResetAsync();
await RabbitMqFixture.CleanupQueuesAsync();
}
public async Task InitializeAsync()
{
await PostgresFixture.InitializeAsync();
await RabbitMqFixture.InitializeAsync();
}
public CustomWebApplicationFactory CreateFactory(string connectionString)
{
return new CustomWebApplicationFactory()
.WithSetting("ConnectionStrings:ordersdb", connectionString);
}
public async Task DisposeAsync()
{
await PostgresFixture.DisposeAsync();
await RabbitMqFixture.DisposeAsync();
}
}
Custom WebApplicationFactory
public class CustomWebApplicationFactory : WebApplicationFactory<IApiMarker>
{
public CustomWebApplicationFactory WithSetting(string key, string value)
{
Settings[key] = value;
return this;
}
public CustomWebApplicationFactory ConfigureTestServices(
Action<IServiceCollection> configure)
{
TestServiceConfigurations.Add(configure);
return this;
}
public CustomWebApplicationFactory WithEnvironment(string name, string value)
{
Environment[name] = value;
return this;
}
}
Test Example
[Collection("integration-tests")]
public class CreateOrderTests(OrderSagaSharedFixture sharedFixture)
: OrderSagaIntegrationTestBase(sharedFixture)
{
[Fact]
public async Task Should_Create_Order_Successfully()
{
var request = new CreateOrderRequest("John Doe", 150.00m);
var response = await Client.PostAsJsonAsync("/api/v1/orders", request);
response.StatusCode.ShouldBe(HttpStatusCode.OK);
await ExecuteDbContextAsync(async db =>
{
var order = await db.Orders.FirstOrDefaultAsync();
order.ShouldNotBeNull();
order.CustomerName.ShouldBe("John Doe");
order.Total.ShouldBe(150.00m);
});
}
}
The IntegrationTestBase provides ExecuteDbContextAsync and access to the shared fixture, client, and service provider:
public abstract class IntegrationTestBase<TEntryPoint, TDbContext, TSharedFixture>(
TSharedFixture sharedFixture)
: IAsyncLifetime
where TEntryPoint : class
where TDbContext : DbContext
where TSharedFixture : SharedFixture<TEntryPoint, TDbContext>
{
protected TSharedFixture SharedFixture { get; } = sharedFixture;
protected HttpClient Client { get; private set; } = null!;
protected IServiceProvider ServiceProvider { get; private set; } = null!;
public async Task InitializeAsync()
{
await SharedFixture.ResetAsync();
var factory = SharedFixture.CreateFactory(SharedFixture.ConnectionString);
Client = factory.CreateClient();
ServiceProvider = factory.Services;
}
protected async Task ExecuteDbContextAsync(
Func<TDbContext, Task> action, CancellationToken ct = default)
{
await using var scope = ServiceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<TDbContext>();
await action(db);
}
public Task DisposeAsync() => Task.CompletedTask;
}
Running the Samples
Prerequisites
- .NET 10 SDK
- Docker Desktop (for TestContainers and Aspire)
Orchestration Sample
cd wolverine-distributed-transaction-sample/orchestration
dotnet run --project src/Aspire/WolverineDistributed.AppHost
# Tests
dotnet test tests/Services/OrderSaga/OrderSaga.IntegrationTests
dotnet test tests/Services/Payment/Payment.IntegrationTests
Choreography Sample
cd wolverine-distributed-transaction-sample/choreography
dotnet run --project src/Aspire/Choreography.AppHost
# Tests
dotnet test tests/Services/Order/Order.IntegrationTests
dotnet test tests/Services/Payment/Payment.IntegrationTests
Conclusion
Distributed transactions are inherently complex, but the Saga pattern makes them manageable. The key insight is that both choreography and orchestration are valid Saga implementations — the difference is who controls the flow.
Orchestration (Wolverine Saga) gives you a central state machine that is easy to trace, test, and reason about. It shines in complex workflows with multiple participants, timeouts, and branch logic. Wolverine's transactional outbox, scheduled message delivery, and durable saga state handle the infrastructure so you focus on business logic.
Choreography (event-driven) gives you maximum decoupling. Services know only about events, not about each other. It is ideal for simple linear flows and polyglot environments where services use different tech stacks. The tradeoff is that the flow is implicit — you must trace event chains to understand the full transaction.
Both approaches handle the three outcomes of any distributed operation: success, failure with compensation, and timeout recovery. Both benefit from Wolverine's durable message infrastructure. The only question is where you put the state machine: in a central saga class (orchestration) or distributed across independent handlers (choreography).
Integration testing with TestContainers ties it together. Running real PostgreSQL and RabbitMQ containers alongside the tests validates that whatever pattern you choose actually works correctly across service boundaries.


