SAGA Pattern
A SAGA is a pattern for handling long-running business processes in distributed systems without using a single distributed database transaction.
In a microservices architecture, each service owns its own data store. That means you cannot safely do:
“Update Service A DB, update Service B DB, update Service C DB — all in one ACID transaction.”
Instead, a SAGA coordinates a sequence of local transactions (each service commits its own DB changes) and uses messages to move the process forward.
If something fails, the SAGA triggers compensating actions to “undo” or offset earlier steps.
What a SAGA does (in one sentence)
A SAGA ensures a multi-step distributed workflow reaches a consistent outcome by coordinating steps and executing compensations when needed.
Why SAGA exists
Distributed transactions (2PC / XA) are usually avoided because they:
- reduce availability
- are complex to operate
- couple services tightly
- don’t play nicely with message brokers and retries
SAGAs are the practical alternative for real-world microservice systems.
Two common SAGA styles
1) Orchestration (central coordinator)
A dedicated Orchestrator service:
- decides what the next step is
- sends commands / publishes events
- tracks saga state
- triggers compensation on failure
Pros
- Clear control flow in one place
- Easier to reason about and test
Cons
- Orchestrator becomes a critical component
- You must design it carefully (state, idempotency, retries)
2) Choreography (event-driven, no central brain)
Services react to events and emit new events, forming a chain.
Pros
- No central service; more decentralized
Cons
- Harder to understand global flow
- Can become “spaghetti events” without strong discipline
Core SAGA concepts you must implement
1) Steps are local transactions
Each step is a normal local DB transaction inside a single service.
2) Correlation
All events/commands in a saga must include a correlation id (often called processId, sagaId, etc.) so the orchestrator can match replies to the correct saga instance.
3) Idempotency
Because messages may be delivered more than once, the orchestrator and all participants must be safe to retry:
- “same command/event again” must not corrupt state
- repeated messages should be ignored or treated as already done
4) Compensation
For each step that can’t be “rolled back” automatically, define a compensating action. Example: if you already issued a ticket, compensation might be “refund/cancel ticket”.
Simple example
Use case: Auto-refund when entry fails for a system reason
Story
- User purchases a ticket.
- User scans ticket at gate.
- Gate denies entry due to a system reason (e.g., scanner service had partial outage, policy misconfiguration).
- Orchestrator triggers a refund in Ticket Service.
- User is notified.
Event/command flow (concept)
sequenceDiagram participant TS as Ticket Service participant ACS as Access Control participant ORCH as Orchestrator participant MQ as Broker TS->>MQ: TicketPurchased (processId) MQ->>ORCH: TicketPurchased ORCH->>MQ: StartEntrySaga (processId) ACS->>MQ: EntryDenied (processId, reason=SYSTEM) MQ->>ORCH: EntryDenied ORCH->>MQ: RefundTicketCommand (processId, ticketId) TS->>MQ: TicketRefunded (processId) MQ->>ORCH: TicketRefunded ORCH->>MQ: SagaCompleted (processId)
Minimal C# example (illustrative orchestrator logic)
This snippet shows the shape of an orchestrator as a state machine.
public enum SagaState
{
Pending,
CreditCheckCompleted,
DocumentVerificationUploadPending,
DocumentVerificationCompleted,
LoanApproved,
Failed
}// NOTE: This is a simplified in-memory state store.
// In a real application this should be persisted somehow.
public class SagaStateStore
{
private readonly Dictionary<Guid, SagaState> _sagaStates = new();
public void UpdateState(Guid applicationId, SagaState state)
{
_sagaStates[applicationId] = state;
}
public SagaState GetState(Guid applicationId)
{
return _sagaStates.GetValueOrDefault(applicationId, SagaState.Pending);
}
}using DataContracts.Messages;
using DataContracts.Messages.ServiceMessages;
using Infrastructure.Messaging.Interfaces;
using Infrastructure.Messaging.RabbitMq;
using Services.Orchestrator.Saga;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<SagaStateStore>();
var app = builder.Build();
var scope = app.Services.CreateScope();
var sagaStateStore = scope.ServiceProvider.GetRequiredService<SagaStateStore>();
IMessageSender sender = await RabbitMqMessagingFactory.CreateSenderAsync(Constants.ExchangeName);
// SAGA - Step 1
// Receive loan application requests and start workflow.
// Start credit check
// No compensation
await RabbitMqMessagingFactory.CreateReceiverAsync<LoanApplicationSubmitted>(
Constants.ExchangeName,
async (@event, message) =>
{
sagaStateStore.UpdateState(message.LoanApplicationId, SagaState.Pending);
await sender.SendMessageAsync(new CreditCheckRequested(message)
{
CustomerFirstName = message.CustomerFirstName,
CustomerLastName = message.CustomerLastName
});
await sender.SendMessageAsync(new Notification(message)
{
Title = "Loan Application Received",
Message = "Your application for a loan has been received and will be further processed."
});
});
// SAGA - Step 2
// React to Credit check result
// on success: start document verification
// compensation for failure: send notification
await RabbitMqMessagingFactory.CreateReceiverAsync<CreditCheckCompleted>(
Constants.ExchangeName,
async (@event, message) =>
{
// Verify correct state in workflow
if (sagaStateStore.GetState(message.LoanApplicationId) != SagaState.Pending) return;
// Handle result of last step
if (message.Result.ResultStatus == ServiceResultStatus.Success)
{
// success
sagaStateStore.UpdateState(message.LoanApplicationId, SagaState.CreditCheckCompleted);
await sender.SendMessageAsync(new DocumentVerificationRequested(message));
await sender.SendMessageAsync(new Notification(message)
{
Title = "Credit Check Accepted",
Message = "Your data has been checked and released for further processing."
});
}
else
{
// compensation
sagaStateStore.UpdateState(message.LoanApplicationId, SagaState.Failed);
await sender.SendMessageAsync(new Notification(message)
{
Title = "Credit Check Declined",
Message = message.Result.FailureReason ?? "Your application got declined for an unknown reason."
});
}
});
// SAGA - Step 3
// React to document verification result
// on success: start loan approval
// compensation for failure: send notification
await RabbitMqMessagingFactory.CreateReceiverAsync<DocumentVerificationCompleted>(
Constants.ExchangeName,
async (@event, message) =>
{
var sagaState = sagaStateStore.GetState(message.LoanApplicationId);
if (sagaState != SagaState.CreditCheckCompleted && sagaState != SagaState.DocumentVerificationUploadPending) return;
if (message.Result.ResultStatus == ServiceResultStatus.Success)
{
// success
sagaStateStore.UpdateState(message.LoanApplicationId, SagaState.DocumentVerificationCompleted);
await sender.SendMessageAsync(new LoanApprovalRequested(message));
await sender.SendMessageAsync(new Notification(message)
{
Title = "Document Verification Completed",
Message = "We received valid documents for a pending application. Your Application is marked for further processing."
});
}
else
{
// compensation
sagaStateStore.UpdateState(message.LoanApplicationId, SagaState.Failed);
await sender.SendMessageAsync(new Notification(message)
{
Title = "Document Verification Failed",
Message = message.Result.FailureReason ?? "Your document verification failed for an unknown reason."
});
}
});
// intermediate result: we might need to wait for document upload
await RabbitMqMessagingFactory.CreateReceiverAsync<DocumentVerificationUploadPending>(
Constants.ExchangeName,
async (@event, message) =>
{
var sagaState = sagaStateStore.GetState(message.LoanApplicationId);
if (sagaState != SagaState.CreditCheckCompleted) return;
sagaStateStore.UpdateState(message.LoanApplicationId, SagaState.DocumentVerificationUploadPending);
await sender.SendMessageAsync(new Notification(message)
{
Title = "Pending Document Upload",
Message = "Received a successful credit check result. Waiting for verification document upload."
});
});
// SAGA - Step 4
// React to loan approval result
// on success: send notification
// compensation for failure: send notification
await RabbitMqMessagingFactory.CreateReceiverAsync<LoanApprovalCompleted>(
Constants.ExchangeName,
async (@event, message) =>
{
if (sagaStateStore.GetState(message.LoanApplicationId) != SagaState.DocumentVerificationCompleted) return;
if (message.Result.ResultStatus == ServiceResultStatus.Success)
{
// success
sagaStateStore.UpdateState(message.LoanApplicationId, SagaState.LoanApproved);
await sender.SendMessageAsync(new Notification(message)
{
Title = "Loan Approved",
Message = "Your application was successfully processed. Your loan request was granted"
});
}
else
{
// compensation
sagaStateStore.UpdateState(message.LoanApplicationId, SagaState.Failed);
await sender.SendMessageAsync(new Notification(message)
{
Title = "Loan Declined",
Message = message.Result.FailureReason ?? "Your loan application got declined for an unknown reason."
});
}
});
app.Run();What this example highlights
- SAGA instances are state machines
- Every message must be correlated with an ID (in this case
LoanApplicationId) - Idempotency is (at least partially) handled, by requiring the correct state when entering a step.
- The orchestrator triggers compensating actions (e.g. refund)
Practical checklist (what you should enforce in your implementation)
- All saga-related messages carry a
processId - Orchestrator persists state (recommended)
- Orchestrator is idempotent (duplicate events are safe)
- Participants handle duplicate commands safely
- Compensation is implemented and observable (logs + notifications)
What you should be able to answer after reading this
- Why can’t we just use a normal transaction across microservices?
- What’s the difference between orchestration and choreography?
- What does “compensation” mean in a SAGA?
- How do correlation ids and idempotency prevent chaos under retries?