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

  1. User purchases a ticket.
  2. User scans ticket at gate.
  3. Gate denies entry due to a system reason (e.g., scanner service had partial outage, policy misconfiguration).
  4. Orchestrator triggers a refund in Ticket Service.
  5. 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?