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:

  1. Create Order: A POST /orders request comes in. We create an order record in the orders service database with a PENDING status.
  2. Reserve Inventory: We then call the inventory service: POST /inventory/reserve with the order details. If successful, inventory is marked as reserved.
  3. Process Payment: Next, we call the payment service: POST /payments/process with the order and user details. If successful, payment is captured.
  4. Mark Order Complete: Finally, we update the orders service: 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 releaseInventory to undo the inventory reservation.
  • Then, it will call compensateCreateOrder to undo the order creation (perhaps by marking the order as CANCELLED).

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.

Want structured learning?

Take the full Saga-pattern course →