Sagas aren’t about eventual consistency so much as they are about bounded consistency across distributed services.
Let’s see what that looks like in practice. Imagine a simple e-commerce order flow:
- Order Service creates an order.
- Payment Service authorizes payment.
- Inventory Service reserves stock.
- Shipping Service creates a shipment.
Here’s a simplified look at how the Order Service might orchestrate this, using a hypothetical event-driven saga pattern. We’ll use a conceptual SagaOrchestrator that listens for events and dispatches commands.
# Example Saga Orchestration Logic (Conceptual)
# In a real system, this would be code or a DSL.
saga_definition:
name: PlaceOrderSaga
initial_state: ORDER_CREATED
transitions:
# --- Success Path ---
ORDER_CREATED:
on_event: OrderCreatedEvent
command_to_send: AuthorizePaymentCommand(orderId, amount)
next_state: PAYMENT_AUTHORIZED
PAYMENT_AUTHORIZED:
on_event: PaymentAuthorizedEvent
command_to_send: ReserveInventoryCommand(orderId, items)
next_state: INVENTORY_RESERVED
INVENTORY_RESERVED:
on_event: InventoryReservedEvent
command_to_send: CreateShipmentCommand(orderId, shippingAddress)
next_state: SHIPMENT_CREATED
SHIPMENT_CREATED:
on_event: ShipmentCreatedEvent
command_to_send: MarkOrderCompleteCommand(orderId)
next_state: ORDER_COMPLETED
final_state: true
# --- Compensation Path (Example for Payment Failure) ---
INVENTORY_RESERVED:
on_event: PaymentFailedEvent # Or a specific compensation event
command_to_send: ReleaseInventoryCommand(orderId, items)
next_state: INVENTORY_RELEASED
compensation_for: PAYMENT_AUTHORIZED # Indicates this compensates a previous step
INVENTORY_RELEASED:
on_event: InventoryReleasedEvent
command_to_send: CancelOrderCommand(orderId) # Compensate order creation
next_state: ORDER_CANCELLED
final_state: true
# ... other compensation paths for Inventory failure, Shipping failure, etc.
When the Order Service receives an OrderCreatedEvent, it sends an AuthorizePaymentCommand to the Payment Service. If the Payment Service replies with PaymentAuthorizedEvent, the Order Service then triggers the next step: ReserveInventoryCommand. If, however, the Payment Service emits a PaymentFailedEvent, the saga doesn’t just stop; it starts compensating. It might send a ReleaseInventoryCommand to the Inventory Service (if inventory was already reserved) and then a CancelOrderCommand to itself.
The problem sagas solve is managing state and consistency across multiple independent services that cannot directly update a shared database. Each service owns its data, and communication happens via asynchronous events or commands. The saga acts as the conductor, ensuring that a series of operations either completes successfully or is undone in a controlled, step-by-step manner. This avoids the distributed transaction complexities of two-phase commit (2PC) while providing a similar level of transactional integrity, albeit with a different consistency model.
The most surprising thing about sagas is that they don’t actually guarantee eventual consistency in the way many people assume. They guarantee bounded consistency – a state where the system is guaranteed to be consistent within the boundaries of the saga, and if the saga fails, it will eventually reach a defined compensating state. It’s not about everything eventually being the same everywhere, but about the transactional outcome eventually being reflected consistently across the involved services, either through success or failure compensation.
A common misconception is that sagas are inherently complex to implement. While they require careful design, modern frameworks and event streaming platforms (like Kafka, RabbitMQ, or cloud-native solutions) provide robust building blocks. The core challenge isn’t the plumbing, but defining the correct compensation logic for every possible failure point.
The "bounded consistency" aspect is key. When a saga completes successfully, the system is in a consistent state with respect to that specific business transaction. If it fails, the compensation logic ensures that any partial effects are undone, bringing the system back to a consistent state that reflects the failure of the transaction. This is distinct from traditional ACID transactions where a failure means absolutely nothing changed. With sagas, things might have changed (e.g., inventory reserved) and then were undone.
Consider the OrderCreatedEvent in the example above. If the saga fails after the OrderCreatedEvent has been published but before the PaymentAuthorizedEvent is received, the Order Service might still have an order in a "pending" or "failed" state. The compensation logic needs to handle this. If the failure happens after PaymentAuthorizedEvent, the compensation might involve calling a RefundPaymentCommand on the Payment Service. The critical part is that each service has its own "compensating" command that the saga orchestrator can invoke.
The real levers you control are the commands you send and the events you listen for. The saga orchestrator is essentially a state machine that maps incoming events to outgoing commands. The complexity arises when you have many services, many possible failure modes, and a need for sophisticated retry strategies or idempotency guarantees within each command handler.
The single most counterintuitive piece of saga design is the need for idempotency on both the forward and compensation paths. If a command is sent twice (e.g., due to network retry), the service must be able to process it harmlessly the second time. This means that if a ReserveInventoryCommand is processed, and then the orchestrator retries sending it due to a timeout, the Inventory Service must detect that inventory was already reserved for that order ID and simply acknowledge success without reserving it again. This applies equally to compensation commands; you don’t want to release inventory twice.
The next concept you’ll explore is how to handle complex, non-linear saga flows and how to manage the state of long-running sagas in a resilient way.