Articles

January 20, 2026

Saga Pattern: ensuring consistency in microservices architectures

So you finally migrated your monolith to microservices. Congratulations! Every service now has its own database, everything is decoupled, and you think you have it all under control — until that seemingly simple requirement shows up:

"we need to process an order that involves payment, inventory AND customer notification"

There you go. Welcome to distributed transaction hell.

Remember when everything was simple in the monolith? You opened an ACID transaction, ran your operations, and if something went wrong a ROLLBACK fixed everything.

But now you have:

  • An orders and inventory service with its own relational database
  • A payment service with MongoDB
  • A notification service

And all of them need to work in a coordinated way. If the payment fails after you already decremented the inventory, what do you do? If the notification never goes out, should everything be reverted?

Using BEGIN TRANSACTION is not going to work here.

That is where the Saga Pattern comes in — basically a way to manage transactions that span multiple services. As Chris Richardson (microservices.io) puts it:

"A saga is a sequence of local transactions. Each local transaction updates the database and publishes a message or event to trigger the next local transaction in the saga. If a local transaction fails because it violates a business rule then the saga executes a series of compensating transactions that undo the changes that were made by the preceding local transactions."

The idea is to break that "big transaction" into several smaller transactions, each one inside its own service. And here is the interesting part:

when something goes wrong, you do not roll back — you run compensating transactions.

What are compensating transactions? They are the "Ctrl+Z" of microservices. A compensating transaction is basically the opposite of the original one. Booked a flight? The compensation is cancelling the booking. Charged the card? The compensation is refunding it, and so on.

Think of that classic travel example (every Saga article uses it, but it is so good that I will use it too 😅). Imagine an application where:

  1. You book a flight
  2. Then you book a hotel room
  3. And finally you rent a car

If the last step, the car rental, fails, the application needs to:

First: cancel the hotel booking

Second: cancel the flight booking

Those are your compensating transactions. Simple, right?

The two faces of Saga: orchestration vs choreography

There are two main ways to implement the Saga Pattern, and each one has its trade-offs.

1. Orchestration

With orchestration you have a central orchestrator coordinating everything. It knows the order of operations, when to call each service and when to run the compensations.

Client → Orchestrator → Service A → Orchestrator → Service B → Orchestrator → Service C ...

Pros:

  • Easier to debug (you know exactly where you are)
  • Centralized logic
  • Easier to visualize the flow

Cons:

  • Single point of failure (if the orchestrator goes down, it is over)
  • It can quickly become a "God Object"
  • Coupling to the orchestrator

AWS has a great example using Step Functions in their documentation, where each step has its own success and failure handlers. AWS example

2. Choreography

With choreography, each service knows what to do when it receives an event and publishes new events for the next one in the chain.

Service A → Event: "A_COMPLETED" → Service B → Event: "B_COMPLETED" → Service C

Pros:

  • Fully decoupled
  • No single point of failure
  • More "microservice-like"

Cons:

  • Hard to debug (the logic is spread out)
  • It can quickly turn into "event hell"
  • Understanding the whole flow requires looking at N services

As the folks at Baeldung point out, choreography is better for simple flows and orchestration for the more complex ones.

Implementing it in practice

I will show a conceptual example in PHP using RabbitMQ with orchestration (because it is easier to follow).

I will assume you already set up the project with the required libraries, so I will focus only on what matters.

So we create the TravelBookingSagaOrchestrator class, which orchestrates the Saga, with the following functions:

public function bookTrip(array $tripData): array
{
    $sagaId = uniqid('trip_', true);
    $this->sagaLog = [];

    try {
        $flight = $this->executeStep('flight', 'reserve', [
            'from' => $tripData['from'],
            'to' => $tripData['to'],
            'date' => $tripData['departure_date'],
            'passengers' => $tripData['passengers']
        ], $sagaId);
        $this->logStep('flight_reserved', $flight);

        $hotel = $this->executeStep('hotel', 'reserve', [
            'city' => $tripData['to'],
            'checkin' => $tripData['checkin_date'],
            'checkout' => $tripData['checkout_date'],
            'guests' => $tripData['passengers']
        ], $sagaId);
        $this->logStep('hotel_reserved', $hotel);

        $car = $this->executeStep('car', 'reserve', [
            'city' => $tripData['to'],
            'pickup_date' => $tripData['checkin_date'],
            'return_date' => $tripData['checkout_date']
        ], $sagaId);
        $this->logStep('car_reserved', $car);

        return [
            'success' => true,
            'booking' => [
                'saga_id' => $sagaId,
                'flight' => $flight,
                'hotel' => $hotel,
                'car' => $car
            ]
        ];

    } catch (Exception $e) {
        echo "Booking error: {$e->getMessage()}\n";
        echo "Starting compensations...\n";
        $this->compensate($sagaId);
        throw new Exception("Trip booking failed: " . $e->getMessage());
    }
}

private function executeStep(string $service, string $action, array $data, string $sagaId): array
{
    $command = [
        'saga_id' => $sagaId,
        'action' => $action,
        'data' => $data,
        'timestamp' => time()
    ];

    $message = new AMQPMessage(
        json_encode($command),
        ['delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT]
    );

    $this->channel->basic_publish($message, '', "{$service}_commands");

    // Wait for response with timeout
    $response = $this->waitForResponse($sagaId, $service, 30);

    if ($response['status'] === 'error') {
        throw new Exception("Step {$service}/{$action} failed: " . $response['data']);
    }

    return $response['data'];
}

private function compensate(string $sagaId): void
{
    // Execute compensations in reverse order
    $reversedLog = array_reverse($this->sagaLog);

    foreach ($reversedLog as $step) {
        $compensation = $this->getCompensation($step);
        if (!$compensation) continue;

        $retries = 0;
        $maxRetries = 5;

        while ($retries < $maxRetries) {
            try {
                $this->executeStep(
                    $compensation['service'],
                    $compensation['action'],
                    $compensation['data'],
                    $sagaId
                );
                echo "Compensated: {$step['type']}\n";
                break;
            } catch (Exception $e) {
                $retries++;
                echo "Compensation retry {$retries}/{$maxRetries}: {$e->getMessage()}\n";
                sleep(pow(2, $retries)); // Exponential backoff
            }
        }
    }
}

private function getCompensation(array $step): ?array
{
    $compensations = [
        'flight_reserved' => [
            'service' => 'flight',
            'action' => 'compensate_cancel',
            'data' => ['id' => $step['data']['id'], 'pnr' => $step['data']['pnr']]
        ],
        'hotel_reserved' => [
            'service' => 'hotel',
            'action' => 'compensate_cancel',
            'data' => ['id' => $step['data']['id']]
        ],
        'car_reserved' => [
            'service' => 'car',
            'action' => 'compensate_cancel',
            'data' => ['id' => $step['data']['id']]
        ]
    ];

    return $compensations[$step['type']] ?? null;
}

private function logStep(string $type, array $data): void
{
    $this->sagaLog[] = ['type' => $type, 'data' => $data, 'timestamp' => time()];
}

To keep it simple, here is a single example of how one of the services would look (FlightService).

class FlightServiceWorker
{
    private $channel;

    public function __construct()
    {
        $this->channel = RabbitMQConnection::getChannel();
        $this->channel->queue_declare('flight_commands', false, true, false, false);
    }

    public function start(): void
    {
        $callback = function($msg) {
            $command = json_decode($msg->body, true);

            try {
                if ($command['is_compensation'] ?? false) {
                    $result = $this->handleCompensation($command);
                } else {
                    $result = $this->handleCommand($command);
                }

                $this->sendResponse($command['saga_id'], 'success', $result);
                $msg->ack();

            } catch (Exception $e) {
                $this->sendResponse($command['saga_id'], 'error', $e->getMessage());
                $msg->nack(false, true); // Requeue on error
            }
        };

        $this->channel->basic_qos(null, 1, null);
        $this->channel->basic_consume('flight_commands', '', false, false, false, false, $callback);

        while ($this->channel->is_consuming()) {
            $this->channel->wait();
        }
    }

    private function handleCommand(array $command): array
    {
        switch($command['action']) {
            case 'reserve':
                return $this->reserveFlight($command['data']);
            default:
                throw new Exception("Unknown action: {$command['action']}");
        }
    }

    private function handleCompensation(array $command): array
    {
        switch($command['action']) {
            case 'compensate_cancel':
                return $this->cancelFlight($command['data']);
            default:
                throw new Exception("Unknown compensation: {$command['action']}");
        }
    }

    private function reserveFlight(array $data): array
    {
        // Idempotence
        $existingReservation = $this->checkExistingReservation($data);
        if ($existingReservation) {
            echo "Reservation already exists (idempotence), return...\n";
            return $existingReservation;
        }

        if (rand(1, 100) <= 10) {
            throw new Exception("Flight unavailable for the requested date.");
        }

        $reservationId = uniqid('FLT_');
        $pnr = strtoupper(substr(md5($reservationId), 0, 6));

        DB::insert('flight_reservations', [
         //...
        ]);

        echo "Flight booked: {$pnr} - {$data['from']}{$data['to']}\n";

        return [
            'id' => $reservationId,
            'pnr' => $pnr,
            'from' => $data['from'],
            'to' => $data['to'],
            'date' => $data['date'],
            'passengers' => $data['passengers'],
            'status' => 'confirmed',
            'price' => 450.00
        ];
    }

    private function cancelFlight(array $data): array
    {
        // Check if it has already been cancelled
        $reservation = DB::selectOne('flight_reservations', ['id' => $data['id']]);
        if ($reservation['status'] === 'cancelled') { return $reservation; }

        DB::update('flight_reservations',
        ['status' => 'cancelled', 'cancelled_at' => date('Y-m-d H:i:s')],
        ['id' => $data['id']]
        );

        return [
            'id' => $data['id'],
            'pnr' => $data['pnr'],
            'status' => 'cancelled'
        ];
    }

    private function checkExistingReservation(array $data): ?array
    {
        $hash = md5(json_encode($data));
        return DB::selectOne('flight_reservations', ['idempotency_key' => $hash]) ?: null;
    }

    private function sendResponse(string $sagaId, string $status, $data): void
    {
        $response = [
            'saga_id' => $sagaId,
            'status' => $status,
            'data' => $data,
            'service' => 'flight',
            'timestamp' => time()
        ];

        $message = new AMQPMessage(
            json_encode($response),
            ['delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT]
        );

        $this->channel->basic_publish($message, '', 'saga_responses');
    }
}

$worker = new FlightServiceWorker();
$worker->start();

And to use the orchestrator (TravelBookingSagaOrchestrator):

try {
    $orchestrator = new TravelBookingSagaOrchestrator();

    $tripData = [
        'from' => 'GRU',
        'to' => 'MIA',
        'departure_date' => '2026-07-15',
        'checkin_date' => '2026-07-15',
        'checkout_date' => '2026-07-22',
        'passengers' => 2
    ];

    $result = $orchestrator->bookTrip($tripData);

    echo "\n Trip booked successfully!\n";
    echo "Flight: {$result['booking']['flight']['pnr']}\n";
    echo "Hotel: {$result['booking']['hotel']['id']}\n";
    echo "Car: {$result['booking']['car']['id']}\n";

} catch (Exception $e) {
    echo "\n ERROR: {$e->getMessage()}\n";
    echo "All reservations have been cancelled.\n";
}

Important tip: in production you would run each worker in a separate process, and the orchestrator could be called through an API or by another worker consuming a "create_order" queue.

Production settings

Before we get to the pitfalls, a few essential settings for production environments:

1. Durable queues

$channel->queue_declare('flight_commands',
    false,  // passive
    true,   // durable - Survives RabbitMQ restart
    false,  // exclusive
    false   // auto_delete
);

2. Persistent messages

$message = new AMQPMessage(
    json_encode($data),
    ['delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT]
);

3. Process 1 message at a time — important to avoid overload

$channel->basic_qos(null, 1, null);

4. Dead Letter Exchange (DLX) — for messages that failed too many times

$args = new AMQPTable([
    'x-dead-letter-exchange' => 'dlx_exchange',
    'x-dead-letter-routing-key' => 'failed_bookings'
]);

$channel->queue_declare('flight_commands', false, true, false, false, false, $args);

This is CRUCIAL so you do not lose messages that failed multiple times.

Airline, hotel and car rental APIs tend to be slow or unstable. That is why configuring timeouts matters:

$connection = new AMQPStreamConnection(
    'localhost', 5672, 'guest', 'guest', '/',
    false, // insist
    'AMQPLAIN', // login method
    null, // login response
    'en_US', // locale
    3.0, // connection timeout
    3.0  // read/write timeout
);

Important point: retry has to be implemented in the compensations!

Common pitfalls

While implementing a Saga you will run into a few obstacles. Watch out for:

1. Lack of isolation

Unlike ACID transactions, Sagas do not guarantee isolation. That means other processes can see intermediate states.

Practical example: a user may see that the payment went through while the order is not confirmed yet. You need to handle that in the UI.

2. Compensations can fail

If a compensation fails, you end up in an inconsistent state with no automatic recovery path.

Solution: implement idempotence and automatic retry. Your compensations must be idempotent (running them N times = running them once) and you need retries until they succeed.

3. Debugging is a nightmare

Especially with choreography. You will need:

  • Correlation IDs everywhere
  • Structured logs
  • Distributed tracing tools
  • A lot of patience

4. Irreversible transactions

Some operations cannot be compensated. If you sent an email, you cannot "unsend" it. If you printed a ticket, you cannot "unprint" it.

In those cases you need to think about alternative compensations (such as sending a cancellation email).

Tools that can help

Depending on the scenario, you do not need to reinvent the wheel. There are several tools/frameworks available:

  • Axon Framework — popular in the Spring Boot world
  • Eventuate — from Chris Richardson himself
  • Temporal — focused on durable execution (very good, by the way)
  • AWS Step Functions — if you are on AWS

The Temporal team has an excellent article showing how they abstract away all the tracking and retry complexity.

When NOT to use Saga

Important: not everything needs to be a Saga.

Do not use Saga if:

  • The transaction is local to a single service (obviously)
  • The data can be eventually consistent WITHOUT coordination
  • The cost of the complexity is higher than the benefit
  • You are just getting started with microservices (seriously, start simple)

As the Azure team puts it well in their documentation: evaluate the business risk. For low-risk operations, simple eventual consistency may be enough.

Lessons I learned in practice

After running Saga in production, a few takeaways:

  • Start with orchestration — it is easier to debug and evolve
  • Invest in observability — you WILL need it
  • Test the compensations — do not discover the problems in production
  • Document the flow — diagrams save lives
  • Be pragmatic — not everything needs to be transactional

Conclusion

The Saga Pattern is not a silver bullet. It adds complexity, demands discipline and will make you think A LOT about edge cases. But when you genuinely need consistency across multiple services, it is pretty much unavoidable.

Once you understand the basic concepts and pick the right approach (orchestration vs choreography), it becomes more manageable. And there are plenty of tools that help.

The secret is not trying to implement everything at once. Start simple, add observability from day one and evolve as needed.

References


Source: Originally published on dev.to

Portrait photo of Dionatan Melo, senior software engineer

Written by

Dionatan Melo

Senior Software Engineer