Saga Pattern in .NET: Managing Distributed Transactions

distributed-systemsdotnetsoftware-architecture
Saga Pattern in .NET: Managing Distributed Transactions

The saga pattern coordinates a business workflow across local transactions in separate services. Each completed step records its result, and failures can trigger compensating actions such as releasing inventory. A saga exposes intermediate states and needs durable progress, reliable messages, idempotent operations, and a way to resolve uncertain outcomes.

What Problem Does a Saga Solve?

An order is awaiting payment when the payment request times out. The payment may have succeeded even though the response never reached the caller. There is no database rollback that can tell the payment provider to forget the request.

The workflow needs a stored state and a recovery decision. It can query the provider using the operation ID, retry with the same idempotency key where supported, or escalate for reconciliation. Treating every timeout as a declined payment creates incorrect compensation.

The distributed transaction comparison explains when compatible resource managers can coordinate an atomic commit. A saga uses a different boundary: each service commits independently, and the workflow manages the consequences. When all relevant writes fit in one database transaction, that local transaction is usually simpler.

Local Transactions and Compensation

Consider an order flow that creates a pending order, reserves inventory, requests payment, and confirms the order. A confirmed payment decline requires releasing the reservation and cancelling the pending order. An unknown payment outcome requires reconciliation before that decision.

The order workflow creates a pending order, reserves inventory, processes payment, and confirms the order; a confirmed decline releases inventory and cancels the order, while an unknown payment outcome requires reconciliation

A compensation is a new business operation. Releasing a reservation can fail and need retry; refunding a successful charge may not reverse fees or notifications. Define which completed steps can be compensated and how to recover when compensation itself fails.

Choreography: Services React to Events

In choreography, each service owns its next-step decision. The order service publishes OrderPlaced, inventory reacts by reserving stock, and payment reacts to InventoryReserved. The order service eventually consumes the payment result and changes the order status.

Choreography saga: the order service publishes OrderPlaced, the inventory service reserves stock and publishes InventoryReserved, the payment service charges and publishes PaymentCompleted, and the order service confirms the order, each reacting to events with no central coordinator

Every database change and its outgoing event need the same local transaction through an outbox. Saving a reservation and then publishing its result directly leaves a crash window that can stall the workflow forever. Consumer deduplication must cover the incoming message and business update too.

Choreography works when the flow remains easy to reconstruct from those handlers. As branches and compensations grow, tracing the current workflow state across several services becomes harder. The orchestration versus choreography guide compares those operational costs.

Orchestration: Persist the Workflow State

In orchestration, a coordinator stores progress and sends commands to the services that own each operation. Those services return result events. The coordinator does not gain a distributed database transaction; it gains an explicit record of what happened and what should happen next.

Order saga states track inventory, payment, compensation, and order confirmation or cancellation; terminal states are reached only after the order service acknowledges the final operation

MassTransit implements persistent state machines for this style of workflow. The following coordinator targets .NET 10 and MassTransit 8.5.11. It uses PostgreSQL for saga state and a transactional consumer outbox for outgoing commands.

Create the project and install the packages:

dotnet new console -n OrderSagaDemo -f net10.0
cd OrderSagaDemo
dotnet add package Microsoft.Extensions.Hosting --version 10.0.0
dotnet add package MassTransit.RabbitMQ --version 8.5.11
dotnet add package MassTransit.EntityFrameworkCore --version 8.5.11
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL --version 10.0.0

Use the PostgreSQL and RabbitMQ compose.yaml from the outbox example, or equivalent local services. The coordinator below creates a separate order_sagas database for demonstration. Production deployments should apply reviewed EF migrations before starting the service.

Define Contracts and States

Put these contracts and state class in Contracts.cs. Command handlers use OrderId to load their service's required data or reservation, so the coordinator does not store the inventory model:

using MassTransit;

namespace OrderSagaDemo;

public record OrderPlaced(Guid OrderId, decimal TotalAmount);
public record ReserveInventory(Guid OrderId);
public record InventoryReserved(Guid OrderId);
public record InventoryRejected(Guid OrderId);
public record ProcessPayment(Guid OrderId, decimal TotalAmount);
public record PaymentCompleted(Guid OrderId);
public record PaymentDeclined(Guid OrderId);
public record ReleaseInventory(Guid OrderId);
public record InventoryReleased(Guid OrderId);
public record ConfirmOrder(Guid OrderId);
public record OrderConfirmed(Guid OrderId);
public record CancelOrder(Guid OrderId);
public record OrderCancelled(Guid OrderId);

public class OrderSagaState : SagaStateMachineInstance
{
    public Guid CorrelationId { get; set; }
    public string CurrentState { get; set; } = string.Empty;
    public decimal TotalAmount { get; set; }
}

PaymentDeclined means the payment service established that the payment was rejected. It must not emit that event merely because a provider call timed out. The payment service should persist and reconcile uncertain attempts using a stable provider idempotency key.

Add OrderSaga.cs:

using MassTransit;

namespace OrderSagaDemo;

public class OrderSaga : MassTransitStateMachine<OrderSagaState>
{
    public State AwaitingInventory { get; private set; } = null!;
    public State AwaitingPayment { get; private set; } = null!;
    public State Compensating { get; private set; } = null!;
    public State AwaitingConfirmation { get; private set; } = null!;
    public State AwaitingCancellation { get; private set; } = null!;
    public State Completed { get; private set; } = null!;
    public State Failed { get; private set; } = null!;

    public Event<OrderPlaced> Placed { get; private set; } = null!;
    public Event<InventoryReserved> Reserved { get; private set; } = null!;
    public Event<InventoryRejected> Rejected { get; private set; } = null!;
    public Event<PaymentCompleted> Paid { get; private set; } = null!;
    public Event<PaymentDeclined> Declined { get; private set; } = null!;
    public Event<InventoryReleased> Released { get; private set; } = null!;
    public Event<OrderConfirmed> Confirmed { get; private set; } = null!;
    public Event<OrderCancelled> Cancelled { get; private set; } = null!;

    public OrderSaga()
    {
        InstanceState(x => x.CurrentState);
        Event(() => Placed, e => e.CorrelateById(c => c.Message.OrderId));
        Event(() => Reserved, e => e.CorrelateById(c => c.Message.OrderId));
        Event(() => Rejected, e => e.CorrelateById(c => c.Message.OrderId));
        Event(() => Paid, e => e.CorrelateById(c => c.Message.OrderId));
        Event(() => Declined, e => e.CorrelateById(c => c.Message.OrderId));
        Event(() => Released, e => e.CorrelateById(c => c.Message.OrderId));
        Event(() => Confirmed, e => e.CorrelateById(c => c.Message.OrderId));
        Event(() => Cancelled, e => e.CorrelateById(c => c.Message.OrderId));

        Initially(When(Placed)
            .Then(c => c.Saga.TotalAmount = c.Message.TotalAmount)
            .Send(new Uri("queue:reserve-inventory"),
                c => new ReserveInventory(c.Saga.CorrelationId))
            .TransitionTo(AwaitingInventory));

        During(AwaitingInventory,
            Ignore(Placed),
            When(Reserved)
                .Send(new Uri("queue:process-payment"),
                    c => new ProcessPayment(c.Saga.CorrelationId, c.Saga.TotalAmount))
                .TransitionTo(AwaitingPayment),
            When(Rejected)
                .Send(new Uri("queue:cancel-order"),
                    c => new CancelOrder(c.Saga.CorrelationId))
                .TransitionTo(AwaitingCancellation));

        During(AwaitingPayment,
            Ignore(Reserved),
            When(Paid)
                .Send(new Uri("queue:confirm-order"),
                    c => new ConfirmOrder(c.Saga.CorrelationId))
                .TransitionTo(AwaitingConfirmation),
            When(Declined)
                .Send(new Uri("queue:release-inventory"),
                    c => new ReleaseInventory(c.Saga.CorrelationId))
                .TransitionTo(Compensating));

        During(Compensating,
            Ignore(Declined),
            When(Released)
                .Send(new Uri("queue:cancel-order"),
                    c => new CancelOrder(c.Saga.CorrelationId))
                .TransitionTo(AwaitingCancellation));

        During(AwaitingConfirmation,
            Ignore(Paid),
            When(Confirmed).TransitionTo(Completed));

        During(AwaitingCancellation,
            Ignore(Rejected),
            Ignore(Released),
            When(Cancelled).TransitionTo(Failed));

        During(Completed, Ignore(Confirmed), Ignore(Paid), Ignore(Placed));
        During(Failed, Ignore(Cancelled), Ignore(Declined), Ignore(Placed));
    }
}

The coordinator waits for OrderConfirmed or OrderCancelled before entering a terminal state. Sending the command alone only proves that the next action was requested. The terminal rows remain stored here; define a retention policy before deleting completed saga history.

Wire Persistence and the Consumer Outbox

Create OrderSagaDbContext.cs with the state mapping and outbox entities:

using MassTransit;
using MassTransit.EntityFrameworkCoreIntegration;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace OrderSagaDemo;

public class OrderSagaMap : SagaClassMap<OrderSagaState>
{
    protected override void Configure(
        EntityTypeBuilder<OrderSagaState> entity, ModelBuilder model)
    {
        entity.Property(x => x.CurrentState).HasMaxLength(64);
        entity.Property(x => x.TotalAmount).HasPrecision(18, 2);
    }
}

public class OrderSagaDbContext(DbContextOptions<OrderSagaDbContext> options)
    : SagaDbContext(options)
{
    protected override IEnumerable<ISagaClassMap> Configurations
    {
        get { yield return new OrderSagaMap(); }
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        modelBuilder.AddInboxStateEntity();
        modelBuilder.AddOutboxMessageEntity();
        modelBuilder.AddOutboxStateEntity();
    }
}

Replace Program.cs with the host registration:

using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using OrderSagaDemo;

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddDbContext<OrderSagaDbContext>(options =>
    options.UseNpgsql(
        "Host=localhost;Database=order_sagas;Username=postgres;Password=postgres"));

builder.Services.AddMassTransit(x =>
{
    x.AddSagaStateMachine<OrderSaga, OrderSagaState>()
        .EntityFrameworkRepository(r =>
        {
            r.ConcurrencyMode = ConcurrencyMode.Pessimistic;
            r.ExistingDbContext<OrderSagaDbContext>();
            r.UsePostgres();
        });

    x.AddEntityFrameworkOutbox<OrderSagaDbContext>(o => o.UsePostgres());

    x.UsingRabbitMq((context, cfg) =>
    {
        cfg.Host("localhost", "/", h =>
        {
            h.Username("guest");
            h.Password("guest");
        });
        cfg.ReceiveEndpoint("order-saga", e =>
        {
            e.UseMessageRetry(r => r.Intervals(100, 500, 1000));
            e.UseEntityFrameworkOutbox<OrderSagaDbContext>(context);
            e.ConfigureSaga<OrderSagaState>(context);
        });
    });
});

using var host = builder.Build();
await using (var scope = host.Services.CreateAsyncScope())
{
    var db = scope.ServiceProvider.GetRequiredService<OrderSagaDbContext>();
    await db.Database.EnsureCreatedAsync();
}
await host.RunAsync();

Run it with dotnet run. This starts the coordinator; inventory, payment, and order command handlers are separate services that must provision the named queues and publish their result contracts. Deploy these endpoints before the order service starts publishing OrderPlaced events.

The saga repository and consumer outbox use the same DbContext. That lets the transactional consumer outbox coordinate the state transition, inbox record, and outgoing commands. The retry policy addresses short transient failures; exhausted messages still need error-queue monitoring and recovery.

Handle Duplicates, Missing Results, and Timeouts

A saga state machine does not automatically buffer an event until a future state can handle it. Unhandled events normally fault, and messages for missing instances need an explicit policy. The sample ignores selected expected duplicate results; add rules for the duplicate and out-of-order cases your contracts permit.

Every command handler must also be idempotent. Reserve inventory using a unique operation or reservation ID, with that uniqueness enforced in the same transaction as the stock update. A preliminary "does this reservation exist?" query alone races with another delivery.

Database inbox deduplication does not make external side effects transactional. Payment calls need a stable provider idempotency key and persisted reconciliation state. Redelivering a command after a timeout must not issue a new logical charge.

The coordinator above demonstrates the confirmed-success and confirmed-failure paths. Before production use, add durable deadlines for pending states, payment-outcome reconciliation, handling for exhausted command retries, and alerts for failed compensation. The MassTransit saga walkthrough provides additional state-machine context.

Test restart recovery as well as the happy path. Check that a crash between persisting state and delivering a command does not lose that command, and that duplicate results cannot repeat a charge or reservation. Observe stuck saga age alongside message queue failure metrics.

Summary

Use a saga when a business process spans local transactions and the business can accept intermediate states. Define compensation for completed steps, reconciliation for uncertain outcomes, and terminal states that reflect acknowledged results.

Persist state and outgoing messages together, make every step idempotent, and give operators a way to recover stalled workflows. Those behaviors determine whether a saga remains reliable after the first timeout or restart.

Frequently Asked Questions

What is the Saga pattern?

A saga is a sequence of local transactions across multiple services. Each step updates its own database and triggers the next step, and failures can trigger compensating actions for completed steps. Compensation is a new business operation and can itself fail.

What is the difference between choreography and orchestration?

In choreography, each service reacts to events and decides what to do next, with no central coordinator. In orchestration, a central saga orchestrator sends commands to services and tracks the state of the whole flow.

Is the Saga pattern the same as a distributed transaction?

No. A distributed transaction (two-phase commit) locks resources across services to get atomicity. A saga uses separate local transactions, permits intermediate states, and uses business compensation rather than a distributed rollback.

What is a compensating transaction?

A compensating transaction semantically undoes a completed saga step, such as refunding a payment or releasing reserved inventory. It is a new operation, not a database rollback, so it must be designed explicitly.

When should I avoid the Saga pattern?

Avoid sagas when the operations live in one database, where a regular transaction is simpler and stronger. Sagas only pay off when a business process spans multiple services with separate data stores.

  • Amazon SQS With .NET: A Practical Guide

    Amazon SQS removes the broker from your operations burden, but reliable consumers still depend on visibility timeouts, explicit deletes, long polling, and a dead letter policy. This guide builds that baseline in .NET with the AWS SDK, FIFO queues, batch operations, and MassTransit.

  • Azure Service Bus Topics, Subscription Filters, and Dead Letter Queues in .NET

    Go beyond basic queues in Azure Service Bus: publish to topics, route messages with SQL and correlation filters on the broker, and build a dead letter queue handling strategy in .NET.

  • Backpressure in .NET: Handling Producers That Outrun Consumers

    Backpressure slows producers when consumers cannot keep up. Use bounded .NET channels to wait for capacity, drop replaceable updates, or reject excess requests with 503. The examples distinguish admission to memory from durable processing and show how to size a queue for a burst.

Browse all Distributed Systems guides →
Loading comments...

Whenever you're ready, there are 5 ways I can help you:

  1. Pragmatic Self-Hosting: Learn to deploy and run your apps on a VPS with Dokploy and Tailscale. Keep administration private, automate releases, and practice restoring backups, with source code and a runbook you can reuse.
  2. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  3. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  4. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  5. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

One practical .NET tip, every Saturday.

Join 66,000+ engineers who read it. Free, and you can unsubscribe any time.