Introduction Link to heading

In this post, we will explore the Saga Pattern, a powerful technique for managing data consistency across multiple microservices in distributed systems. We will focus on the Orchestrator approach, which is one of the two main ways to implement the Saga Pattern (the other being the Choreography approach, which we will cover in Part 2).

Why Do We Need the Saga Pattern? Link to heading

In a traditional monolithic application, it’s easy to use database transactions to ensure that a series of operations either all succeed or all fail (the so-called “all-or-nothing” property). However, in a microservices architecture, each service typically manages its own database, making distributed transactions difficult or impossible. This is where the Saga Pattern comes in.

The Saga Pattern breaks a long transaction into a series of smaller, isolated transactions that are coordinated using a sequence of events or commands. If one step fails, the Saga Pattern ensures that previous steps are compensated (undone) to keep the system consistent.

There are 2 main ways to coordinate Sagas:

  • Orchestrator: A central service (the orchestrator) tells each participant what to do and when.
  • Choreography: Each service listens for events and decides what to do next, with no central coordinator.

In this post, we will focus on the Orchestrator approach, where a dedicated service is responsible for managing the entire workflow.

Problem Statement Link to heading

Let’s say we want to build a simple e-commerce system with the following 3 microservices:

  • Order Service – creates an order
  • Inventory Service – reserves stock
  • Payment Service – charges payment

Suppose a customer tries to place an order. The system needs to:

  • Reserve the item in inventory
  • Charge the customer’s payment method
  • Complete the order

But what if the payment fails? We don’t want to leave the inventory reserved forever. We need to roll back the inventory reservation.

Firstly, we will show what happens when the payment fails: inventory still gets reserved, but order is never completed or rolled back. Then, we will refactor the system to use the Saga Pattern to see how it works.

Folder Structure Link to heading

saga-pattern/
├── docker-compose.yml
├── order-service/
│   └── app.py
│   └── Dockerfile
├── inventory-service/
│   └── app.py
│   └── Dockerfile
├── payment-service/
│   └── app.py
│   └── Dockerfile
# docker-compose.yml
services:
  order:
    build: ./order-service
    ports:
      - "5000:5000"
    depends_on:
      - inventory
      - payment

  inventory:
    build: ./inventory-service
    ports:
      - "5001:5001"

  payment:
    build: ./payment-service
    ports:
      - "5002:5002"
# order-service/app.py
from flask import Flask, request, jsonify
import requests

app = Flask(__name__)

@app.route("/create_order", methods=["POST"])
def create_order():
    order = request.json
    product_id = order.get("product_id")
    amount = order.get("amount")

    try:
        # Step 1: Reserve Inventory
        res = requests.post("http://inventory:5001/reserve", json={"product_id": product_id})
        res.raise_for_status()

        # Step 2: Charge Payment
        res = requests.post("http://payment:5002/pay", json={"amount": amount})
        res.raise_for_status()

        return jsonify({"status": "success", "message": "Order completed!"})
    except Exception as e:
        return jsonify({"status": "failure", "message": f"Order failed: {e}"}), 500

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)
# inventory-service/app.py
from flask import Flask, request, jsonify

app = Flask(__name__)
inventory = {"item-1": 10}

@app.route("/reserve", methods=["POST"])
def reserve():
    product_id = request.json.get("product_id")
    if inventory.get(product_id, 0) > 0:
        inventory[product_id] -= 1
        print(f"Reserved 1 unit of {product_id}. Remaining: {inventory[product_id]}")
        return jsonify({"status": "reserved"})
    else:
        return jsonify({"status": "out of stock"}), 400

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5001)
# payment-service/app.py
from flask import Flask, jsonify

app = Flask(__name__)

@app.route("/pay", methods=["POST"])
def pay():
    # Simulate failure
    return jsonify({"status": "error", "message": "Payment gateway failed!"}), 500

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5002)
# Dockerfile: Same for all services
FROM python:3.11-slim

ENV PYTHONUNBUFFERED=1

WORKDIR /app

COPY app.py .

RUN pip install --no-cache-dir Flask==3.1.1 requests==2.32.3

CMD ["python", "app.py"]

Test the current implementation Link to heading

  1. Build and run all services
docker-compose up --build
  1. Test create an order
curl -X POST http://localhost:5000/create_order \
  -H "Content-Type: application/json" \
  -d '{"product_id": "item-1", "amount": 1}'
  1. Check docker-compose logs, you will see:
  • Inventory is reserved. The remaining inventory is 9
  • Payment fails as simulated
  • Order remains in failed state, and inventory is not rolled back

The logs will look like this:

inventory-1  | Reserved 1 unit of item-1. Remaining: 9
inventory-1  | 172.19.0.4 - - [16/Jan/2024 01:33:27] "POST /reserve HTTP/1.1" 200 -
payment-1    | 172.19.0.4 - - [16/Jan/2024 01:33:27] "POST /pay HTTP/1.1" 500 -
order-1      | 192.168.65.1 - - [16/Jan/2024 01:33:27] "POST /create_order HTTP/1.1" 500 -

Inventory service does not undo actions when a later step (payment) fails. We end up with inconsistent data. In this case, inventory is down, and no order is created.

Refactor to use Saga Pattern Link to heading

With the Saga Orchestrator Pattern, we introduce a new service - the Orchestrator - that coordinates the entire process. The orchestrator:

  • Tells each service what to do (reserve inventory, charge payment, etc.)
  • If a step fails, it tells previous services to compensate (e.g., cancel the inventory reservation)

This way, the system remains consistent, even if something goes wrong.

# orchestrator-service/app.py
from flask import Flask, request, jsonify
import requests

app = Flask(__name__)

@app.route("/orchestrate_order", methods=["POST"])
def orchestrate_order():
    payload = request.json
    product_id = payload.get("product_id")
    amount = payload.get("amount")

    try:
        # Step 1: Reserve inventory
        res = requests.post("http://inventory:5001/reserve", json={"product_id": product_id})
        res.raise_for_status()

        # Step 2: Process payment
        res = requests.post("http://payment:5002/pay", json={"amount": amount})
        res.raise_for_status()

        # Step 3: Finalize order
        res = requests.post("http://order:5000/complete", json={"product_id": product_id, "amount": amount})
        res.raise_for_status()

        return jsonify({"status": "success", "message": "Order placed successfully!"})

    except Exception as e:
        # Compensation logic
        print("Something went wrong. Rolling back...")
        requests.post("http://inventory:5001/cancel_reservation", json={"product_id": product_id})
        return jsonify({"status": "failure", "message": f"Transaction failed and rolled back: {e}"}), 500

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000)

We will update the docker-compose.yml to include the Orchestrator service.

# docker-compose.yml
services:
  orchestrator:
    build: ./orchestrator-service
    ports:
      - "8000:8000"
    depends_on:
      - order
      - inventory
      - payment
  order:
    build: ./order-service
    ports:
      - "5000:5000"

  inventory:
    build: ./inventory-service
    ports:
      - "5001:5001"

  payment:
    build: ./payment-service
    ports:
      - "5002:5002"

We need to update the order service to include a new route for completing an order.

# order-service/app.py
@app.route("/complete", methods=["POST"])
def complete_order():
    data = request.json
    print(f"Order completed for product {data['product_id']} at ${data['amount']}")
    return jsonify({"status": "completed"})

We also need to update the inventory service to include a new route for cancelling a reservation.

# inventory-service/app.py
@app.route("/cancel_reservation", methods=["POST"])
def cancel_reservation():
    product_id = request.json.get("product_id")
    inventory[product_id] += 1
    print(f"Reservation cancelled: {product_id}, Restored: {inventory[product_id]}")
    return jsonify({"status": "restored"})

Test the new implementation Link to heading

  1. Build and run all services
docker-compose up --build
  1. Test create an order
curl -X POST http://localhost:8000/orchestrate_order \
  -H "Content-Type: application/json" \
  -d '{"product_id": "item-1", "amount": 1}'
  1. Check the docker-compose logs, you will see:
  • Inventory is reserved. The remaining inventory is 9
  • Payment fails as simulated
  • Order is rolled back. Inventory is restored to 10
  • Orchestrator returns a 500 error

The logs will look like this:

inventory-1     | Reserved 1 unit of item-1. Remaining: 9
inventory-1     | 172.19.0.5 - - [24/Jul/2025 02:00:54] "POST /reserve HTTP/1.1" 200 -
payment-1       | 172.19.0.5 - - [24/Jul/2025 02:00:54] "POST /pay HTTP/1.1" 500 -
orchestrator-1  | Something went wrong. Rolling back...
inventory-1     | Reservation cancelled: item-1, Restored: 10
inventory-1     | 172.19.0.5 - - [24/Jul/2025 02:00:54] "POST /cancel_reservation HTTP/1.1" 200 -
orchestrator-1  | 192.168.65.1 - - [24/Jul/2025 02:00:54] "POST /orchestrate_order HTTP/1.1" 500 -

Our system is now consistent even on failure

When to Use the Orchestrator Pattern Link to heading

  • When you want a centralized place to manage business logic and workflow.
  • When the sequence of steps is complex or may change over time.
  • When you need clear visibility and control over the process.

Drawbacks Link to heading

  • The orchestrator can become a single point of failure or a bottleneck if not designed carefully.
  • It can become complex as the number of steps and compensations grows.

What’s Next? Link to heading

In the next post, we will look at the Choreography Pattern, where there is no central orchestrator. Instead, each service listens for events and reacts accordingly. This approach is more decentralized and can be more scalable, but it comes with its own challenges.