# Saga Pattern in a Modular Monolith

> Placing an order touches Ordering, Inventory, Payment, and Shipping, and any step can fail after the previous ones already committed. The saga pattern breaks the process into local transactions with compensating actions, coordinated by an orchestrator that fits in one class. Here is how to build one inside a modular monolith, without a distributed transaction in sight.

Published: 2026-08-13. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/saga-pattern-modular-monolith

The saga pattern coordinates a long-running business process that spans multiple modules by breaking it into a sequence of local transactions, with compensating actions that undo completed steps when a later step fails.
Inside a modular monolith it's simpler than its microservices reputation suggests: the orchestrator fits in one class, and there is no distributed transaction in sight.

Placing an order starts in the Ordering module.
Fulfilling it needs Inventory, Payment, and Shipping, and any of those steps can fail after the previous ones already committed.
So what happens when the payment is declined and the stock is already reserved?
That's the saga pattern's job.

## The Problem With Cross-Module Transactions

In a [**modular monolith**](https://milanjovanovic.tech/blog/modular-monolith-architecture-dotnet), each module owns its data. When a business process spans multiple modules - like placing an order that requires inventory reservation, payment processing, and shipping - you can't wrap everything in a single database transaction.

If your modules use [**separate databases**](https://milanjovanovic.tech/blog/modular-monolith-data-isolation) or [**separate schemas**](https://milanjovanovic.tech/blog/schema-per-module-vs-database-per-module), there's no way to do a distributed ACID transaction without introducing tight coupling.

To be precise: with schema-per-module in one physical database, a cross-module transaction is *technically* possible.
But using it means one module's transaction now holds locks on another module's tables, and your modules can never be separated without rewriting the process.
Treat module boundaries as transaction boundaries, and the saga pattern follows naturally.

The saga pattern solves this by breaking a long-running process into a sequence of local transactions, each within a single module, coordinated through events or a centralized orchestrator.

## Choreography vs Orchestration

There are two approaches to implementing sagas.

**Choreography** - Each module listens for events and decides what to do next. There's no central coordinator. Module A publishes an event, Module B reacts, publishes its own event, and Module C reacts to that.

**Orchestration** - A central saga orchestrator tells each module what to do and when. It maintains the state of the process and sends commands to each participant.

I prefer orchestration for most use cases because the process flow is explicit and visible in one place. With choreography, the business logic is scattered across event handlers in different modules, making it hard to understand and debug.
I've written a deeper comparison in [**Orchestration vs Choreography**](https://milanjovanovic.tech/blog/orchestration-vs-choreography).

## Defining a Saga State Machine

A saga is essentially a state machine. Each step transitions the saga to a new state, and failures trigger compensating actions.

![State diagram of the order saga moving through Started, InventoryReserved, PaymentProcessed, ShipmentScheduled, and Completed on the happy path, with failure transitions to InventoryCompensated and Failed](https://milanjovanovic.tech/blogs/articles/saga-pattern-modular-monolith/order-saga-state.png)

```csharp
public class OrderSaga
{
    public Guid Id { get; private set; }
    public Guid OrderId { get; private set; }
    public decimal TotalAmount { get; private set; }
    public OrderSagaState State { get; private set; }
    public DateTime StartedAtUtc { get; private set; }
    public DateTime? CompletedAtUtc { get; private set; }
    public string? FailureReason { get; private set; }

    public static OrderSaga Start(Guid orderId, decimal totalAmount)
    {
        return new OrderSaga
        {
            Id = Guid.NewGuid(),
            OrderId = orderId,
            TotalAmount = totalAmount,
            State = OrderSagaState.Started,
            StartedAtUtc = DateTime.UtcNow
        };
    }

    public void TransitionTo(OrderSagaState newState)
    {
        State = newState;
    }

    public void Fail(string reason)
    {
        FailureReason = reason;
        State = OrderSagaState.Failed;
    }

    public void Complete()
    {
        State = OrderSagaState.Completed;
        CompletedAtUtc = DateTime.UtcNow;
    }
}

public enum OrderSagaState
{
    Started,
    InventoryReserved,
    PaymentProcessed,
    ShipmentScheduled,
    Completed,
    InventoryCompensated,
    Failed
}
```

## Building the Saga Orchestrator

The orchestrator processes events and drives the saga forward. Each event handler checks the current state and issues the next command.

The saga is triggered by the same `OrderPlacedIntegrationEvent` contract used for [**event-driven communication**](https://milanjovanovic.tech/blog/event-driven-communication-modules), and every later step publishes its own thin event.
`IIntegrationEvent` is the marker from the [**shared kernel**](https://milanjovanovic.tech/blog/shared-kernel-pattern-modular-monolith): an `EventId` plus an `OccurredOnUtc` timestamp.

```csharp
// Ordering.Contracts
public sealed record OrderPlacedIntegrationEvent(
    Guid EventId,
    DateTime OccurredOnUtc,
    Guid OrderId,
    Guid CustomerId,
    decimal TotalAmount) : IIntegrationEvent;

public sealed record OrderLine(Guid ProductId, int Quantity);

// Inventory.Contracts
public sealed record StockReservedEvent(
    Guid EventId, DateTime OccurredOnUtc, Guid OrderId) : IIntegrationEvent;

public sealed record StockReservationFailedEvent(
    Guid EventId, DateTime OccurredOnUtc,
    Guid OrderId, string Reason) : IIntegrationEvent;

// Payment.Contracts
public sealed record PaymentProcessedEvent(
    Guid EventId, DateTime OccurredOnUtc, Guid OrderId) : IIntegrationEvent;

public sealed record PaymentFailedEvent(
    Guid EventId, DateTime OccurredOnUtc,
    Guid OrderId, string Reason) : IIntegrationEvent;

// Shipping.Contracts
public sealed record ShipmentScheduledEvent(
    Guid EventId, DateTime OccurredOnUtc, Guid OrderId) : IIntegrationEvent;
```

The orchestrator reacts to each of them:

```csharp
public class OrderSagaOrchestrator
{
    private readonly IOrderingModule _ordering;
    private readonly IInventoryModule _inventory;
    private readonly IPaymentModule _payment;
    private readonly IShippingModule _shipping;
    private readonly ISagaRepository _sagaRepository;

    public OrderSagaOrchestrator(
        IOrderingModule ordering,
        IInventoryModule inventory,
        IPaymentModule payment,
        IShippingModule shipping,
        ISagaRepository sagaRepository)
    {
        _ordering = ordering;
        _inventory = inventory;
        _payment = payment;
        _shipping = shipping;
        _sagaRepository = sagaRepository;
    }

    public async Task HandleAsync(OrderPlacedIntegrationEvent @event)
    {
        var saga = OrderSaga.Start(@event.OrderId, @event.TotalAmount);
        await _sagaRepository.SaveAsync(saga);

        var lines = await _ordering.GetOrderLinesAsync(@event.OrderId);
        await _inventory.ReserveStockAsync(@event.OrderId, lines);
    }

    public async Task HandleAsync(StockReservedEvent @event)
    {
        var saga = await _sagaRepository.GetByOrderIdAsync(@event.OrderId);
        saga.TransitionTo(OrderSagaState.InventoryReserved);
        await _sagaRepository.SaveAsync(saga);

        await _payment.ProcessPaymentAsync(saga.OrderId, saga.TotalAmount);
    }

    public async Task HandleAsync(PaymentProcessedEvent @event)
    {
        var saga = await _sagaRepository.GetByOrderIdAsync(@event.OrderId);
        saga.TransitionTo(OrderSagaState.PaymentProcessed);
        await _sagaRepository.SaveAsync(saga);

        await _shipping.ScheduleShipmentAsync(saga.OrderId);
    }

    public async Task HandleAsync(ShipmentScheduledEvent @event)
    {
        var saga = await _sagaRepository.GetByOrderIdAsync(@event.OrderId);
        saga.TransitionTo(OrderSagaState.ShipmentScheduled);
        saga.Complete();

        await _sagaRepository.SaveAsync(saga);
    }
}
```

Two details in this class carry more weight than they look.

The saga keeps `TotalAmount` in its **own state**, captured when the process started.
The Inventory module has no idea what the order costs, so `StockReservedEvent` can't be the source of that number; without it, the payment step has nothing correct to charge.

And each handler persists the state transition **before** issuing the next command.
Crash between the two and you get a saga parked in a known state that a timeout can pick up later, instead of a payment charge the saga doesn't remember requesting.

Each module exposes a public API - an interface - that the orchestrator calls. The modules don't know about the saga. They simply execute commands and publish events.

```csharp
public interface IOrderingModule
{
    Task<IReadOnlyList<OrderLine>> GetOrderLinesAsync(Guid orderId);
    Task CancelOrderAsync(Guid orderId, string reason);
}

public interface IInventoryModule
{
    Task ReserveStockAsync(Guid orderId, IReadOnlyList<OrderLine> lines);
    Task ReleaseStockAsync(Guid orderId);
}

public interface IPaymentModule
{
    Task ProcessPaymentAsync(Guid orderId, decimal amount);
}

public interface IShippingModule
{
    Task ScheduleShipmentAsync(Guid orderId);
}
```

## Implementing Compensating Actions

When a step fails, you need to undo previous steps. These are compensating actions - the reverse of the original operation.

```csharp
public async Task HandleAsync(PaymentFailedEvent @event)
{
    var saga = await _sagaRepository.GetByOrderIdAsync(@event.OrderId);

    // Compensate the inventory reservation
    await _inventory.ReleaseStockAsync(@event.OrderId);
    saga.TransitionTo(OrderSagaState.InventoryCompensated);

    saga.Fail(@event.Reason);
    await _sagaRepository.SaveAsync(saga);

    await _ordering.CancelOrderAsync(@event.OrderId, @event.Reason);
}

public async Task HandleAsync(StockReservationFailedEvent @event)
{
    var saga = await _sagaRepository.GetByOrderIdAsync(@event.OrderId);

    saga.Fail(@event.Reason);
    await _sagaRepository.SaveAsync(saga);

    await _ordering.CancelOrderAsync(@event.OrderId, @event.Reason);
}
```

Compensation must be idempotent. If the compensating action fails and gets retried, it should produce the same result. Use the [**outbox pattern**](https://milanjovanovic.tech/blog/outbox-pattern-dual-write-problem) to guarantee event delivery even during failures.

Note that compensation is not a rollback.
The payment happened; the refund is a new business operation with its own audit trail.
Some actions can't be perfectly compensated (you can't unsend an email), so design the step order to put hard-to-compensate actions last.

## Wiring Events to the Orchestrator

Use your module communication infrastructure to route [**integration events**](https://milanjovanovic.tech/blog/event-driven-communication-modules) to the orchestrator.
I'm using the `IIntegrationEventHandler<T>` abstraction from the [**shared kernel**](https://milanjovanovic.tech/blog/shared-kernel-pattern-modular-monolith):

```csharp
public interface IIntegrationEventHandler<in TEvent>
    where TEvent : IIntegrationEvent
{
    Task HandleAsync(TEvent @event, CancellationToken ct = default);
}
```

A small router implements it for every saga event and delegates to the orchestrator:

```csharp
public sealed class OrderSagaEventRouter :
    IIntegrationEventHandler<OrderPlacedIntegrationEvent>,
    IIntegrationEventHandler<StockReservedEvent>,
    IIntegrationEventHandler<StockReservationFailedEvent>,
    IIntegrationEventHandler<PaymentProcessedEvent>,
    IIntegrationEventHandler<PaymentFailedEvent>,
    IIntegrationEventHandler<ShipmentScheduledEvent>
{
    private readonly OrderSagaOrchestrator _orchestrator;

    public OrderSagaEventRouter(OrderSagaOrchestrator orchestrator)
    {
        _orchestrator = orchestrator;
    }

    public Task HandleAsync(
        OrderPlacedIntegrationEvent @event, CancellationToken ct = default) =>
        _orchestrator.HandleAsync(@event);

    public Task HandleAsync(
        StockReservedEvent @event, CancellationToken ct = default) =>
        _orchestrator.HandleAsync(@event);

    public Task HandleAsync(
        StockReservationFailedEvent @event, CancellationToken ct = default) =>
        _orchestrator.HandleAsync(@event);

    public Task HandleAsync(
        PaymentProcessedEvent @event, CancellationToken ct = default) =>
        _orchestrator.HandleAsync(@event);

    public Task HandleAsync(
        PaymentFailedEvent @event, CancellationToken ct = default) =>
        _orchestrator.HandleAsync(@event);

    public Task HandleAsync(
        ShipmentScheduledEvent @event, CancellationToken ct = default) =>
        _orchestrator.HandleAsync(@event);
}
```

Register the router once per event type it handles:

```csharp
public static class OrderSagaModule
{
    public static IServiceCollection AddOrderSaga(
        this IServiceCollection services)
    {
        services.AddScoped<OrderSagaOrchestrator>();
        services.AddScoped<ISagaRepository, SagaRepository>();

        services.AddScoped<
            IIntegrationEventHandler<OrderPlacedIntegrationEvent>,
            OrderSagaEventRouter>();
        services.AddScoped<
            IIntegrationEventHandler<StockReservedEvent>,
            OrderSagaEventRouter>();
        services.AddScoped<
            IIntegrationEventHandler<StockReservationFailedEvent>,
            OrderSagaEventRouter>();
        services.AddScoped<
            IIntegrationEventHandler<PaymentProcessedEvent>,
            OrderSagaEventRouter>();
        services.AddScoped<
            IIntegrationEventHandler<PaymentFailedEvent>,
            OrderSagaEventRouter>();
        services.AddScoped<
            IIntegrationEventHandler<ShipmentScheduledEvent>,
            OrderSagaEventRouter>();

        return services;
    }
}
```

Miss a registration and that event is silently ignored, which is exactly how sagas get stuck halfway.
An architecture test that asserts every saga event has a registered handler is cheap insurance.

## Persisting Saga State

The saga state must be persisted so it survives application restarts. A simple table works:

```csharp
public class SagaDbContext(DbContextOptions<SagaDbContext> options)
    : DbContext(options)
{
    public DbSet<OrderSaga> OrderSagas => Set<OrderSaga>();
}

public interface ISagaRepository
{
    Task<OrderSaga> GetByOrderIdAsync(Guid orderId);
    Task SaveAsync(OrderSaga saga);
}

public class SagaRepository : ISagaRepository
{
    private readonly SagaDbContext _dbContext;

    public SagaRepository(SagaDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<OrderSaga> GetByOrderIdAsync(Guid orderId)
    {
        return await _dbContext.OrderSagas
            .FirstOrDefaultAsync(s => s.OrderId == orderId)
            ?? throw new InvalidOperationException(
                $"Saga not found for order {orderId}.");
    }

    public async Task SaveAsync(OrderSaga saga)
    {
        var entry = _dbContext.Entry(saga);
        if (entry.State == EntityState.Detached)
        {
            _dbContext.OrderSagas.Add(saga);
        }

        await _dbContext.SaveChangesAsync();
    }
}
```

One production concern the simple repository hides: **concurrency**.
If two events for the same saga arrive close together, both handlers load the same row and the last write wins.
Add a concurrency token (`xmin` in PostgreSQL, `rowversion` in SQL Server) to the saga entity and retry on `DbUpdateConcurrencyException`.

## When to Use Sagas in a Modular Monolith

Not every cross-module operation needs a saga. Use sagas when:

- The process spans three or more modules
- Steps can fail independently and need compensation
- You need visibility into the process state for debugging or monitoring
- The process is long-running (seconds to days)

For simple two-module interactions, direct [**module communication**](https://milanjovanovic.tech/blog/modular-monolith-communication-patterns) with error handling is often sufficient.

If you'd rather not hand-roll the state machine, [**MassTransit's saga support**](https://milanjovanovic.tech/blog/implementing-the-saga-pattern-with-masstransit) gives you persistence, concurrency handling, and timeouts out of the box, and it works with an in-memory transport inside a monolith.
For sagas across separate services, see the [**saga pattern in .NET**](https://milanjovanovic.tech/blog/saga-pattern-dotnet) guide.

## Summary

1. Sagas coordinate long-running business processes that span multiple modules without distributed transactions.
2. Orchestration keeps the process flow explicit in a single class, making it easier to understand and debug.
3. Each saga step is a local transaction within one module, maintaining [**data isolation**](https://milanjovanovic.tech/blog/schema-per-module-vs-database-per-module).
4. Compensating actions undo previous steps when a later step fails - they must be idempotent.
5. Persist saga state so the process survives application restarts and can be monitored.
6. Use the [**outbox pattern**](https://milanjovanovic.tech/blog/outbox-pattern-dual-write-problem) alongside sagas for reliable event delivery.

Thanks for reading, and stay awesome!

---

## Frequently asked questions

### Do you need the saga pattern in a modular monolith?

Only for long-running processes that span three or more modules with independent failure modes. For simple two-module interactions, direct module communication with error handling is usually enough.

### What is the difference between saga orchestration and choreography?

With orchestration, a central coordinator tells each participant what to do and tracks the process state. With choreography, each module reacts to events and publishes its own, with no central coordinator. Orchestration is easier to understand and debug.

### What are compensating actions in a saga?

Compensating actions undo the effects of previously completed steps when a later step fails. For example, if payment fails after stock was reserved, the saga releases the stock reservation. They must be idempotent because they can be retried.

### Why not just use a database transaction across modules in a monolith?

Technically you can if modules share one database, but a cross-module transaction couples the modules at the data layer and breaks the boundary that makes future extraction possible. Sagas keep each step local to one module.

### How do you persist saga state in .NET?

Store the saga as a row in a database table with its current state, keyed by the business process ID. Add a concurrency token so two events processed at the same time cannot corrupt the state. Libraries like MassTransit provide this out of the box.
