Tag: Choreography

  • Saga Orchestration vs Choreography on Hazelcast

    Saga Orchestration vs Choreography on Hazelcast

    Part 10 in the “Building Event-Driven Microservices with Hazelcast” series


    Back in Part 4, we built a choreographed saga for order fulfillment. Four services — Order, Inventory, Payment, and Account — coordinate through Hazelcast ITopic events. Each service reacts independently, no central coordinator. The flow is implicit, spread across three saga listeners, a compensation registry, and a timeout detector.

    That works. It works well, actually, for loosely coupled flows where services don’t need to know about each other. But I kept running into the same questions: what if you need the whole saga visible in one place? What if you need per-step timeout and retry? What if the caller wants to wait for the saga to finish before responding?

    So the framework now supports a second saga architectural pattern — the orchestrated saga. This post compares both, walks through the implementation, and shows how they run side by side in the same system.


    When to Use Which

    Neither pattern wins across the board. It depends on what you need:

    Requirement Choreography Orchestration
    Services should be fully decoupled Best
    Need the whole flow in one readable file Best
    Caller needs a synchronous response Best
    High throughput (thousands of sagas/sec) Best
    Per-step timeout and retry Best
    Services evolve independently Best
    Complex branching or conditional logic Best
    No single point of failure Best

    Choreography is the better fit when services publish events for many consumers, not just one saga. Adding a new saga consumer doesn’t require changing any existing service — you just stand up a new listener.

    Orchestration is the better fit when the saga is a well-defined workflow with a clear owner and the caller (a REST endpoint, typically) wants to return the result directly. Order fulfillment is a textbook example.


    Architecture Comparison

    Choreographed Flow

    Choreographed saga flow: the caller receives 202 Accepted immediately from the Order Service, then OrderCreated, StockReserved, and PaymentProcessed events propagate as asynchronous Hazelcast ITopic messages through Inventory, Payment, and Account services with no central coordinator

    Every arrow is an asynchronous event on the shared Hazelcast cluster. The caller gets back a 202 Accepted immediately. The saga completes whenever it completes.

    Orchestrated Flow

    Orchestrated saga flow: the caller invokes the Saga Orchestrator and waits for the final result while the orchestrator makes synchronous HTTP calls for CreateOrder, ReserveStock, ProcessPayment, and ConfirmOrder to the Order, Inventory, Payment, and Account services

    Every arrow is a synchronous HTTP call. The orchestrator waits for each step to finish before moving to the next. The caller gets the final result — success or failure — in the response.

    The visual difference tells you most of what you need to know. Choreography is a chain. Orchestration is a hub with spokes.


    Implementation Comparison

    Choreography: Saga Listeners

    In the choreographed pattern, each service has a listener subscribed to events on the shared Hazelcast cluster:

    @Component
    public class InventorySagaListener {
    
        public InventorySagaListener(
                @Qualifier("hazelcastClient") HazelcastInstance hazelcast,
                InventoryService inventoryService,
                SagaStateStore sagaStateStore) {
    
            ITopic<GenericRecord> topic = hazelcast.getTopic("OrderCreated");
            topic.addMessageListener(message -> {
                GenericRecord event = message.getMessageObject();
                String sagaId = event.getString("sagaId");
    
                // Guard: only process OrderFulfillment sagas
                if (!"OrderFulfillment".equals(event.getString("sagaType"))) return;
    
                // Perform local action
                inventoryService.reserveStockForSaga(productId, quantity, ...);
    
                // Update saga state and publish next event
                sagaStateStore.updateOrAddStep(sagaId, 1, StepStatus.COMPLETED);
                hazelcast.getTopic("StockReserved").publish(nextEvent);
            });
        }
    }
    

    Three listeners across three services, wired together only by event names. To understand the full flow, you read code in three different modules. Compensation is handled by a CompensationRegistry that maps forward events to their compensating counterparts:

    registry.register("OrderCreated", "OrderCancelled", "order-service");
    registry.register("StockReserved", "StockReleased", "inventory-service");
    registry.register("PaymentProcessed", "PaymentRefunded", "payment-service");
    

    Orchestration: SagaDefinition Builder

    The orchestrated version puts the entire saga in one place:

    @Component
    public class OrderFulfillmentSagaFactory {
    
        public SagaDefinition create() {
            return SagaDefinition.builder()
                    .name("OrderFulfillmentOrchestrated")
    
                    .step("CreateOrder")
                        .action(this::createOrderAction)
                        .compensation(this::createOrderCompensation)
                        .timeout(Duration.ofSeconds(15))
                        .build()
    
                    .step("ReserveStock")
                        .action(this::reserveStockAction)
                        .compensation(this::reserveStockCompensation)
                        .timeout(Duration.ofSeconds(15))
                        .build()
    
                    .step("ProcessPayment")
                        .action(this::processPaymentAction)
                        .compensation(this::processPaymentCompensation)
                        .timeout(Duration.ofSeconds(15))
                        .build()
    
                    .step("ConfirmOrder")
                        .action(this::confirmOrderAction)
                        .noCompensation()
                        .timeout(Duration.ofSeconds(10))
                        .build()
    
                    .sagaTimeout(Duration.ofSeconds(60))
                    .build();
        }
    }
    

    Four steps, forward actions, compensations, timeouts — all readable in one file. You pay for that readability: the Order Service now has direct knowledge of the Inventory and Payment services’ HTTP endpoints.


    The Orchestrator State Machine

    HazelcastSagaOrchestrator is the engine that executes a SagaDefinition. The execution flow:

    HazelcastSagaOrchestrator execution flow: start records the saga and schedules a 60-second timeout, then each step runs its action under a 15-second timeout, branching to SUCCESS which merges context and executes the next step, FAILURE after retries which compensates in reverse order, or TIMEOUT which compensates in reverse order

    Internally, a SagaExecution instance tracks the running state: current step index, completed step names, an AtomicBoolean for compensation (preventing a race between step failure and the saga-level timeout firing at the same moment), and step start timestamps for duration metrics.

    Per-Step Retry

    Each SagaStep can configure maxRetries and retryDelay. When a step fails, the orchestrator checks if retries remain. If so, it waits retryDelay milliseconds and re-executes. If not, compensation kicks in.

    This is separate from the Resilience4j circuit breakers that the choreographed saga listeners use. Different communication styles, different retry mechanisms.


    Why HTTP Instead of ITopic?

    You might wonder why the orchestrator makes HTTP calls to the other services instead of publishing events on Hazelcast ITopic.

    Two reasons.

    First, request-response semantics. The orchestrator needs to know whether each step succeeded before proceeding to the next. ITopic is fire-and-forget — there’s no built-in way for a publisher to wait for a consumer’s response. HTTP gives you synchronous request-response for free.

    Second, our dual-instance architecture. Each service runs an embedded Hazelcast instance for Jet pipelines and a client to the shared cluster for cross-service events. Jet pipeline lambdas reference service-specific classes that can’t serialize across services — that’s the whole reason for the dual-instance design (see Part 5 for where this architecture first bit us). HTTP sidesteps Hazelcast serialization entirely. Each service processes the request in its own JVM with full access to its own classes.

    The SagaServiceClient wraps these calls:

    public class SagaServiceClient implements SagaServiceClientOperations {
    
        public OrchestratedStepResponse reserveStock(
                String productId, int quantity, String orderId) {
            // POST /api/saga/inventory/reserve-stock
            return restTemplate.postForObject(
                    inventoryServiceUrl + "/api/saga/inventory/reserve-stock",
                    request, OrchestratedStepResponse.class);
        }
    
        public OrchestratedStepResponse processPayment(
                String orderId, String customerId,
                double amount, String currency, String method) {
            // POST /api/saga/payment/process
            return restTemplate.postForObject(
                    paymentServiceUrl + "/api/saga/payment/process",
                    request, OrchestratedStepResponse.class);
        }
    }
    

    Each remote service exposes dedicated saga endpoints (like /api/saga/inventory/reserve-stock) that return an OrchestratedStepResponse — a success/failure envelope. The SagaServiceClient implements the SagaServiceClientOperations interface, which exists so Mockito can mock it on Java 25. (Mockito’s inline mock maker can’t mock concrete classes there. We hit this in several places — extract an interface, move on.)


    Compensation: Two Approaches

    Choreography: Event-Based

    When a step fails, the SagaCompensator looks up the CompensationRegistry and publishes compensation events via ITopic:

    Choreography event-based compensation: a PaymentFailed event triggers SagaCompensator.compensate, which looks up the CompensationRegistry to map StockReserved to StockReleased and OrderCreated to OrderCancelled, publishing each compensating event on its ITopic

    Each service processes its own compensation event independently. The SagaTimeoutDetector can also trigger this if a saga exceeds its deadline.

    Orchestration: Lambda-Based

    When a step fails, the orchestrator walks completed steps in reverse and executes their compensation lambdas directly:

    Orchestration lambda-based compensation: after Step 2 ProcessPayment fails, the orchestrator walks completed steps in reverse, issuing an HTTP POST to release stock and cancelling the order locally, then finalizes the saga as COMPENSATED

    No events, no registry. The compensation logic sits right next to the forward action in the SagaDefinition. The final step (ConfirmOrder) uses .noCompensation() — there’s nothing to undo once you’ve confirmed.

    If a compensation step itself fails, the orchestrator marks the saga as FAILED rather than COMPENSATED. That means manual intervention. It’s not a situation you want, but at least you know about it immediately rather than discovering it later in an audit.


    Running Both Simultaneously

    Both patterns coexist in the same system. No interference.

    Pattern Saga Type REST Endpoint
    Choreography OrderFulfillment POST /api/orders
    Orchestration OrderFulfillmentOrchestrated POST /api/orders/orchestrated

    The key to coexistence is the sagaType field. Choreographed saga listeners filter on it — when the orchestrated flow creates an OrderCreated event, the InventorySagaListener ignores it because the type is “OrderFulfillmentOrchestrated”, not “OrderFulfillment”.

    Both patterns write to the same SagaStateStore (a Hazelcast IMap), so you can query across both or filter:

    # All sagas
    curl http://localhost:8083/api/sagas
    
    # Only choreographed
    curl http://localhost:8083/api/sagas?type=OrderFulfillment
    
    # Only orchestrated
    curl http://localhost:8083/api/sagas?type=OrderFulfillmentOrchestrated
    

    The MCP list_sagas tool supports the same filter:

    list_sagas(type="OrderFulfillmentOrchestrated", status="COMPLETED")
    


    Observability

    The orchestrator records a saga.step.duration timer for every step:

    private void recordStepDuration(SagaExecution exec, String stepName) {
        if (sagaMetrics != null && exec.stepStartedAt != null) {
            Duration stepDuration = Duration.between(exec.stepStartedAt, Instant.now());
            sagaMetrics.recordStepDuration(
                    exec.definition.getName(), stepName, stepDuration);
        }
    }
    

    Tagged with sagaType and stepName, so you can query individual steps:

    # p95 duration for the ProcessPayment step
    histogram_quantile(0.95,
      rate(saga_step_duration_seconds_bucket{
        sagaType="OrderFulfillmentOrchestrated",
        stepName="ProcessPayment"
      }[5m]))
    

    Choreographed sagas track overall saga_duration_seconds but not per-step timing — the flow is distributed across services, so there’s no single place to measure each step. That’s a genuine observability trade-off between the two patterns.

    The Grafana saga dashboard has a Choreography vs Orchestration row: p50/p95 duration comparison, success/failure rates per pattern, and an orchestrated step breakdown showing where time goes across CreateOrder, ReserveStock, ProcessPayment, and ConfirmOrder.

    The MCP run_demo tool includes orchestrated scenarios:

    Scenario Pattern Expected Outcome
    happy_path Choreographed Order confirmed via events
    payment_failure Choreographed Stock released via compensation events
    orchestrated_happy_path Orchestrated Order confirmed via HTTP, sync response
    orchestrated_payment_failure Orchestrated Stock released via reverse compensation, 409 response

    The Summary Table

    Choreography Orchestration
    Communication Hazelcast ITopic events HTTP calls
    Flow definition Distributed across listeners Centralized in SagaDefinition
    Compensation CompensationRegistry + event publishing Reverse-order lambda execution
    Timeout handling SagaTimeoutDetector (scheduled) Per-step + saga-level timeouts
    Response model Async (202 Accepted) Sync (201 Created or 409 Conflict)
    Retry Resilience4j (circuit breaker + retry) Built-in per-step retry with delay
    Metrics Saga-level duration Saga-level + per-step duration
    Saga type OrderFulfillment OrderFulfillmentOrchestrated

    Choreography is still the right default for most event-sourced systems — it preserves service independence and scales naturally. Orchestration earns its place when you need the flow readable in one file, synchronous responses, and fine-grained per-step control.

    Both patterns share the same SagaStateStore, the same Grafana dashboards, and the same MCP tools. Pick the right one for each saga, or run both and compare.


    Previous: Dead Letter Queue + Idempotency: Exactly-Once on Hazelcast

    Code: github.com/myawnhc/hazelcast-microservices-framework — clone it, docker-compose up, and the framework boots locally with sample data.

  • Saga Pattern: Distributed Transactions Without 2PC

    Saga Pattern: Distributed Transactions Without 2PC

    Part 4 in the “Building Event-Driven Microservices with Hazelcast” series


    Introduction

    In the first three articles, we built an event sourcing framework with a Jet pipeline and materialized views. Each service is self-contained — it owns its events, its views, and its data. And one of the things that makes event-driven architecture powerful is that event publishing is fire-and-forget. The publisher doesn’t know or care who’s receiving the messages, or whether anyone acted on them properly.

    But someone cares. Probably your boss.

    That decoupling is a feature, not a bug — it’s exactly what gives you the independence to evolve services separately. If you wanted to add a loyalty program based on orders placed, you wouldn’t touch a single line of existing code. You’d build the new functionality and subscribe it to OrderCreated events. Done. No coordination, no release trains, no “can you add a field to your response for us.”

    But that same independence becomes a problem when a business operation requires coordination. Placing an order in our eCommerce system spans four services:

    1. Order Service creates the order
    2. Inventory Service reserves stock
    3. Payment Service charges the customer
    4. Order Service confirms the order

    If payment fails after stock is reserved, we need to release the stock. If the inventory service is down, we need to cancel the order. The fire-and-forget publisher doesn’t know any of this happened — and nobody else is keeping track unless we build something that does.

    Now — a real eCommerce system wouldn’t cancel an order just because the inventory service hiccupped. You’d create a backorder, or queue the reservation for retry, or do whatever it takes to keep the customer’s money. Nobody in the business is going to say “yeah, let’s give up on that sale because a container restarted.” But we’re building a framework to demonstrate microservice patterns, not to compete with Shopify. The cancel-and-compensate flow shows sagas at their most illustrative: forward steps, failure detection, compensation, and state tracking across services. That’s the machinery we want to examine. So we went with the version that best shows how the pattern works, not the version that best sells laptops.

    This is the distributed transaction problem, and sagas are the solution.


    Why Sagas? Because the Alternative Is Worse.

    We didn’t adopt event sourcing because it was fashionable. We adopted it because the alternative — mutable state scattered across services with no audit trail — was worse. Sagas are the same kind of choice.

    When a business operation touches one database, you wrap it in a transaction. ACID gives you atomicity: either all the changes commit or none of them do. Every developer learns this in their first database course, and it works.

    But our order fulfillment touches four services, each with its own data. A single database transaction can’t span them. So you reach for two-phase commit — 2PC — the textbook answer for distributed transactions. And that’s where the trouble starts.

    2PC works by appointing a coordinator that asks each participant “can you commit?” and then, once everyone says yes, tells them all to go ahead. The problem is what happens between those two phases. Every participant is holding locks — on inventory rows, on payment records, on order state — while waiting for the coordinator’s decision. In a monolith, that wait is microseconds. Across a network, it’s milliseconds at best, and if the coordinator is slow or the network partitions, those locks can be held for seconds. Or longer.

    Think about what that means for throughput. While one order is sitting in that limbo state between “prepare” and “commit,” no other transaction can touch those same inventory rows. At 50 orders per second, you’ve got 50 sets of distributed locks competing for the same resources, each one waiting on network round-trips to a coordinator that is itself a single point of failure. If the coordinator crashes mid-protocol, those locks stay held until someone intervenes manually. The participants are stuck — they’ve promised to commit but haven’t been told to, and they can’t unilaterally release the locks without risking inconsistency.

    It’s not that 2PC is theoretically wrong. It works fine in environments where all participants are on the same local network, latency is sub-millisecond, and the coordinator never fails. That environment is called “a single database server.” Once you’ve distributed beyond that, you’re fighting physics.

    Sagas take a fundamentally different approach. Instead of one big atomic transaction with distributed locks, you execute a sequence of local transactions — each one fast, each one touching only one service’s data, each one committing immediately. If step 3 fails, you don’t roll back steps 1 and 2 by releasing locks. You compensate — you issue new transactions that undo the business effect of the earlier steps. Reserve stock, then charge payment, then… payment fails? Issue a stock release. The saga doesn’t pretend the work never happened. It acknowledges what happened and fixes it forward.

    The trade-off is that you give up the clean atomicity of “all or nothing” and accept eventual consistency — a window of time where stock is reserved but payment hasn’t been attempted yet, or payment has been charged but the order isn’t confirmed. In our system, that window is typically under a second. For the user, the order is “processing.” For the system, the saga is in flight. And if something fails, compensation events fire and the system converges to a consistent state — just not instantaneously.


    Choreography and Orchestration

    There are two ways to coordinate a saga. They represent genuinely different architectural philosophies, and neither is universally right.

    Choreography: Services React to Events

    In a choreographed saga, there’s no coordinator. Each service listens for events that concern it and reacts:

    Order Service publishes OrderCreated -->
      Inventory Service hears it, reserves stock, publishes StockReserved -->
        Payment Service hears it, charges customer, publishes PaymentProcessed -->
          Order Service hears it, confirms order
    

    The flow emerges from the interactions between services. No single component knows the full sequence. Each service knows only “when I see event X, I do Y and publish Z.”

    Orchestration: A Central Controller Directs the Flow

    In an orchestrated saga, one component — the orchestrator — knows the whole sequence and directs each step:

    Orchestrator --> "Order Service, create order"
    Orchestrator --> "Inventory Service, reserve stock"
    Orchestrator --> "Payment Service, charge customer"
    Orchestrator --> "Order Service, confirm order"
    

    The orchestrator is a state machine. It tracks where the saga is, what comes next, and what to undo if something fails.

    Choosing Between Them

    Choreography is the simpler choice when your saga is a linear chain — A triggers B triggers C triggers D — and the services are already publishing events for other reasons. If your system is event-driven (ours is), choreographed sagas are almost free. The services are already emitting events. The saga is just another consumer. No new infrastructure, no single point of failure, and each service stays fully independent.

    But choreography gets painful as the flow gets complex. Say step 2 needs to branch — charge a credit card or apply store credit, depending on the payment method. Now you’re encoding conditional logic across multiple listeners that can’t see each other. If someone asks “what does this saga actually do, end to end?” the answer is “go read three listener classes in three different services and piece it together.” For a four-step linear chain, fine. For a ten-step flow with branches and conditional skips, that’s a mess.

    That’s where orchestration earns its keep. The entire saga — forward steps, compensation steps, timeouts, retry logic — lives in a single definition file. You read it top to bottom. You can add per-step timeouts, per-step retries. The caller can wait for the result synchronously. The cost is coupling: the orchestrator needs to know about every service’s endpoint, and it becomes a component you have to keep running.

    A rough rule of thumb:

    Start with choreography when the saga is a linear chain with fewer than about five steps, the services already publish events, and you don’t need a synchronous response. Move to orchestration when you need branching or conditional logic, per-step timeout and retry control, a synchronous success/failure response for the caller, or when the saga has gotten complex enough that nobody can trace it across listeners without a whiteboard.

    We started with choreography for order fulfillment because it’s a clean four-step chain and our entire architecture is already built around events. Later, when we needed synchronous responses for certain API paths and wanted per-step timeout control, we added orchestration as a second option. Both patterns coexist in the framework, running against the same saga state store. We’ll cover the orchestration implementation and how we run both side by side in a later article.


    The Order Fulfillment Saga

    Here’s the full flow for the choreographed version:

    Order Fulfillment Saga happy path — four services emitting OrderCreated, StockReserved, PaymentProcessed, OrderConfirmed in sequence, with saga state transitioning from STARTED through IN_PROGRESS to COMPLETED

    Event Context Propagation

    Every saga event carries metadata that links the chain together:

    // These fields are present on every saga event
    String sagaId;         // Links all events in one saga instance
    String correlationId;  // Links to the original API request
    String sagaType;       // "OrderFulfillment"
    int stepNumber;        // Which step (0, 1, 2, 3)
    boolean isCompensating; // True for compensation events
    

    The sagaId is generated when the order is created and propagated through every subsequent event. That’s how the Inventory Service knows which saga a StockReserved event belongs to, and how the Payment Service gets the payment details — amount, currency, method — from the StockReserved event’s context fields.


    Implementing Saga Listeners

    Each service has a saga listener that subscribes to relevant events via Hazelcast ITopic. Here’s the Payment Service’s:

    @Component
    public class PaymentSagaListener {
    
        public PaymentSagaListener(
                @Qualifier("hazelcastClient") HazelcastInstance hazelcast,
                PaymentService paymentService,
                SagaStateStore sagaStateStore) {
    
            // Listen for StockReserved --> process payment
            ITopic<GenericRecord> stockReservedTopic = hazelcast.getTopic("StockReserved");
            stockReservedTopic.addMessageListener(message -> {
                GenericRecord event = message.getMessageObject();
                String sagaId = event.getString("sagaId");
                String orderId = event.getString("orderId");
                String amount = event.getString("paymentAmount");
                String currency = event.getString("paymentCurrency");
                String method = event.getString("paymentMethod");
    
                paymentService.processPaymentForOrder(
                    orderId, customerId, amount, currency, method,
                    sagaId, event.getString("correlationId")
                );
            });
    
            // Listen for PaymentRefundRequested --> process refund
            ITopic<GenericRecord> refundTopic = hazelcast.getTopic("PaymentRefundRequested");
            refundTopic.addMessageListener(message -> {
                GenericRecord event = message.getMessageObject();
                String paymentId = event.getString("paymentId");
                String sagaId = event.getString("sagaId");
    
                paymentService.refundPaymentForSaga(
                    paymentId, "Saga compensation", sagaId,
                    event.getString("correlationId")
                );
            });
        }
    }
    

    The listener uses @Qualifier(“hazelcastClient”) — it connects to the shared cluster, not the embedded instance. That’s the dual-instance architecture from Part 2. Each listener is a plain @Component that Spring creates on startup; the ITopic subscription stays active for the life of the service. And the listener itself doesn’t contain business logic — it unpacks the event, delegates to the service layer, and gets out of the way.


    Compensation: Undoing Work

    When a step fails, we need to undo the work completed by previous steps. This is compensation — often described as the saga equivalent of a rollback, though it isn’t really a rollback at all. More on that in a minute.

    Compensation Flow: Payment Fails

    Order Fulfillment Saga compensation flow — payment fails at step 3, triggering reverse-order compensation events PaymentRefundRequested, StockReleased, and OrderCancelled, ending in the COMPENSATED state

    Compensation Flow: Stock Unavailable

    StockReservationFailed event
             |
             '---> Order Service
                   '-- Cancels order (status: CANCELLED)
                   '-- No stock release needed (nothing was reserved)
    
             Saga finalized as COMPENSATED
    

    The Compensation Registry

    A CompensationRegistry maps each forward event to its compensating event and responsible service:

    @Configuration
    public class ECommerceCompensationConfig {
    
        @Bean
        public CompensationRegistrar ecommerceCompensations(CompensationRegistry registry) {
            // Step 0: OrderCreated --> compensate with OrderCancelled
            registry.register("OrderCreated", "OrderCancelled", "order-service");
    
            // Step 1: StockReserved --> compensate with StockReleased
            registry.register("StockReserved", "StockReleased", "inventory-service");
    
            // Step 2: PaymentProcessed --> compensate with PaymentRefunded
            registry.register("PaymentProcessed", "PaymentRefunded", "payment-service");
    
            // Step 3: OrderConfirmed --> no compensation (terminal success)
            return () -> {};
        }
    }
    

    This is the one place that documents the entire saga structure. Even though the execution is distributed across listeners, the registry makes the step-to-compensation mapping readable in a single file. That’s something people miss about choreography — it doesn’t mean the structure is undocumented, it means the structure is declared separately from the execution.


    Saga State Tracking

    The SagaState class is an immutable state machine that tracks each saga instance:

    public class SagaState implements Serializable {
    
        private final String sagaId;
        private final String sagaType;         // "OrderFulfillment"
        private final String correlationId;
        private final SagaStatus status;       // STARTED --> IN_PROGRESS --> COMPLETED
        private final List<SagaStepRecord> steps;
        private final Instant startedAt;
        private final Instant deadline;        // Absolute timeout
    }
    

    Status transitions:

    STARTED --> IN_PROGRESS --> COMPLETED     (happy path)
    STARTED --> IN_PROGRESS --> COMPENSATING --> COMPENSATED  (failure + recovery)
    STARTED --> IN_PROGRESS --> TIMED_OUT --> COMPENSATING --> COMPENSATED  (timeout)
    STARTED --> IN_PROGRESS --> FAILED        (unrecoverable)
    

    The state lives in a HazelcastSagaStateStore backed by an IMap on the shared cluster. Every service can read and update saga state because they all connect to the same shared cluster via the client instance.

    Each step gets recorded:

    public class SagaStepRecord implements Serializable {
    
        private final int stepNumber;
        private final String eventType;
        private final StepStatus status;    // PENDING, COMPLETED, FAILED, COMPENSATED
        private final Instant completedAt;
        private final String failureReason;
    }
    

    Timeout Handling

    Sagas can get stuck. A service might be down, a message might be lost, or a listener might throw an unhandled exception. Without timeout detection, a stuck saga hangs forever — stock reserved but never charged, or charged but never confirmed.

    The SagaTimeoutDetector is a scheduled service that runs in each service instance:

    @Component
    public class SagaTimeoutDetector {
    
        @Scheduled(fixedDelayString = "${saga.timeout.check-interval:5000}")
        public void detectTimeouts() {
            List<SagaState> timedOut = sagaStateStore.findTimedOutSagas(
                maxBatchSize
            );
    
            for (SagaState saga : timedOut) {
                sagaStateStore.markTimedOut(saga.getSagaId());
    
                if (autoCompensate) {
                    compensator.compensate(saga);
                }
    
                applicationEventPublisher.publishEvent(
                    new SagaTimedOutEvent(saga)
                );
    
                sagaMetrics.recordSagaTimedOut(saga.getSagaType());
            }
        }
    }
    

    Every five seconds, it looks for sagas that have blown past their deadline. When it finds one, it marks it timed out, triggers compensation for whatever steps already completed, publishes a Spring event for logging and alerting, and records the metric.

    Timeout behavior is configurable per service:

    saga:
      timeout:
        enabled: true
        check-interval: 5000          # Check every 5 seconds
        default-deadline: 30000       # 30-second default timeout
        auto-compensate: true         # Auto-trigger compensation
        max-batch-size: 100           # Process up to 100 timeouts per check
        saga-types:
          OrderFulfillment: 60000     # 60 seconds for order fulfillment
    

    The Order Fulfillment saga gets 60 seconds — longer than the 30-second default — because it spans four services and includes payment processing, which can be slow. Choosing the right timeout value is harder than it sounds, and we have quite a bit more to say about that in the next article.


    Monitoring Sagas

    The SagaMetrics class exposes counters and timers to Prometheus:

    Metric What It Tells You
    saga.started How many sagas are being created
    saga.completed How many succeed end-to-end
    saga.compensated How many required rollback
    saga.timedout How many exceeded their deadline
    saga.duration (p95) How long sagas are taking
    saga.compensation.duration (p95) How long compensation takes

    The pre-provisioned Saga Dashboard in Grafana visualizes active saga counts, throughput rates by saga type, duration percentiles, success rates, timeout detection rates, and compensation breakdowns. Pre-configured alerts fire on high failure rates, timeouts, compensation failures, and success rate drops below 90%.

    The dashboard is useful. But the metric that actually saved us during a sustained load incident was dead simple: saga.timedout plotted alongside saga.completed. When timeouts exceeded completions, we knew we had a systemic problem, not just a few slow sagas. More on that story next time.


    Design Decisions and Trade-offs

    Eventual Consistency

    Sagas provide eventual consistency, not immediate consistency. Between the time stock is reserved and payment is processed, the system is in a “pending” state. That’s intentional — the trade-off buys us service independence and availability. For our use case, the consistency window is sub-second. For domains where that’s not acceptable (financial settlement, medical records), you’d need stronger guarantees.

    Idempotency

    Saga listeners must be idempotent. ITopic delivery is at-most-once in Hazelcast, but the timeout detector might trigger compensation for a saga that was actually processing — just slowly. If the original flow completes after compensation starts, the system has to handle the overlap gracefully.

    SagaState handles this with updateOrAddStep, which replaces by step number. A step can’t be recorded twice.

    Compensation Is Not Rollback

    This is worth being explicit about, because the terminology invites confusion.

    Compensation undoes the business effect, not the technical state. When we “refund a payment,” we don’t delete the payment record. We create a new PaymentRefundedEvent that changes the payment status to REFUNDED. The event history preserves the full story: payment was processed, then refunded. If an auditor comes asking, the trail is complete.

    This fits naturally with event sourcing. The compensation is just another event. Nothing is erased, nothing is pretended away. The system records what actually happened, including the part where something went wrong and was corrected.


    Summary

    The saga pattern solves distributed transaction coordination without distributed locks:

    Choreographed sagas fit naturally with event sourcing — each service reacts to events independently, no coordinator required. Orchestrated sagas earn their place when flows grow complex, need synchronous responses, or require per-step control. Compensation provides rollback-like semantics through new events, preserving the full history. Timeout detection catches stuck sagas. Saga state tracking gives you a complete audit trail.

    The result is a system where four independent services coordinate a complex business transaction without any service knowing about the others — they only know about events. The cost is that you’re managing a distributed state machine instead of a database transaction. But the alternative — distributed locks held across network calls while a coordinator decides everyone’s fate — is worse.


    Next up: Saga Timeouts — When Distributed Things Go Wrong

    Previous: Materialized Views for Fast Queries

    Code: github.com/myawnhc/hazelcast-microservices-framework — clone it, docker-compose up, and the framework boots locally with sample data.