19 min readMehdi Hadeli

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

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:

  1. OrderSaga (Orchestrator) - creates orders, tracks saga state
  2. Payment (Worker) - processes payments, reports success or failure

The happy path looks like this:

POST /orders → create order (Pending)ProcessPayment → payment OKConfirm 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:

  1. Creates an order (status = Pending)
  2. Sends ProcessPayment to the Payment service via RabbitMQ
  3. Schedules an OrderTimeout for the saga (fallback if Payment never responds)

Three outcomes can happen:

EventActionSaga Status
PaymentProcessedorder.Confirm() then mark saga completeCompleted (happy path)
PaymentFailedorder.Cancel(reason) then mark saga completeCompleted (compensation)
OrderTimeoutorder.Cancel("timeout") then mark saga completeCompleted (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

Rendering diagram...

Saga Flow Details

Rendering diagram...

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

  1. Order service: creates order (Pending), publishes OrderCreated, schedules OrderTimeoutCheck
  2. Payment service: listens to OrderCreated, processes payment, publishes PaymentProcessed or PaymentFailed
  3. 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

Rendering diagram...

Choreography Flow Details

Rendering diagram...

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

AspectOrchestration (Wolverine Saga)Choreography (Event-Driven)
State managementCentral saga state in DBNo saga state — each handler checks status independently
Flow visibilityExplicit — single saga class documents the whole flowImplicit — flow is distributed across handlers
Timeout handlingBuilt-in via TimeoutMessage — saga MarkCompleted() auto-cancelsManual OrderTimeoutCheck handler must be defensive
IdempotencySaga Handle() methods execute once per stateEach handler checks order.Status before acting
CouplingTight — orchestrator knows all participantsLoose — services only know events, not each other
TestingSingle saga class can be unit tested easilyMust test chains of handlers together
Recovery after crashSaga state persists — Wolverine resumes from last stepMessages replay — handlers are idempotent so safe
When to useComplex workflows, long-running with timeouts, need audit trailSimple 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;
}
  • PersistMessagesWithPostgresql stores saga state, outbox, inbox, and scheduled messages in the same PostgreSQL database
  • UseEntityFrameworkCoreTransactions ensures saga state changes and outgoing messages share one DB transaction
  • ScanHandlers auto-discovers saga handler methods and message consumers
  • ListenerCount(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.

You can find the sample code in this repository: