+1 (850) 665-2441

Sagas in Camel: Compensation for Work That Can't Be Rolled Back

A route books the shipment, charges the customer, then fails to update the warehouse system. Three systems, three separate commits, one half-finished business transaction. There is no rollback() that reaches across an HTTP call to a carrier API. This is the problem the Saga pattern exists for, and Camel ships an EIP for it. Whether you should use that EIP is a different question, and worth asking before you add a coordinator to your runtime.

Our post on transactions in Camel covered the case where a database and a broker are involved and local transactions plus an outbox get you home. Sagas are for the case where they do not: remote calls, external side effects, work that is already visible to someone else by the time the next step fails.

What a saga actually is

A saga is a sequence of local transactions where each step publishes its own compensating action. If step four fails, the coordinator runs the compensations for steps three, two, and one, in reverse. You do not get atomicity. You get eventual consistency with an explicit, auditable cleanup path.

That distinction matters because compensation is not rollback. Rollback makes it as if the work never happened. Compensation is a new business action that offsets the old one: a refund, not an un-charge; a cancellation notice, not an un-booking. If your business cannot express the offsetting action — you cannot un-send an email, un-ship a pallet, un-file a regulatory report — a saga will not save you. Reorder the steps so the irreversible one goes last, or accept a manual exception queue.

The Camel Saga EIP

Camel's saga() definition marks a route as a saga participant. Each participant declares what to call if the saga is compensated, and optionally what to call when it completes successfully.

from("direct:bookShipment")
    .saga()
        .propagation(SagaPropagation.REQUIRED)
        .option("orderId", simple("${header.orderId}"))
        .compensation("direct:cancelShipment")
        .completion("direct:confirmShipment")
    .to("rest:post:/carrier/bookings")
    .log("booked ${body}");

from("direct:cancelShipment")
    .log("compensating booking for order ${header.orderId}")
    .to("rest:delete:/carrier/bookings/${header.orderId}");

Three details carry most of the weight:

  • option(...) is your compensation payload. The compensating route does not receive the original exchange. It receives the options you declared, and nothing else. If cancelling needs a carrier booking reference that only exists after the call, set it as a saga option after the call, not before.
  • propagation behaves like Spring's. REQUIRED joins an in-flight saga or starts one; REQUIRES_NEW always starts a nested one; MANDATORY, SUPPORTS, NEVER do what their names say. Getting propagation wrong is the usual cause of "my compensation ran for the wrong scope."
  • completion is not an afterthought. It is where you release reservations, delete staged files, and close out holds on the happy path. A saga with compensations but no completions leaks state quietly for months.

Composition is the real benefit: an orchestrating route calls three participant routes, each of which registers its own compensation, and the coordinator handles the unwinding order. You are not writing a bespoke undo tree per flow.

In-memory coordinator vs. LRA

Out of the box, camel-saga uses an in-memory coordinator. It is fine for a single JVM and useless if that JVM dies mid-saga: the compensation list disappears with the heap. Use it for prototypes, single-process orchestrations with short durations, and tests.

For anything that must survive a restart, camel-lra plugs Camel into a MicroProfile LRA coordinator (Narayana LRA is the usual choice). The coordinator persists saga state and drives compensation even if your participant pods are replaced mid-flight. The cost is real and should be priced in:

  • Another stateful service to deploy, back up, monitor, and upgrade.
  • Your participants must be reachable by the coordinator at a stable callback URL. On Kubernetes that means a Service per participant and some care with network policy.
  • Saga timeouts become an operational parameter you will tune in production. Too short and in-flight work gets compensated out from under a slow downstream; too long and stuck sagas pile up invisibly.

Decide which coordinator you need before you write the routes, because the failure semantics you are allowed to promise depend entirely on that choice.

Compensations are the hard part

The EIP is small. The engineering is in the compensating routes, and they have requirements the happy path does not.

They must be idempotent. A coordinator will retry a compensation it is not sure completed. Cancelling an already-cancelled booking must return success, not a 404 that the coordinator reads as failure and retries forever. The same reasoning as idempotent consumers, applied to the undo side.

They must tolerate "never happened." Compensation can fire for a step that failed partway through. Write them to assert the desired end state — "this order has no active booking" — rather than to perform a specific inverse operation.

They must not themselves fail silently. A failed compensation is a business inconsistency that no retry will fix. Route it to a dedicated dead letter channel with enough context for a human, and alert on it separately from ordinary route errors. The count of failed compensations is one of the few integration metrics worth paging on.

They need their own tests. AdviceWith plus mock endpoints will let you force a failure at step three and assert that exactly the right compensations ran, in the right order, with the right options. If you only test the happy path, you have untested code whose entire purpose is to run during an incident.

When not to use a saga

Plenty of multi-step flows do not need one:

  • Two steps, one of them a database write. Local transaction plus the outbox pattern. Simpler, cheaper, fully recoverable.
  • Steps that are all retryable and order-independent. If step three failing just means retrying step three until it works, you need a durable queue and a dead letter channel, not compensation. Most file-and-queue orchestrations are this.
  • A flow where the business already has a manual exception process. If operations reconciles exceptions daily anyway, a saga coordinator may add runtime complexity to automate a path that is exercised twice a month. Make the failure visible and let the existing process work.
  • Migrations where the legacy ESB used a real XA transaction. Do not reach for a saga as a like-for-like replacement without checking the actual guarantee the business depends on. Often it is weaker than the implementation suggests, and the honest answer is a reconciliation job.

The rule we apply: use a saga when a multi-step flow has remote side effects, a compensating business action genuinely exists, and the inconsistency window has a real cost. Otherwise, retries plus a dead letter channel and a reconciliation report will outlive the fancier design.

A short checklist

  • Can every step be compensated by a real business action? If not, reorder so the irreversible step is last.
  • In-memory or LRA? Pick based on whether saga state must survive a pod restart.
  • Are compensation options captured after the call that generates the identifiers?
  • Is every compensating route idempotent and end-state-asserting?
  • Do failed compensations go somewhere a human will see, with an alert of their own?
  • Does the test suite force a mid-saga failure and assert the unwind?

If you are weighing a saga against a simpler recovery design for an estate in production, that is exactly the trade-off an integration architecture review is meant to settle — with the failure modes written down rather than argued at a whiteboard.