Camel Saga Pattern: Long-Running Transaction Compensation

A saga breaks a long-running business transaction into a sequence of local steps, each with a compensating action that undoes its effect if a later step fails. Apache Camel provides first-class saga support via the camel-saga component: you declare each step with its compensation route, and Camel triggers compensation automatically on failure. This avoids distributed locks while still achieving eventual consistency.

Contents

Dependency & Coordinator

Add the following to your pom.xml (Maven) or build.gradle (Gradle). Spring Boot manages compatible versions automatically when you use the Spring Boot BOM.

XML
<dependency>
  <groupId>org.apache.camel.springboot</groupId>
  <artifactId>camel-saga-starter</artifactId>
</dependency>
Java
@Configuration
public class SagaConfig {

    // NaiveLRACoordinator — in-memory, suitable for single-node / dev
    // Use camel-lra with a real coordinator (Narayana) for production multi-node
    @Bean
    public CamelSagaService sagaService() {
        InMemorySagaService service = new InMemorySagaService();
        return service;
    }
}

Basic Saga Route

The .saga() DSL marks the beginning of a saga. Each .compensation("uri") call registers the route to invoke if the saga is cancelled.

Java
@Component
public class OrderSagaRoute extends RouteBuilder {

    @Override
    public void configure() {

        // Main saga — each step declares its compensation
        from("direct:placeOrder")
            .saga()
                .timeout(Duration.ofMinutes(5))     // cancel saga if not complete in 5min
            .log("Starting order saga for ${body.orderId}")

            // Step 1 — Reserve inventory
            .to("direct:reserveInventory")

            // Step 2 — Charge payment
            .to("direct:chargePayment")

            // Step 3 — Create shipment
            .to("direct:createShipment")

            .log("Order saga completed successfully");
    }
}

Compensation Routes

Each participant route that modifies state must declare a compensation route. Camel invokes it automatically when the saga is cancelled due to a downstream failure.

Java
@Component
public class InventoryRoute extends RouteBuilder {

    @Override
    public void configure() {

        // Step — reserve inventory, declare its compensation
        from("direct:reserveInventory")
            .saga()
                .propagation(SagaPropagation.MANDATORY)  // must be part of an existing saga
                .compensation("direct:releaseInventory")  // called if saga fails
                .option("reservationId", header("reservationId"))  // pass data to compensation
            .bean(inventoryService, "reserve")
            .log("Inventory reserved: ${header.reservationId}");

        // Compensation — release the reservation
        from("direct:releaseInventory")
            .log("Compensating: releasing reservation ${header.reservationId}")
            .bean(inventoryService, "release");

        // Payment step with its compensation
        from("direct:chargePayment")
            .saga()
                .propagation(SagaPropagation.MANDATORY)
                .compensation("direct:refundPayment")
                .option("transactionId", header("transactionId"))
            .bean(paymentService, "charge")
            .log("Payment charged: ${header.transactionId}");

        from("direct:refundPayment")
            .log("Compensating: refunding transaction ${header.transactionId}")
            .bean(paymentService, "refund");
    }
}

Saga Options & Propagation

PropagationBehaviour
REQUIRED (default)Join existing saga or create new one
REQUIRES_NEWAlways create a new saga, suspend existing
MANDATORYMust join an existing saga; throw if none active
SUPPORTSJoin existing saga if present; run without saga otherwise
NOT_SUPPORTEDRun outside any saga
NEVERThrow if a saga is active
Java
// Pass saga-scoped data via .option() — available in compensation routes
from("direct:chargePayment")
    .saga()
        .propagation(SagaPropagation.MANDATORY)
        .compensation("direct:refundPayment")
        // Store headers for compensation to use (saga-scoped, not just exchange-scoped)
        .option("amount",    header("amount"))
        .option("accountId", header("accountId"))
    .to("direct:paymentGateway");

Saga Timeout

The class below shows the implementation. Key points are highlighted in the inline comments.

Java
from("direct:placeOrder")
    .saga()
        .timeout(Duration.ofMinutes(2))   // saga auto-cancels after 2 minutes
    .to("direct:reserveInventory")
    .to("direct:chargePayment")
    .to("direct:createShipment");

// On timeout, Camel triggers compensation for all completed steps automatically
Saga timeout is measured from the point .saga() is entered. If any step is slow (e.g., waiting on an external service), the timeout fires and triggers compensation for all steps that already completed, preventing partial saga completion from being left in place indefinitely.

Completion & Compensation Callbacks

The class below shows the implementation. Key points are highlighted in the inline comments.

Java
from("direct:placeOrder")
    .saga()
        .completion("direct:onOrderComplete")      // called on successful saga finish
        .compensation("direct:onOrderCancelled")   // called on saga failure/timeout
    .to("direct:reserveInventory")
    .to("direct:chargePayment")
    .to("direct:createShipment");

// Saga-level completion handler — send customer notification
from("direct:onOrderComplete")
    .log("Saga completed — notifying customer")
    .to("direct:sendConfirmationEmail");

// Saga-level compensation handler — log and alert
from("direct:onOrderCancelled")
    .log(LoggingLevel.ERROR, "Saga cancelled — order ${header.orderId} rolled back")
    .to("direct:sendCancellationEmail");

Full Order Saga Example

The class below shows the implementation. Key points are highlighted in the inline comments.

Java
@Component
public class FullOrderSaga extends RouteBuilder {

    @Override
    public void configure() {
        onException(Exception.class)
            .handled(true)
            .log(LoggingLevel.ERROR, "Saga step failed: ${exception.message}")
            .to("direct:sagaFailure");

        // Orchestrator
        from("direct:placeOrder")
            .saga().timeout(Duration.ofMinutes(5))
            .setHeader("orderId", simple("${body.orderId}"))
            .to("direct:reserveInventory")   // step 1
            .to("direct:chargePayment")      // step 2
            .to("direct:scheduleDelivery")   // step 3
            .log("Order ${header.orderId} saga complete");

        // Inventory
        from("direct:reserveInventory")
            .saga().propagation(SagaPropagation.MANDATORY)
                   .compensation("direct:releaseInventory")
            .bean("inventoryService", "reserve");

        from("direct:releaseInventory")
            .bean("inventoryService", "release");

        // Payment
        from("direct:chargePayment")
            .saga().propagation(SagaPropagation.MANDATORY)
                   .compensation("direct:refundPayment")
                   .option("chargeId", header("chargeId"))
            .bean("paymentService", "charge");

        from("direct:refundPayment")
            .bean("paymentService", "refund");

        // Delivery
        from("direct:scheduleDelivery")
            .saga().propagation(SagaPropagation.MANDATORY)
                   .compensation("direct:cancelDelivery")
            .bean("deliveryService", "schedule");

        from("direct:cancelDelivery")
            .bean("deliveryService", "cancel");
    }
}

MicroProfile LRA Integration

For production multi-node deployments, replace the in-memory coordinator with a real LRA coordinator (Narayana) via camel-lra.

XML
<dependency>
  <groupId>org.apache.camel.springboot</groupId>
  <artifactId>camel-lra-starter</artifactId>
</dependency>
YAML
# application.yml
camel:
  lra:
    coordinator-url: http://lra-coordinator:8080    # Narayana LRA coordinator
    local-participant-url: http://payment-service:8080  # this service's callback URL
    local-participant-context-path: /lra-participant