The most surprising thing about Sagas is that they don’t actually guarantee transactional atomicity across distributed services; instead, they provide eventual consistency through a sequence of compensating actions.
Let’s see what this looks like in practice. Imagine a simple order placement flow:
- Create Order: A
POST /ordersrequest comes in. We create an order record in theordersservice database with aPENDINGstatus. - Reserve Inventory: We then call the
inventoryservice:POST /inventory/reservewith the order details. If successful, inventory is marked as reserved. - Process Payment: Next, we call the
paymentservice:POST /payments/processwith the order and user details. If successful, payment is captured. - Mark Order Complete: Finally, we update the
ordersservice:PUT /orders/{order_id}/complete.
Here’s a simplified Go representation of a Saga orchestrator:
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
)
type Order struct {
ID string `json:"id"`
UserID string `json:"userId"`
Amount float64 `json:"amount"`
Status string `json:"status"`
}
type InventoryReservation struct {
OrderID string `json:"orderId"`
ItemID string `json:"itemId"`
Quantity int `json:"quantity"`
}
type PaymentDetails struct {
OrderID string `json:"orderId"`
UserID string `json:"userId"`
Amount float64 `json:"amount"`
}
type SagaOrchestrator struct {
OrderServiceURL string
InventoryServiceURL string
PaymentServiceURL string
}
func (s *SagaOrchestrator) PlaceOrder(order Order) error {
// Step 1: Create Order
order.Status = "PENDING"
createdOrder, err := s.createOrder(order)
if err != nil {
return fmt.Errorf("failed to create order: %w", err)
}
fmt.Printf("Order created: %+v\n", createdOrder)
// Step 2: Reserve Inventory
reservation := InventoryReservation{
OrderID: createdOrder.ID,
ItemID: "item-123", // Simplified for example
Quantity: 1,
}
if err := s.reserveInventory(reservation); err != nil {
fmt.Printf("Inventory reservation failed, initiating compensation: %v\n", err)
s.compensateCreateOrder(createdOrder.ID) // Compensate step 1
return fmt.Errorf("failed to reserve inventory: %w", err)
}
fmt.Printf("Inventory reserved for order %s\n", createdOrder.ID)
// Step 3: Process Payment
payment := PaymentDetails{
OrderID: createdOrder.ID,
UserID: createdOrder.UserID,
Amount: createdOrder.Amount,
}
if err := s.processPayment(payment); err != nil {
fmt.Printf("Payment processing failed, initiating compensation: %v\n", err)
s.releaseInventory(reservation) // Compensate step 2
s.compensateCreateOrder(createdOrder.ID) // Compensate step 1
return fmt.Errorf("failed to process payment: %w", err)
}
fmt.Printf("Payment processed for order %s\n", createdOrder.ID)
// Step 4: Mark Order Complete
if err := s.completeOrder(createdOrder.ID); err != nil {
fmt.Printf("Order completion failed, initiating compensation: %v\n", err)
s.refundPayment(payment) // Compensate step 3
s.releaseInventory(reservation) // Compensate step 2
s.compensateCreateOrder(createdOrder.ID) // Compensate step 1
return fmt.Errorf("failed to complete order: %w", err)
}
fmt.Printf("Order %s completed successfully\n", createdOrder.ID)
return nil
}
// --- Helper methods for service interactions ---
func (s *SagaOrchestrator) createOrder(order Order) (Order, error) {
resp, err := http.Post(s.OrderServiceURL+"/orders", "application/json", marshalJSON(order))
if err != nil {
return Order{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return Order{}, fmt.Errorf("order service returned status %d", resp.StatusCode)
}
var created Order
json.NewDecoder(resp.Body).Decode(&created)
return created, nil
}
func (s *SagaOrchestrator) reserveInventory(res InventoryReservation) error {
resp, err := http.Post(s.InventoryServiceURL+"/inventory/reserve", "application/json", marshalJSON(res))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("inventory service returned status %d", resp.StatusCode)
}
return nil
}
func (s *SagaOrchestrator) processPayment(pay PaymentDetails) error {
resp, err := http.Post(s.PaymentServiceURL+"/payments/process", "application/json", marshalJSON(pay))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("payment service returned status %d", resp.StatusCode)
}
return nil
}
func (s *SagaOrchestrator) completeOrder(orderID string) error {
req, err := http.NewRequest("PUT", fmt.Sprintf("%s/orders/%s/complete", s.OrderServiceURL, orderID), nil)
if err != nil {
return err
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("order service returned status %d", resp.StatusCode)
}
return nil
}
// --- Compensation methods ---
func (s *SagaOrchestrator) compensateCreateOrder(orderID string) {
fmt.Printf("Compensating order creation for order ID: %s\n", orderID)
// In a real system, this would typically involve marking the order as CANCELLED
// or deleting it, depending on business logic. For simplicity, we'll just log.
}
func (s *SagaOrchestrator) releaseInventory(res InventoryReservation) {
fmt.Printf("Releasing inventory for order ID: %s\n", res.OrderID)
// Call inventory service to release reserved items.
// e.g., POST /inventory/release with reservation details
}
func (s *SagaOrchestrator) refundPayment(pay PaymentDetails) {
fmt.Printf("Refunding payment for order ID: %s\n", pay.OrderID)
// Call payment service to refund the amount.
// e.g., POST /payments/refund with payment details
}
func marshalJSON(v interface{}) *bytes.Buffer {
b := new(bytes.Buffer)
json.NewEncoder(b).Encode(v)
return b
}
func main() {
// Example usage
orchestrator := &SagaOrchestrator{
OrderServiceURL: "http://localhost:8081",
InventoryServiceURL: "http://localhost:8082",
PaymentServiceURL: "http://localhost:8083",
}
newOrder := Order{
ID: "order-abc-123",
UserID: "user-xyz",
Amount: 99.99,
}
if err := orchestrator.PlaceOrder(newOrder); err != nil {
fmt.Printf("Saga failed: %v\n", err)
}
}
The core idea is that each step in the "happy path" has a corresponding "compensation" action. If any step fails, the orchestrator executes the compensation actions for all preceding successful steps in reverse order.
For instance, if processPayment fails after createOrder and reserveInventory succeeded:
- The orchestrator will call
releaseInventoryto undo the inventory reservation. - Then, it will call
compensateCreateOrderto undo the order creation (perhaps by marking the order asCANCELLED).
This reactive approach, driven by failures, is what allows Sagas to manage distributed transactions. The orchestrator acts as the central brain, coordinating the sequence of operations and their rollbacks.
One key aspect often overlooked is the idempotency of both the forward and compensating operations. Imagine the reserveInventory call times out. The orchestrator might retry it. If the reserveInventory operation isn’t idempotent, it could lead to double-reservations. Similarly, if a compensation call times out and is retried, it must not cause unintended side effects. This often involves adding unique request IDs to operations and having services check if an operation with that ID has already been processed.
The next challenge you’ll encounter is handling long-running Sagas and ensuring their state is persisted across restarts.