System Design

Saga Pattern: Mastering Distributed Transactions in Microservices

Demystify the Saga pattern for distributed transactions in microservices. Learn choreography vs. orchestration, compensating transactions, and real-world examples for robust systems.

Khader Vali October 4, 2026 16 min read

Saga Pattern: Mastering Distributed Transactions in Microservices

As microservices architectures become the de facto standard for building scalable and resilient applications, developers frequently encounter a significant challenge: managing distributed transactions. In a monolithic application, ensuring data consistency across multiple operations is relatively straightforward, often handled by a single ACID-compliant database transaction. However, in a distributed microservices environment, where data is often partitioned across multiple services, each with its own database, maintaining consistency becomes exponentially more complex. This is where patterns like the Saga come into play.

At Khadervali.com, we believe in sharing practical, battle-tested solutions to complex software engineering problems. Today, we’re diving deep into the Saga pattern, a powerful approach to managing distributed transactions that helps you build robust and consistent microservices without sacrificing the benefits of distributed systems. Whether you’re a seasoned architect or a developer just starting your microservices journey, this guide will provide a comprehensive understanding of Sagas, their implementation strategies, and how to apply them effectively in real-world scenarios.

The Problem with Distributed Transactions in Microservices

Before we explore the Saga pattern, let’s understand why traditional transaction models fall short in a microservices landscape.

ACID and Two-Phase Commit (2PC) in Monoliths

In a traditional monolithic application, transactions adhere to the ACID properties:

  • Atomicity: All operations in a transaction either succeed or fail as a single, indivisible unit.
  • Consistency: A transaction brings the database from one valid state to another.
  • Isolation: Concurrent transactions execute as if they were running serially, preventing interference.
  • Durability: Once a transaction is committed, its changes are permanent, even in the event of system failure.

Achieving ACID properties over a single database is handled by the database itself. When a transaction needs to span multiple resources (e.g., two different databases or a database and a message queue), the Two-Phase Commit (2PC) protocol is often used. 2PC works by having a coordinator service that orchestrates the commit or rollback of a transaction across multiple participants.

  1. Prepare Phase: The coordinator asks all participants to prepare to commit. Each participant performs its operations and logs them to a durable undo/redo log, then votes “yes” or “no.”
  2. Commit Phase: If all participants vote “yes,” the coordinator instructs them to commit. If any participant votes “no” or fails, the coordinator instructs all participants to roll back.

Why 2PC is Problematic for Microservices

While 2PC provides strong consistency, it introduces significant drawbacks in a microservices architecture:

  1. Tight Coupling: Participants in a 2PC transaction are tightly coupled to the coordinator and to each other. A failure in one participant can block the entire transaction, reducing overall system availability.
  2. Performance Bottleneck: The synchronous nature of 2PC, with its communication overhead and blocking operations, can lead to performance bottlenecks, especially under high load.
  3. Reduced Availability: During the prepare phase, participants often hold locks on resources, making those resources unavailable to other transactions. If the coordinator fails during this phase, resources can remain locked indefinitely, leading to a distributed deadlock.
  4. Heterogeneous Data Stores: Microservices often use different types of databases (e.g., relational, NoSQL, graph databases) optimized for their specific needs. Many modern NoSQL databases do not support 2PC, making it impossible to use this protocol across heterogeneous data stores.
  5. Scalability Challenges: The global coordination required by 2PC inherently limits the scalability of the system, running counter to one of the primary benefits of microservices.
  6. CAP Theorem: The CAP theorem states that a distributed system can only guarantee two out of three properties: Consistency, Availability, and Partition tolerance. 2PC prioritizes strong consistency, often at the cost of availability and partition tolerance, which are crucial for resilient microservices.

Given these limitations, microservices architectures typically embrace an eventual consistency model. This means that instead of requiring all parts of a distributed transaction to commit simultaneously, we allow for temporary inconsistencies, with the guarantee that the system will eventually reach a consistent state. The Saga pattern is a powerful mechanism for achieving this eventual consistency.

Saga Pattern: Mastering Distributed Transactions in Microservices
Generated Image

Introducing the Saga Pattern

The Saga pattern, originally described by Hector Garcia-Molina and Kenneth Salem in 1987, is a way to manage distributed transactions that maintain data consistency across multiple services without using 2PC. A Saga is a sequence of local transactions, where each local transaction updates data within a single service and publishes an event that triggers the next local transaction in the saga. If a local transaction fails, the saga executes a series of compensating transactions to undo the changes made by the preceding local transactions.

Key Principles of the Saga Pattern

  • Sequence of Local Transactions: A saga is composed of multiple local ACID transactions, each performed by a single service.
  • Eventual Consistency: The system achieves consistency over time rather than instantaneously. Intermediate states may be inconsistent, but the final state will be consistent.
  • Compensating Transactions: To handle failures, each local transaction in a saga must have a corresponding compensating transaction. A compensating transaction is designed to undo the effects of a preceding transaction. It doesn’t roll back the transaction in the traditional sense (as the local transaction might have already committed), but rather performs an inverse operation.
  • No Global Lock: Unlike 2PC, Sagas do not involve global locks, which enhances availability and throughput.

Two Main Types of Sagas: Choreography vs. Orchestration

There are two primary ways to implement the Saga pattern:

  1. Choreography-based Saga: Each service produces and listens to events, deciding for itself whether to perform its local transaction. Services communicate directly with each other via events.
  2. Orchestration-based Saga: A dedicated “Saga Orchestrator” (or “Saga Coordinator”) service is responsible for centralizing the decision-making and sequencing of the saga’s local transactions. It tells each participant service what local transaction to execute.

Let’s explore each approach in detail.

Choreography-based Saga

Description

In a choreography-based saga, each service involved in the distributed transaction operates autonomously. There’s no central orchestrator. Instead, each service performs its local transaction, publishes an event indicating the completion of its task, and then other services listen to these events and react accordingly by performing their own local transactions. This pattern is often implemented using a publish/subscribe messaging system (e.g., Kafka, RabbitMQ, AWS SNS/SQS).

Architecture Description (Word Diagram)

Imagine an e-commerce order process:

User initiates Order -> Order Service
    Order Service: Creates Order (Local Tx 1)
    PUBLISH: OrderCreatedEvent
    
Inventory Service (LISTENS to OrderCreatedEvent)
    Inventory Service: Reserves Inventory (Local Tx 2)
    PUBLISH: InventoryReservedEvent
    
Payment Service (LISTENS to InventoryReservedEvent)
    Payment Service: Processes Payment (Local Tx 3)
    PUBLISH: PaymentProcessedEvent
    
Shipping Service (LISTENS to PaymentProcessedEvent)
    Shipping Service: Schedules Shipment (Local Tx 4)
    PUBLISH: ShippingScheduledEvent
    
Order Service (LISTENS to ShippingScheduledEvent OR PaymentFailedEvent OR InventoryFailedEvent)
    Order Service: Updates Order Status (e.g., "Confirmed" or "Cancelled")

Pros of Choreography

  • Loose Coupling: Services are highly decoupled; they only need to know about the events they produce and consume, not about the specific services involved. This promotes modularity and independent deployment.
  • Simplicity for Small Sagas: For simple sagas with few participants and straightforward logic, choreography can be easier to implement initially as it avoids the overhead of creating and managing an orchestrator.
  • Decentralization: No single point of failure related to a central orchestrator.

Cons of Choreography

  • Complexity for Large Sagas: As the number of participants and steps grows, understanding the end-to-end flow of a saga becomes very difficult. It’s hard to track the state of the overall transaction.
  • Difficult Error Handling: Error handling and rollback logic can become convoluted. If a service fails, it might publish a failure event, but identifying which services need to compensate and in what order can be tricky without a central coordinator.
  • Circular Dependencies: Services might end up listening to each other’s events in complex ways, leading to implicit circular dependencies that are hard to manage.
  • Lack of Central Visibility: Debugging and monitoring the overall saga state is challenging because there’s no single component that knows the entire transaction’s progress.

Real-World Scenario: E-commerce Order Creation (Choreography)

Let’s consider an order processing flow where an order requires inventory reservation and payment processing. If inventory isn’t available, the order must be cancelled, and payment refunded.

Code Example (Choreography)

We’ll use a simplified Python example with a hypothetical message broker interaction.

# Assuming a message broker client 'broker' exists (e.g., Kafka producer/consumer)

# --- Service: Order Service ---
class OrderService:
    def create_order(self, order_details):
        order_id = self._generate_order_id()
        # Local Transaction 1: Create order in Order Service's DB
        print(f"OrderService: Creating order {order_id} with details: {order_details}")
        # In a real app, save to DB here
        
        # Publish OrderCreatedEvent
        event_payload = {"order_id": order_id, "items": order_details["items"], "customer_id": order_details["customer_id"], "total_amount": order_details["total_amount"]}
        broker.publish("order_events", "OrderCreatedEvent", event_payload)
        print(f"OrderService: Published OrderCreatedEvent for order {order_id}")
        return order_id

    def handle_inventory_reserved_event(self, event):
        order_id = event["order_id"]
        print(f"OrderService: Received InventoryReservedEvent for order {order_id}. Updating order status to 'Pending Payment'.")
        # Update order status in DB
        
    def handle_payment_processed_event(self, event):
        order_id = event["order_id"]
        print(f"OrderService: Received PaymentProcessedEvent for order {order_id}. Updating order status to 'Confirmed'.")
        # Update order status in DB

    def handle_inventory_failed_event(self, event):
        order_id = event["order_id"]
        reason = event.get("reason", "Unknown")
        print(f"OrderService: Received InventoryFailedEvent for order {order_id}. Cancelling order. Reason: {reason}")
        # Update order status to 'Cancelled', potentially trigger other compensations
        
    def handle_payment_failed_event(self, event):
        order_id = event["order_id"]
        reason = event.get("reason", "Unknown")
        print(f"OrderService: Received PaymentFailedEvent for order {order_id}. Cancelling order. Reason: {reason}")
        # Update order status to 'Cancelled', potentially trigger other compensations

# --- Service: Inventory Service ---
class InventoryService:
    def handle_order_created_event(self, event):
        order_id = event["order_id"]
        items = event["items"]
        print(f"InventoryService: Received OrderCreatedEvent for order {order_id}. Attempting to reserve inventory for {items}")
        
        # Local Transaction 2: Reserve inventory in Inventory Service's DB
        inventory_available = self._check_and_reserve_inventory(items) # Simulate
        
        if inventory_available:
            print(f"InventoryService: Successfully reserved inventory for order {order_id}")
            event_payload = {"order_id": order_id, "items": items}
            broker.publish("inventory_events", "InventoryReservedEvent", event_payload)
        else:
            print(f"InventoryService: Failed to reserve inventory for order {order_id}")
            event_payload = {"order_id": order_id, "reason": "Inventory unavailable"}
            broker.publish("inventory_events", "InventoryFailedEvent", event_payload)
            # This triggers a compensating transaction (OrderService cancels order)

    def _check_and_reserve_inventory(self, items):
        # Simulate inventory check and reservation
        return True # Or False to simulate failure

    # Compensating transaction for Inventory Service
    def handle_payment_failed_event(self, event):
        order_id = event["order_id"]
        print(f"InventoryService: Received PaymentFailedEvent for order {order_id}. Releasing reserved inventory.")
        # Local Compensating Transaction: Release reserved inventory

# --- Service: Payment Service ---
class PaymentService:
    def handle_inventory_reserved_event(self, event):
        order_id = event["order_id"]
        total_amount = event.get("total_amount", 100.0) # Assume total_amount from initial event
        print(f"PaymentService: Received InventoryReservedEvent for order {order_id}. Attempting to process payment for {total_amount}")
        
        # Local Transaction 3: Process payment in Payment Service's DB / Gateway
        payment_successful = self._process_payment(order_id, total_amount) # Simulate
        
        if payment_successful:
            print(f"PaymentService: Successfully processed payment for order {order_id}")
            event_payload = {"order_id": order_id, "transaction_id": "PAY123"}
            broker.publish("payment_events", "PaymentProcessedEvent", event_payload)
        else:
            print(f"PaymentService: Failed to process payment for order {order_id}")
            event_payload = {"order_id": order_id, "reason": "Payment gateway declined"}
            broker.publish("payment_events", "PaymentFailedEvent", event_payload)
            # This triggers compensating transactions (InventoryService releases inventory, OrderService cancels order)

    def _process_payment(self, order_id, amount):
        # Simulate payment processing
        return True # Or False to simulate failure

    # Compensating transaction for Payment Service (e.g., refund)
    def handle_order_cancelled_event(self, event):
        order_id = event["order_id"]
        print(f"PaymentService: Received OrderCancelledEvent for order {order_id}. Initiating refund if payment was processed.")
        # Local Compensating Transaction: Refund payment (if already processed)

# --- Message Broker Simulation ---
class MessageBroker:
    def __init__(self):
        self.topics = {}
        self.subscribers = {}

    def publish(self, topic, event_type, payload):
        event = {"type": event_type, **payload}
        print(f"--- BROKER: Publishing {event_type} to {topic}: {payload}")
        if topic in self.subscribers:
            for service_instance, handler_method in self.subscribers[topic]:
                if hasattr(service_instance, handler_method):
                    getattr(service_instance, handler_method)(event)

    def subscribe(self, topic, service_instance, handler_method):
        if topic not in self.subscribers:
            self.subscribers[topic] = []
        self.subscribers[topic].append((service_instance, handler_method))
        print(f"--- BROKER: {service_instance.__class__.__name__} subscribed to {topic} with {handler_method}")

# --- Main Application Flow (Orchestrating the choreography setup) ---
if __name__ == "__main__":
    broker = MessageBroker()

    order_service = OrderService()
    inventory_service = InventoryService()
    payment_service = PaymentService()

    # Subscriptions
    broker.subscribe("order_events", inventory_service, "handle_order_created_event")
    broker.subscribe("inventory_events", order_service, "handle_inventory_reserved_event")
    broker.subscribe("inventory_events", payment_service, "handle_inventory_reserved_event") # Payment also needs to know inventory is reserved
    broker.subscribe("inventory_events", order_service, "handle_inventory_failed_event") # Order service needs to know if inventory failed
    broker.subscribe("payment_events", order_service, "handle_payment_processed_event")
    broker.subscribe("payment_events", inventory_service, "handle_payment_failed_event") # Inventory needs to know if payment failed to release reservation
    broker.subscribe("payment_events", order_service, "handle_payment_failed_event") # Order service needs to know if payment failed

    print("\n--- Initiating successful order flow ---")
    order_service.create_order({"items": ["Laptop"], "customer_id": "CUST1", "total_amount": 1200.0})

    print("\n--- Initiating order flow with simulated inventory failure ---")
    # To simulate inventory failure, temporarily modify _check_and_reserve_inventory
    # For this example, let's just trace the event flow
    # Resetting the broker for a new scenario
    broker = MessageBroker()
    order_service = OrderService()
    inventory_service = InventoryService()
    payment_service = PaymentService()
    
    broker.subscribe("order_events", inventory_service, "handle_order_created_event")
    broker.subscribe("inventory_events", order_service, "handle_inventory_reserved_event")
    broker.subscribe("inventory_events", payment_service, "handle_inventory_reserved_event")
    broker.subscribe("inventory_events", order_service, "handle_inventory_failed_event")
    broker.subscribe("payment_events", order_service, "handle_payment_processed_event")
    broker.subscribe("payment_events", inventory_service, "handle_payment_failed_event")
    broker.subscribe("payment_events", order_service, "handle_payment_failed_event")
    
    # Simulate inventory failure
    original_reserve = inventory_service._check_and_reserve_inventory
    inventory_service._check_and_reserve_inventory = lambda items: False 
    order_service.create_order({"items": ["Out of Stock Item"], "customer_id": "CUST2", "total_amount": 50.0})
    inventory_service._check_and_reserve_inventory = original_reserve # Restore original

    print("\n--- Initiating order flow with simulated payment failure ---")
    # Resetting the broker for a new scenario
    broker = MessageBroker()
    order_service = OrderService()
    inventory_service = InventoryService()
    payment_service = PaymentService()
    
    broker.subscribe("order_events", inventory_service, "handle_order_created_event")
    broker.subscribe("inventory_events", order_service, "handle_inventory_reserved_event")
    broker.subscribe("inventory_events", payment_service, "handle_inventory_reserved_event")
    broker.subscribe("inventory_events", order_service, "handle_inventory_failed_event")
    broker.subscribe("payment_events", order_service, "handle_payment_processed_event")
    broker.subscribe("payment_events", inventory_service, "handle_payment_failed_event")
    broker.subscribe("payment_events", order_service, "handle_payment_failed_event")

    # Simulate payment failure after inventory is reserved
    original_process_payment = payment_service._process_payment
    payment_service._process_payment = lambda order_id, amount: False
    order_service.create_order({"items": ["Widget"], "customer_id": "CUST3", "total_amount": 25.0})
    payment_service._process_payment = original_process_payment # Restore original

Orchestration-based Saga

Description

In an orchestration-based saga, a central component, the “Saga Orchestrator” (sometimes called a “Saga Coordinator”), takes responsibility for managing the overall distributed transaction. The orchestrator defines the sequence of local transactions, invokes participant services, and handles compensating transactions if any step fails. It maintains the state of the saga and determines the next action based on the responses from participant services.

Architecture Description (Word Diagram)

User initiates Order -> Order Service
    Order Service: Calls Saga Orchestrator to start_order_saga(order_details)
    
Saga Orchestrator:
    1. CALLS Inventory Service: reserve_inventory(order_id, items)
       <- Inventory Service responds (success/failure)
    
    IF Inventory Service SUCCESS:
        2. CALLS Payment Service: process_payment(order_id, amount)
           <- Payment Service responds (success/failure)
        
        IF Payment Service SUCCESS:
            3. CALLS Shipping Service: schedule_shipping(order_id, address)
               <- Shipping Service responds (success/failure)
            
            IF Shipping Service SUCCESS:
                Saga Orchestrator: CALLS Order Service: update_order_status(order_id, "CONFIRMED")
            ELSE (Shipping Service FAILURE):
                Saga Orchestrator:
                    CALLS Payment Service: refund_payment(order_id) (Compensating Tx)
                    CALLS Inventory Service: release_inventory(order_id) (Compensating Tx)
                    CALLS Order Service: update_order_status(order_id, "CANCELLED")
        
        ELSE (Payment Service FAILURE):
            Saga Orchestrator:
                CALLS Inventory Service: release_inventory(order_id) (Compensating Tx)
                CALLS Order Service: update_order_status(order_id, "CANCELLED")
    
    ELSE (Inventory Service FAILURE):
        Saga Orchestrator: CALLS Order Service: update_order_status(order_id, "CANCELLED")

Pros of Orchestration

  • Centralized Control and Visibility: The orchestrator provides a clear view of the saga’s state and flow, making it easier to monitor, debug, and manage.
  • Simplified Error Handling: The orchestrator explicitly defines the compensation logic, making it easier to implement and ensure that all necessary compensating transactions are executed in the correct order.
  • Reduced Coupling (between business services): Participant services are decoupled from each other; they only need to implement the APIs that the orchestrator calls and send responses back to the orchestrator.
  • Easier to Add/Remove Steps: Modifying the saga flow is straightforward as changes are concentrated in the orchestrator.

Cons of Orchestration

  • Potential Single Point of Failure: The orchestrator itself can become a single point of failure if not designed with high availability and fault tolerance in mind.
  • Increased Complexity for Orchestrator: The orchestrator service can become complex, especially for large sagas with many steps and intricate branching logic. It essentially becomes a state machine.
  • Service Coupling (to orchestrator): While business services are decoupled from each other, they are coupled to the orchestrator.

Real-World Scenario: E-commerce Order Creation (Orchestration)

Using the same e-commerce order example, but now with a central orchestrator.

Code Example (Orchestration)

We’ll use a simplified Python example, simulating API calls to other services.

# --- Simulated Service Clients (instead of direct calls, imagine HTTP/RPC calls) ---
class InventoryServiceClient:
def reserve_inventory(self, order_id, items):
print(f"InventoryServiceClient: Calling reserve_inventory for order {order_id}, items: {items}")
if "Out of Stock Item" in items: # Simulate failure
print(f"InventoryServiceClient: Inventory reservation FAILED for {order_id}")
return False
print(f"InventoryServiceClient: Inventory reservation SUCCESS for {order_id}")
return True

def release_inventory(self, order_id):
print(f"InventoryServiceClient: Releasing inventory for order {order_id} (Compensating Tx)")
return True

class PaymentServiceClient:
def process_payment(self, order_id, amount):
print(f"PaymentServiceClient: Calling process_payment for order {order_id}, amount: {amount}")
if amount > 1000: # Simulate failure for high amount
print(f"PaymentServiceClient: Payment processing FAILED for {order_id}")
return False
print(f"PaymentServiceClient: Payment processing SUCCESS for {order_id}")
return True

def refund_payment(self, order_id):
print(f"PaymentServiceClient: Refunding payment for order {order_id} (Compensating Tx)")
return True

class ShippingServiceClient:
def schedule_shipping(self, order_id, address):
print(f"ShippingServiceClient: Calling schedule_shipping for order {order_id}, address: {address}")
# Assume shipping always succeeds for simplicity in this example
print(f"ShippingServiceClient: Shipping scheduling SUCCESS for {order_id}")
return True

class OrderServiceClient:
def update_order_status(self, order_id, status):
print(f"OrderServiceClient: Updating order {order_id} status to '{status}'")
return True

Written by

Khader Vali

Senior Software Engineer specializing in cloud architecture, real-time systems, and enterprise-scale applications.

Share this article

Related Articles

Building Event-Driven Serverless Apps on AWS

Sep 25, 2026 · 17 min read

Building Feature Flag Systems for Continuous Delivery

Sep 17, 2026 · 16 min read

Building Feature Flag Systems for Continuous Delivery

Sep 22, 2026 · 18 min read