System Design

Saga Pattern: Distributed Transactions in Microservices

Dive deep into the Saga pattern for managing distributed transactions in microservices. Understand choreography vs. orchestration, benefits, challenges, and real-world applications.

Khader Vali September 24, 2026 17 min read

Saga Pattern: Distributed Transactions in Microservices

Welcome to Khadervali.com! Today, we’re tackling one of the most persistent and intricate challenges in modern software architecture: managing transactions across multiple, independent services. As we embrace the power and flexibility of microservices, we inevitably confront the complexities of ensuring data consistency when a single business operation spans several distinct components. This is where the Saga pattern steps in, offering a robust and practical approach to distributed transactions.

In this comprehensive guide, we’ll peel back the layers of the Saga pattern, exploring its fundamental principles, diving into its two primary implementation styles—choreography and orchestration—and examining the benefits, challenges, and real-world considerations for adopting it in your microservices ecosystem. Whether you’re a seasoned architect or a developer new to distributed systems, this article will equip you with a deep understanding of how to master the Saga pattern.

<

Saga Pattern: Distributed Transactions in Microservices
Generated Image

>

The Distributed Transaction Dilemma in Microservices

Before we introduce the Saga pattern, let’s understand the problem it aims to solve. In a traditional monolithic application, maintaining data consistency is relatively straightforward. Most business operations interact with a single database, allowing us to rely on ACID (Atomicity, Consistency, Isolation, Durability) transactions. A typical transaction manager can wrap multiple database operations into a single unit of work, ensuring that either all operations succeed (commit) or all are rolled back (abort).

However, microservices fundamentally change this paradigm. Each microservice typically owns its data store, isolating it from other services. This autonomy is a core benefit—it allows services to evolve independently, choose the best database technology for their needs, and scale without impacting others. But this autonomy comes at a cost: a business operation that requires updates across multiple services can no longer be encapsulated by a single, global ACID transaction.

Consider an e-commerce order: when a customer places an order, it might involve:

  1. Creating an order record in the Order Service‘s database.
  2. Processing payment through the Payment Service.
  3. Updating inventory in the Inventory Service.
  4. Notifying the Shipping Service.

If any of these steps fail, the entire operation must be reversed or compensated to maintain a consistent state across the system. This is the essence of the distributed transaction dilemma. Applying a traditional two-phase commit (2PC) protocol across multiple services and their independent databases is generally not feasible in a high-availability, high-performance microservices environment. 2PC introduces tight coupling, blocking operations, and a single point of failure (the transaction coordinator), which contradicts the core tenets of microservices architecture.

Instead, microservices architectures often embrace eventual consistency. This means that at any given moment, data across different services might be temporarily inconsistent, but the system guarantees that it will eventually reach a consistent state. The Saga pattern is a powerful mechanism for achieving this eventual consistency in a controlled and reliable manner for complex business processes.

What is the Saga Pattern?

The Saga pattern is a way to manage distributed transactions in microservices by breaking down a large, atomic transaction into a sequence of smaller, local transactions. Each local transaction is executed within a single service and updates its own database. Crucially, if any local transaction in the sequence fails, the Saga executes a series of compensating transactions to undo the changes made by the preceding successful local transactions. This ensures that the overall system remains consistent, even if not immediately atomic in the traditional sense.

Think of it like this: you’re trying to build a complex structure, and each step involves adding a specific component. If you successfully add all components, great. But if you get halfway through and realize a component is missing or faulty, you can’t just leave it half-built. You need a plan to dismantle the components you did add, bringing the structure back to a stable, known state. The “dismantling” steps are your compensating transactions.

Key Characteristics of the Saga Pattern:

  • Sequence of Local Transactions: A Saga is composed of multiple local transactions, each managed by a single service and its database. Each local transaction is atomic within its service.
  • Eventual Consistency: Unlike ACID transactions, Sagas achieve eventual consistency. During the execution of a Saga, the system might be in an inconsistent state, but it will eventually reach a consistent state, either by successfully completing all local transactions or by fully compensating for any failures.
  • Compensating Transactions: For every local transaction in a Saga, there’s a corresponding compensating transaction designed to undo the effects of the initial transaction. These are critical for handling failures and maintaining data integrity.
  • No Global Locks: Sagas avoid global locks, which are a major bottleneck in distributed systems. This allows for higher concurrency and better performance.

The Saga pattern directly addresses the limitations of 2PC by embracing the distributed nature of microservices and favoring availability and partition tolerance over strong immediate consistency (BASE – Basically Available, Soft state, Eventually consistent, vs. ACID).

Two Main Types of Saga Implementation

The Saga pattern can be implemented in two primary ways: Choreography and Orchestration. Both achieve the same goal but differ significantly in how the flow of transactions and compensations is managed.

1. Choreography-based Saga

In a choreography-based Saga, there is no central coordinator. Instead, each service participating in the Saga produces events that trigger the next step in the sequence. Services listen for relevant events and react to them, effectively “choreographing” the overall transaction flow through event-driven communication.

How it Works (Diagram in words):

Imagine a distributed system where services communicate primarily through an event bus (like Apache Kafka or RabbitMQ).

[Service A] --publishes Event A--> (Event Bus)
(Event Bus) --Event A consumed by--> [Service B]
[Service B] --publishes Event B--> (Event Bus)
(Event Bus) --Event B consumed by--> [Service C]
[Service C] --publishes Event C--> (Event Bus)
... and so on ...

If any service (e.g., Service B) encounters an error and cannot complete its local transaction, it publishes a “Failure Event” or a “Compensation Event.” Other services that have already completed their part of the Saga, upon receiving this failure event, will then execute their respective compensating transactions.

Pros of Choreography:

  • Decentralized: No single point of failure or bottleneck, as there’s no central coordinator.
  • Loosely Coupled: Services are highly decoupled, only aware of the events they produce and consume. This aligns well with microservices principles.
  • Simpler for Small Sagas: For Sagas with only a few steps, choreography can be straightforward to implement.

Cons of Choreography:

  • Complexity for Large Sagas: As the number of services and steps increases, understanding the overall flow and debugging issues becomes very challenging. The “spaghetti code” problem can emerge with events.
  • Lack of Central Visibility: It’s difficult to get a global view of the Saga’s progress or state, making monitoring and auditing harder.
  • Circular Dependencies: Managing dependencies and preventing circular event chains can be tricky.
  • Difficult to Implement Compensating Transactions: Ensuring that all necessary compensating transactions are triggered and executed correctly can be complex, especially with multiple failure points.

Choreography Code Example (Simplified Python-like Pseudocode):

Let’s consider our e-commerce order scenario. Services involved: OrderService, PaymentService, InventoryService.

# --- Event Definitions (Simplified) ---
class OrderCreatedEvent:
    def __init__(self, order_id, amount, items):
        self.order_id = order_id
        self.amount = amount
        self.items = items

class OrderPaymentReservedEvent:
    def __init__(self, order_id, payment_id):
        self.order_id = order_id
        self.payment_id = payment_id

class OrderPaymentFailedEvent:
    def __init__(self, order_id, reason):
        self.order_id = order_id
        self.reason = reason

class InventoryReservedEvent:
    def __init__(self, order_id, item_reservations):
        self.order_id = order_id
        self.item_reservations = item_reservations

class InventoryReservationFailedEvent:
    def __init__(self, order_id, reason):
        self.order_id = order_id
        self.reason = reason

class OrderCompletedEvent:
    def __init__(self, order_id):
        self.order_id = order_id

# --- Order Service ---
class OrderService:
    def __init__(self, event_publisher):
        self.event_publisher = event_publisher
        self.orders_db = {} # Simulate a database

    def create_order(self, customer_id, items):
        order_id = f"ORDER-{hash(customer_id + str(items))}"
        amount = sum(item['price'] * item['qty'] for item in items)
        self.orders_db[order_id] = {"status": "PENDING", "amount": amount, "items": items}
        print(f"OrderService: Order {order_id} created in PENDING state.")
        event = OrderCreatedEvent(order_id, amount, items)
        self.event_publisher.publish("order_created", event)
        return order_id

    def handle_payment_reserved(self, event: OrderPaymentReservedEvent):
        if event.order_id in self.orders_db:
            self.orders_db[event.order_id]["payment_status"] = "RESERVED"
            print(f"OrderService: Payment reserved for order {event.order_id}.")
            # Next step in choreography is handled by InventoryService listening to OrderCreatedEvent

    def handle_inventory_reserved(self, event: InventoryReservedEvent):
        if event.order_id in self.orders_db:
            self.orders_db[event.order_id]["inventory_status"] = "RESERVED"
            self.orders_db[event.order_id]["status"] = "COMPLETED"
            print(f"OrderService: Inventory reserved for order {event.order_id}. Order {event.order_id} COMPLETED.")
            self.event_publisher.publish("order_completed", OrderCompletedEvent(event.order_id))

    def handle_payment_failed(self, event: OrderPaymentFailedEvent):
        if event.order_id in self.orders_db:
            self.orders_db[event.order_id]["status"] = "CANCELLED"
            print(f"OrderService: Order {event.order_id} CANCELLED due to payment failure: {event.reason}")
            # No compensation needed here for OrderService itself, as it's still PENDING.
            # But other services might need to compensate if they acted on OrderCreatedEvent.

    def handle_inventory_reservation_failed(self, event: InventoryReservationFailedEvent):
        if event.order_id in self.orders_db:
            self.orders_db[event.order_id]["status"] = "CANCELLED"
            print(f"OrderService: Order {event.order_id} CANCELLED due to inventory failure: {event.reason}")
            # Order service needs to trigger compensation for payment
            self.event_publisher.publish("order_cancelled_due_to_inventory", {"order_id": event.order_id})


# --- Payment Service ---
class PaymentService:
    def __init__(self, event_publisher):
        self.event_publisher = event_publisher
        self.payments_db = {} # Simulate a database

    def handle_order_created(self, event: OrderCreatedEvent):
        print(f"PaymentService: Received OrderCreatedEvent for order {event.order_id}.")
        # Simulate payment processing
        if event.amount > 0: # Basic validation
            payment_id = f"PAY-{event.order_id}"
            self.payments_db[payment_id] = {"order_id": event.order_id, "amount": event.amount, "status": "RESERVED"}
            print(f"PaymentService: Payment {payment_id} reserved for order {event.order_id}.")
            self.event_publisher.publish("payment_reserved", OrderPaymentReservedEvent(event.order_id, payment_id))
        else:
            print(f"PaymentService: Payment failed for order {event.order_id}: Invalid amount.")
            self.event_publisher.publish("payment_failed", OrderPaymentFailedEvent(event.order_id, "Invalid amount"))

    def handle_order_cancelled_due_to_inventory(self, event):
        order_id = event["order_id"]
        # Find and reverse payment
        for payment_id, payment_info in self.payments_db.items():
            if payment_info["order_id"] == order_id and payment_info["status"] == "RESERVED":
                payment_info["status"] = "REFUNDED"
                print(f"PaymentService: Compensating: Payment {payment_id} refunded for order {order_id}.")
                # Could publish an event like PaymentRefundedEvent if other services cared

# --- Inventory Service ---
class InventoryService:
    def __init__(self, event_publisher):
        self.event_publisher = event_publisher
        self.inventory_db = {"item_A": 10, "item_B": 5} # Simulate inventory

    def handle_payment_reserved(self, event: OrderPaymentReservedEvent):
        print(f"InventoryService: Received PaymentReservedEvent for order {event.order_id}.")
        # Simulate inventory reservation
        can_reserve = True
        reservations = []
        order_details = {"items": [{"id": "item_A", "qty": 2}, {"id": "item_B", "qty": 1}]} # Assuming order details were passed or fetched
        for item in order_details["items"]:
            if self.inventory_db.get(item["id"], 0) < item["qty"]:
                can_reserve = False
                break
            reservations.append({"item_id": item["id"], "qty": item["qty"]})

        if can_reserve:
            for res in reservations:
                self.inventory_db[res["item_id"]] -= res["qty"]
            print(f"InventoryService: Inventory reserved for order {event.order_id}.")
            self.event_publisher.publish("inventory_reserved", InventoryReservedEvent(event.order_id, reservations))
        else:
            print(f"InventoryService: Inventory reservation failed for order {event.order_id}: Not enough stock.")
            self.event_publisher.publish("inventory_reservation_failed", InventoryReservationFailedEvent(event.order_id, "Not enough stock"))
            # No explicit compensation needed for InventoryService itself yet, as it hasn't modified state

    def handle_order_cancelled_due_to_payment_failure(self, event):
        order_id = event["order_id"]
        # If inventory was reserved, release it
        # (This example assumes a clean state upon initial failure, but in reality, InventoryService might need to
        #  listen to PaymentFailedEvent and compensate if it had already reserved)
        print(f"InventoryService: No compensation needed for order {order_id} due to payment failure (assuming no prior reservation).")


# --- Mock Event Bus ---
class EventBus:
    def __init__(self):
        self.subscribers = {}

    def subscribe(self, event_type, handler):
        if event_type not in self.subscribers:
            self.subscribers[event_type] = []
        self.subscribers[event_type].append(handler)

    def publish(self, event_type, event):
        print(f"\n--- EventBus: Publishing {event_type} ---")
        if event_type in self.subscribers:
            for handler in self.subscribers[event_type]:
                handler(event)
        print(f"--- EventBus: Finished publishing {event_type} ---")


# --- Simulation ---
if __name__ == "__main__":
    event_bus = EventBus()

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

    # Subscribe services to relevant events
    event_bus.subscribe("order_created", payment_service.handle_order_created)
    event_bus.subscribe("payment_reserved", order_service.handle_payment_reserved)
    event_bus.subscribe("payment_reserved", inventory_service.handle_payment_reserved) # Inventory waits for payment
    event_bus.subscribe("inventory_reserved", order_service.handle_inventory_reserved)

    # Failure handling
    event_bus.subscribe("payment_failed", order_service.handle_payment_failed)
    event_bus.subscribe("inventory_reservation_failed", order_service.handle_inventory_reservation_failed)
    event_bus.subscribe("order_cancelled_due_to_inventory", payment_service.handle_order_cancelled_due_to_inventory)


    print("\n--- Initiating a successful order Saga ---")
    order_id_success = order_service.create_order("customer123", [{"id": "item_A", "qty": 1}, {"id": "item_B", "qty": 1}])
    print(f"\nFinal state of order {order_id_success}: {order_service.orders_db.get(order_id_success)}")
    print(f"Inventory state: {inventory_service.inventory_db}")

    print("\n\n--- Initiating a failed order Saga (inventory failure) ---")
    order_id_fail_inventory = order_service.create_order("customer456", [{"id": "item_A", "qty": 100}]) # More than available
    print(f"\nFinal state of order {order_id_fail_inventory}: {order_service.orders_db.get(order_id_fail_inventory)}")
    print(f"Inventory state: {inventory_service.inventory_db}")

In this example, services react to events. If InventoryService fails to reserve items, it publishes InventoryReservationFailedEvent. The OrderService hears this and updates its status to "CANCELLED", and then publishes another event (`order_cancelled_due_to_inventory`) which the PaymentService is listening for to perform its compensating transaction (refund). This shows the reactive, decentralized nature of choreography.

<

Saga Pattern: Distributed Transactions in Microservices
Generated Image

>

2. Orchestration-based Saga

In an orchestration-based Saga, a central component, the "Saga Orchestrator" or "Saga Coordinator," is responsible for managing the entire lifecycle of the distributed transaction. The orchestrator tells each participating service what local transaction to execute, and upon completion (or failure) of that transaction, it decides the next step, including executing compensating transactions if needed.

How it Works (Diagram in words):

An orchestrator service acts as the brain of the Saga. It has the full workflow logic embedded within it.

[Saga Orchestrator] ---requests Do Task 1---> [Service A]
[Service A]         ---Task 1 Result (Success/Failure)---> [Saga Orchestrator]

IF Success:
    [Saga Orchestrator] ---requests Do Task 2---> [Service B]
    [Service B]         ---Task 2 Result (Success/Failure)---> [Saga Orchestrator]

    IF Success:
        [Saga Orchestrator] ---requests Do Task 3---> [Service C]
        [Service C]         ---Task 3 Result (Success/Failure)---> [Saga Orchestrator]
        IF Success: Saga Completed
        IF Failure:
            [Saga Orchestrator] ---requests Compensate Task 2---> [Service B]
            [Saga Orchestrator] ---requests Compensate Task 1---> [Service A]
            Saga Failed (Compensated)

    IF Failure:
        [Saga Orchestrator] ---requests Compensate Task 1---> [Service A]
        Saga Failed (Compensated)

IF Failure:
    Saga Failed (Initial Task Failed, no compensation needed yet)

The orchestrator maintains the state of the Saga and invokes services via command messages (e.g., REST API calls, asynchronous messages). Services, in turn, respond with completion or failure messages (e.g., events or direct responses) back to the orchestrator.

Pros of Orchestration:

  • Centralized Control: The orchestrator has a clear view of the entire Saga's progress, making monitoring, auditing, and debugging much easier.
  • Simplified Service Logic: Individual services don't need to know about the overall Saga flow; they just execute their local transaction and report back. This keeps services highly focused on their domain logic.
  • Easier to Add/Remove Steps: Modifying the Saga flow is easier as it's all defined in one place (the orchestrator).
  • Explicit Compensation Logic: The compensation flow is clearly defined within the orchestrator, leading to more predictable behavior.

Cons of Orchestration:

  • Central Point of Failure/Bottleneck: The orchestrator itself can become a single point of failure or a performance bottleneck if not designed for high availability and scalability.
  • Increased Complexity for Orchestrator: The orchestrator service can become complex, especially for very long and branching Sagas, as it needs to manage state, timeouts, and retry logic.
  • Tight Coupling (to an extent): While individual services are loosely coupled from each other, they are coupled to the orchestrator in that they must respond to its commands.

Orchestration Code Example (Simplified Python-like Pseudocode):

Using the same e-commerce order scenario.

# --- Service Interfaces (Commands/Responses) ---
class OrderCommand:
def __init__(self, order_id, customer_id, items, amount):
self.order_id = order_id
self.customer_id = customer_id
self.items = items
self.amount = amount

class PaymentCommand:
def __init__(self, order_id, amount):
self.order_id = order_id
self.amount = amount

class InventoryCommand:
def __init__(self, order_id, items):
self.order_id = order_id
self.items = items

class ServiceResponse:
def __init__(self, success, message="", data=None):
self.success = success
self.message = message
self.data = data

# --- Services (implementing local transactions and compensations) ---
class OrderService:
def __init__(self):
self.orders_db = {}

def create_order(self, cmd: OrderCommand):
self.orders_db[cmd.order_id] = {"customer_id": cmd.customer_id, "items": cmd.items,
"amount": cmd.amount, "status": "PENDING"}
print(f"OrderService: Created order {cmd.order_id} in PENDING state.")
return ServiceResponse(True, "Order created", {"order_id": cmd.order_id})

def update_order_status(self, order_id, status):
if order_id in self.orders_db:
self.orders_db[order_id]["status"] = status
print(f"OrderService: Order {order_id} status updated to {status}.")
return ServiceResponse(True, f"Order {order_id} status updated.")
return ServiceResponse(False, f"Order {order_id} not found.")

def compensate_order_creation(self, order_id):
# In this simple example, compensating order creation means cancelling it
if order_id in self.orders_db:
self.orders_db[order_id]["status"] = "CANCELLED"
print(f"OrderService: Compensating: Order {order_id} cancelled.")
return ServiceResponse(True, f"Order {order_id} cancelled.")
return ServiceResponse(False, f"Order {order_id} not found for compensation.")

class PaymentService:
def __init__(self):
self.payments_db = {} # order_id -> status

def process_payment(self, cmd: PaymentCommand):
# Simulate payment gateway interaction
if cmd.amount > 0:
self.payments_db[cmd.order_id] = "RESERVED"
print(f"PaymentService: Payment for order {cmd.order_id} RESERVED.")
return ServiceResponse(True, "Payment reserved.")
else:
print(f"PaymentService: Payment for order {cmd.order_id} FAILED (invalid amount).")
return ServiceResponse(False, "Invalid amount for payment.")

def refund_payment(self, order_id):
if self.payments_db.get(order_id) == "RESERVED":
self.payments_db[order_id] = "REFUNDED"
print(f"PaymentService: Compensating: Payment for order {order_id} REFUNDED.")
return ServiceResponse(True, "Payment refunded.")
print(f"PaymentService: No payment to refund for order {order_id} or already processed.")
return ServiceResponse(True, "No compensation needed or already done.") # Idempotent

class InventoryService:
def __init__(self):
self.inventory_db = {"item_A": 10, "item_B": 5}
self.reservations = {} # order_id -> list of {"item_id": qty}

def reserve_items(self, cmd: InventoryCommand):
can_reserve = True
current_reservations = []
for item in cmd.items:
if self.inventory_db.get(item["id"], 0) < item["qty"]: can_reserve = False break current_reservations.append({"item_id": item["id"], "qty": item["qty"]}) if can_reserve: for res_item in current_reservations: self.inventory_db[res_item["item_id"]] -= res_item["qty"] self.reservations[cmd.order_id] = current_reservations print(f"InventoryService: Items reserved for order {cmd.order_id}.") return ServiceResponse(True, "Inventory reserved.") else: print(f"InventoryService: Failed to reserve items for order {cmd.order_id}:

Written by

Khader Vali

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

Share this article

Related Articles

CQRS & Event Sourcing for Scalable Applications

Sep 29, 2026 · 18 min read

Building Feature Flag Systems for Continuous Delivery

Sep 17, 2026 · 16 min read

API Gateway Patterns: Routing, Auth, Rate Limiting Explained

Aug 12, 2026 · 7 min read