Saga Pattern for Distributed Transactions in C
This article provides a practical overview of the Saga pattern for maintaining data consistency across distributed C# microservices. You will learn the fundamental mechanisms of the pattern, the differences between choreography and orchestration, how compensating transactions replace traditional two-phase commits, and how to implement Sagas in .NET using modern frameworks like MassTransit.
The Problem with Distributed Transactions
In a monolithic architecture, a single database can rely on ACID transactions to guarantee that all operations either succeed together or fail together. In a distributed microservices architecture, however, each service owns its private database. A business process spanning multiple services—such as an e-commerce checkout involving ordering, payment processing, and inventory allocation—cannot use a single database transaction.
Traditional distributed solutions like the Two-Phase Commit (2PC) protocol introduce high latency and tight coupling. They lock database rows across networks, creating bottlenecks and single points of failure that harm availability and scalability.
What Is the Saga Pattern?
The Saga pattern addresses this challenge by breaking a large, distributed transaction into a sequence of smaller, local transactions. Each local transaction updates the database within a single service and publishes an event or message to trigger the next transaction in the sequence.
Because there is no global lock, the Saga pattern relies on eventual consistency. If any step in the sequence fails, the Saga executes a series of compensating transactions to reverse the changes made by the preceding steps, returning the system to a consistent state.
Saga Execution Approaches
There are two primary ways to coordinate a Saga:
1. Choreography
In a choreographed Saga, there is no central coordinator. Each service performs its local transaction and publishes an event. Other services listen to these events and execute their local tasks accordingly.
- Pros: Simple to set up for workflows with few steps; no central point of coordination.
- Cons: Can become difficult to track and maintain as the workflow grows; cyclic dependencies can arise; testing the end-to-end flow is complex.
2. Orchestration
In an orchestrated Saga, a centralized coordinator (an orchestrator) manages the entire workflow. The orchestrator tells each participant service what action to execute by sending commands and listening for response events.
- Pros: Centralized business logic; easy to understand workflow state; clear separation of concerns; avoids complex event graphs.
- Cons: Requires managing the coordinator infrastructure; can inadvertently centralize too much domain logic if not designed carefully.
Implementing Sagas in C
While you can write custom coordination logic using background services and message brokers like RabbitMQ or Azure Service Bus, the .NET ecosystem provides robust open-source libraries that handle Saga persistence, state tracking, and retries natively.
MassTransit State Machine (Automatonymous)
MassTransit is one of the most widely used message-based frameworks in C# for orchestrating Sagas. It allows you to define a Saga state machine using a fluent API.
Consider an order workflow:
OrderServicesubmits a new order request and creates an instance ofOrderSagaState.- The orchestrator sends a
ProcessPaymentcommand toPaymentService. - If
PaymentServicesucceeds, the orchestrator issues aReserveInventorycommand toInventoryService. - If
InventoryServicefails (e.g., out of stock), the orchestrator triggers a compensating command:RefundPaymentsent toPaymentService, followed by marking the order asCancelled.
In C#, a MassTransit state machine typically looks like this:
public class OrderState : SagaStateMachineInstance
{
public Guid CorrelationId { get; set; }
public string CurrentState { get; set; }
public Guid OrderId { get; set; }
}
public class OrderStateMachine : MassTransitStateMachine<OrderState>
{
public State ProcessingPayment { get; private set; }
public State ReservingInventory { get; private set; }
public Event<OrderSubmitted> OrderSubmitted { get; private set; }
public Event<PaymentCompleted> PaymentCompleted { get; private set; }
public Event<PaymentFailed> PaymentFailed { get; private set; }
public Event<InventoryAllocationFailed> InventoryFailed { get; private set; }
public OrderStateMachine()
{
InstanceState(x => x.CurrentState);
Initially(
When(OrderSubmitted)
.Then(context => context.Saga.OrderId = context.Message.OrderId)
.Publish(context => new ProcessPayment(context.Saga.OrderId))
.TransitionTo(ProcessingPayment)
);
During(ProcessingPayment,
When(PaymentCompleted)
.Publish(context => new ReserveInventory(context.Saga.OrderId))
.TransitionTo(ReservingInventory),
When(PaymentFailed)
.Publish(context => new CancelOrder(context.Saga.OrderId))
.Finalize()
);
During(ReservingInventory,
When(InventoryFailed)
.Publish(context => new RefundPayment(context.Saga.OrderId))
.Publish(context => new CancelOrder(context.Saga.OrderId))
.Finalize()
);
}
}Critical Design Considerations
To build resilient Sagas in C#, keep the following technical requirements in mind:
- Idempotency: Network partitions and retries mean services may receive duplicate messages. Every local transaction and compensating transaction handler must be idempotent.
- Correlation IDs: Every message, event, and command
within a Saga lifecycle must carry a shared identifier (such as
CorrelationId) to map incoming events back to the active Saga instance. - Semantic Rollbacks: Compensating transactions do not restore the database to its exact prior state via a snapshot; they apply forward-moving operations that logically undo previous actions (e.g., creating a credit entry instead of deleting a debit entry).
- State Persistence: In orchestration, the Saga's current state must be persisted reliably (using databases like SQL Server, PostgreSQL, or MongoDB) to survive service crashes and restarts.