+1 (850) 665-2441

Transactions in Camel: Local Transactions, the Outbox Pattern, and the XA Question

Here is a route shape we find in almost every estate we review:

from("jms:queue:orders")
    .bean(orderService, "persist")
    .to("kafka:order-events");

Read it as an operations person, not as a developer. It consumes a message, writes a row, and publishes an event. Three resources, three failure points, and no statement anywhere about what the system looks like if the process dies between the second and the third. That gap is a dual write, and dual writes are the quiet source of the reconciliation tickets that show up months later as "the database says the order exists but downstream never heard about it."

Camel does not make this problem worse. It makes it visible, because the resources are named in the route. What follows is how to close it — starting with the cheapest option, because the cheapest option is usually enough.

Step one: know your transaction boundary

A Camel exchange is not a transaction. Nothing in the framework implicitly ties the consumer's acknowledgement to the work your processors do. You opt in, and you opt in per route:

from("jms:queue:orders")
    .transacted()
    .bean(orderService, "persist")
    .to("jms:queue:order-events");

transacted() hands the route to a Spring PlatformTransactionManager. With a JmsTransactionManager and a JMS consumer configured for transacted sessions, the consume, the send, and any JDBC work sharing the same transaction manager commit or roll back together. On rollback, the broker redelivers — which is exactly the behaviour you want, and exactly the behaviour that makes your route's idempotency story mandatory rather than optional. (We wrote about the mechanics of that in Idempotent Consumers in Camel.)

Two details decide whether this works in practice:

  • The transaction manager must actually span the resources you think it spans. A JmsTransactionManager and a DataSourceTransactionManager in the same application are two independent transactions. Committing one does not commit the other. Teams are routinely surprised by this because the code reads as though it is one unit of work.
  • The consumer must be transacted too. A JMS consumer with transacted=false, or a Spring Boot DefaultJmsListenerContainerFactory left on its default, acknowledges on receipt. The route's rollback then rolls back nothing that matters.

Step two: collapse the dual write if you can

Before reaching for distributed transaction machinery, ask whether the route needs two resources at all.

One resource, one transaction. If the broker and the state both live in the same database — a work table plus a business table — a single local JDBC transaction gives you atomicity for free. Not every integration can be arranged this way. More can than teams assume.

Reverse the order and make the second step retryable. If the event publish is the thing that might fail, and the downstream consumer is idempotent, publishing after commit with a durable retry is often correct. The risk you accept is a delayed event, not a lost one — provided the retry is durable. A try/catch that logs the failure is not durable retry; it is a lost event with a paper trail.

Make the consumer pull instead. Sometimes the cleanest fix is to stop publishing and let downstream poll a query or a change feed. Fewer moving parts, no dual write.

When none of these apply and the write plus the publish genuinely must be atomic, you have two honest options left.

Step three: the transactional outbox

The outbox pattern converts a dual write into a single local transaction plus a relay. In one JDBC transaction the route writes both the business row and a row in an outbox table. A separate Camel route polls the outbox, publishes each row to the broker, and marks it sent.

from("jms:queue:orders")
    .transacted()
    .bean(orderService, "persistOrderAndOutboxRow");

from("sql:SELECT * FROM outbox WHERE sent_at IS NULL ORDER BY id"
   + "?consumer.onConsume=UPDATE outbox SET sent_at = now() WHERE id = :#id")
    .to("kafka:order-events");

What you get: the business state and the intent to publish commit atomically, so you can never have the row without the event. What you accept:

  • At-least-once publication. A crash between the send and the onConsume update republishes on the next poll. This is not a defect to engineer away; it is the deal. The downstream consumer deduplicates on a business key or an outbox id.
  • Ordering is yours to manage. Poll by primary key, and if per-entity ordering matters, either keep the relay single-threaded or partition it by entity key. Two relay threads racing the same table will reorder events cheerfully.
  • A second relay instance will double-publish unless you lock. SELECT ... FOR UPDATE SKIP LOCKED in the consumer query, or a leader election, or accept the duplicates the downstream already tolerates. Decide explicitly; do not discover it during a rolling deploy.
  • Table growth. The outbox needs a retention job from day one. An unpruned outbox table is a slow-motion outage.

The outbox costs you a table, a relay route, and a monitoring signal (oldest unsent row age — alert on it). In return, the atomicity question has a written answer. For most estates that trade is worth taking.

Step four: XA, and when it is still right

XA two-phase commit across a broker and a database is not dead, and dismissing it reflexively is as lazy as reaching for it reflexively. Camel supports it: a JTA transaction manager (Narayana, Atomikos, or the app server's) behind transacted(), XA-capable connection factories and data sources, and the resource managers coordinate the commit.

XA is worth considering when:

  • Both resources genuinely support XA — classic JMS brokers and relational databases do. Kafka does not participate in XA. Kafka's transactions cover Kafka topics and consumer offsets only; there is no two-phase commit joining a Kafka publish to a JDBC insert. If your route touches Kafka and a database atomically, the outbox is the pattern, not XA.
  • You are migrating from a commercial ESB whose flows relied on XA, and reproducing those semantics keeps the parallel-run reconciliation clean. Changing delivery semantics during a migration means every diff has two possible causes.
  • The operational cost is understood: recovery logs need durable, node-stable storage, in-doubt transactions need a human procedure, and throughput drops because every commit is two round trips.

What XA does not give you is relief from idempotency. Heuristic outcomes and in-doubt branches exist, and a resource can still be committed twice in a genuine failure. XA narrows the window; it does not remove it.

What we look for in a review

When we audit an estate, the transaction questions are short and the answers are usually missing:

  1. Which routes touch more than one resource? That list is the dual-write inventory.
  2. For each: what happens on a crash between resource one and resource two — and how would anyone find out?
  3. Are consumers transacted, or acknowledging on receipt?
  4. Where redelivery happens, what makes the route safe to run twice?
  5. Is there a monitored signal for stuck work — unsent outbox rows, in-doubt XA branches, growing dead letter queues?

Most teams do not need XA. Most teams do need a local transaction around the consume plus the database write, an outbox for the event, an idempotent consumer downstream, and one alert on the oldest unsent row. That combination is boring, inspectable, and defensible at a whiteboard — which is the bar.

If you are staring at a route that writes to two places and cannot say what a crash in the middle does, that is the kind of finding our integration architecture reviews are built to surface.