Skip to main content

Apache Camel 4 on Spring Boot: Content-Based Routing and Dead-Letter Handling

Apache Camel’s whole pitch is that integration logic — where a message goes, what happens when processing it fails — is declared once in a route, not scattered across try/catch blocks and if-statements. This post builds a small order-intake route on Camel 4 and Spring Boot 4: content-based routing on a header, and a redelivery policy that retries a failing message twice before giving up and shunting it to a dead-letter endpoint.

The route logic itself turned out to be the easy part. The real content here is three things that only showed up by actually running it: confirming Camel 4 resolves cleanly against Spring Boot 4.1’s dependencies at all, a genuinely useful testability trick (routing to property-placeholder endpoints so a test can swap in mocks without touching the route), and a sneaky test-isolation bug in that same trick’s test harness.

Versions used in this post. Spring Boot 4.1.1 · Apache Camel 4.22.1 (camel-spring-boot-bom) · JDK 25 (Temurin). Camel 4.22.1 was the latest release on Maven Central at the time of writing, checked against the real maven-metadata.xml rather than assumed, and mvn dependency:tree confirmed it pulls in Spring Boot 4.1.1 and Spring Framework 7.0.9 with no version conflicts.

This demo is small enough that it doesn’t warrant its own repository module — every source file is linked for download right where it’s discussed below.

A dependency coordinate that isn’t quite what it looks like

Camel’s Spring Boot BOM manages a test-support artifact for exactly this kind of route test — but guessing its name from the usual Camel naming convention didn’t work:

[ERROR] 'dependencies.dependency.version' for org.apache.camel.springboot:camel-test-spring-junit5-starter:jar is missing.

Reading the BOM’s own pom.xml directly turned up the real coordinates: groupId org.apache.camel (not org.apache.camel.springboot), and artifact camel-test-spring-junit6 (not junit5) — Camel 4.22’s Spring test support targets JUnit 6, matching Spring Framework 7’s own baseline:

<dependency>
    <groupId>org.apache.camel</groupId>
    <artifactId>camel-test-spring-junit6</artifactId>
    <scope>test</scope>
</dependency>

Download: pom.xml.

The domain and the route

public record Order(String orderId, String orderType, BigDecimal amount) {
}

Download: Order.java.

@Component
public class OrderProcessor {

    private final AtomicInteger attemptCount = new AtomicInteger();

    public Order processOrder(Order order) {
        attemptCount.incrementAndGet();
        if (order.amount().compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("order " + order.orderId() + " has a non-positive amount: " + order.amount());
        }
        return order;
    }

    public int attemptCount() {
        return attemptCount.get();
    }
}

Download: OrderProcessor.java. attemptCount exists purely so a test can prove how many times the redelivery policy actually re-invoked this method — the same role ExchangeRateLookupService‘s call counter played in an earlier post in this series on distributed caching.

@Component
public class OrderRoutes extends RouteBuilder {

    private final OrderProcessor orderProcessor;

    public OrderRoutes(OrderProcessor orderProcessor) {
        this.orderProcessor = orderProcessor;
    }

    @Override
    public void configure() {
        onException(IllegalArgumentException.class)
                .maximumRedeliveries(2)
                .redeliveryDelay(0)
                .handled(true)
                .to("{{camel.route.dead-letter-out}}")
                .end();

        from("direct:orders")
                .routeId("order-intake")
                .choice()
                    .when(header("orderType").isEqualTo("PRIORITY"))
                        .bean(orderProcessor, "processOrder")
                        .to("{{camel.route.priority-out}}")
                    .otherwise()
                        .bean(orderProcessor, "processOrder")
                        .to("{{camel.route.standard-out}}")
                .end();
    }
}

Download: OrderRoutes.java · application entry point: CamelDemoApplication.java. Two Camel features in one route: choice()/when()/otherwise() is content-based routing on the orderType header, and the onException block is a redelivery policy — retry up to twice with no delay, and once retries are exhausted, mark the exception handled(true) (so it doesn’t propagate back to the caller) and route the failed exchange to a dead-letter endpoint instead.

Going deeper: why the terminal endpoints are {{property}} placeholders, not literal URIs

{{camel.route.priority-out}}, {{camel.route.standard-out}} and {{camel.route.dead-letter-out}} are Camel property placeholders, resolved from Spring’s own Environment — camel-spring-boot wires that up automatically, no extra configuration needed. The main application.properties points them at real log: endpoints; src/test/resources/application.properties overrides the same three keys to point at mock: endpoints instead. The route’s Java code — OrderRoutes.configure() — never changes between the two; only configuration does. That’s the same shape of problem this series solved with @Profile-switched CacheManager beans for distributed caching, applied here to endpoint URIs instead of whole beans, and it means testing this route never needs Camel’s heavier AdviceWith API at all.

Testing the route

@CamelSpringBootTest
@SpringBootTest(classes = CamelDemoApplication.class)
class OrderRoutesTest {

    @Autowired
    CamelContext camelContext;

    @Produce("direct:orders")
    ProducerTemplate producer;

    @EndpointInject("mock:priorityOut")
    MockEndpoint priorityOut;

    @EndpointInject("mock:standardOut")
    MockEndpoint standardOut;

    @EndpointInject("mock:deadLetterOut")
    MockEndpoint deadLetterOut;

    @Autowired
    OrderProcessor orderProcessor;

    @BeforeEach
    void resetMockEndpoints() {
        MockEndpoint.resetMocks(camelContext);
    }

    // ...
}

Download: OrderRoutesTest.java. Sending a PRIORITY-tagged order routes it to the priority mock and nowhere else:

orderType=PRIORITY -> routed to {{camel.route.priority-out}} (mock:priorityOut), 1 message
standardOut received 0 messages for this exchange

Transcript: 30-content-based-routing.txt. A STANDARD-tagged order routes the other way:

orderType=STANDARD -> routed to {{camel.route.standard-out}} (mock:standardOut), 1 message
priorityOut received 0 messages for this exchange

Transcript: 30-standard-routing.txt. And an order with a non-positive amount gets retried exactly as many times as maximumRedeliveries(2) says, then ends up on the dead-letter mock:

order ord-3 amount=-5.00 -> IllegalArgumentException on every attempt
maximumRedeliveries(2) -> processOrder actually invoked 3 times (1 original + 2 redeliveries)
after redeliveries exhausted -> handled(true), routed to {{camel.route.dead-letter-out}} (mock:deadLetterOut), 1 message

Transcript: 30-dead-letter-retry.txt. Three invocations for two configured redeliveries is correct, not an off-by-one: the first is the original attempt, and maximumRedeliveries counts only the retries on top of it.

Going deeper: the mock endpoint that remembered the previous test

The first version of this test class had no @BeforeEach reset, and its second test failed with mock://priorityOut Received message count. Expected: <0> but was: <1> — even though that test never sent a priority order at all. Spring caches one ApplicationContext across every @Test method in a class by default, which means one CamelContext and the exact same MockEndpoint instances too. The first test’s priority message was still sitting in priorityOut‘s received-message history when the second test’s assertion ran. MockEndpoint.resetMocks(camelContext), called in a @BeforeEach, clears every mock endpoint’s state before each test — the fix that actually worked, and the reason this class autowires the CamelContext at all.

direct:orders -> choice() -> two outcomes, plus one failure path orderType == “PRIORITY” bean(processOrder) -> priority-out mock:priorityOut in tests otherwise (STANDARD) bean(processOrder) -> standard-out mock:standardOut in tests processOrder() throws IllegalArgumentException onException: maximumRedeliveries(2) -> handled(true) -> dead-letter-out 3 total attempts (1 original + 2 redeliveries), then routed off, never thrown back to the caller

What this post didn’t cover

  • No real transport. Every endpoint here is direct:, mock:, or log: — in-memory or console-only. A production order-intake route would more likely start from camel-jms or camel-kafka, which this post doesn’t wire up.
  • No exponential backoff. redeliveryDelay(0) keeps the test fast; a real redelivery policy almost always wants backOffMultiplier() and a non-zero base delay so retries don’t hammer a struggling downstream immediately.
  • No route-level unit testing with AdviceWith. The property-placeholder trick above avoids needing it for this demo, but AdviceWith remains the right tool when a route’s endpoints can’t be externalized as cleanly as they were here.

When this is the right tool

NeedWhat to use
Route to different destinations based on message contentchoice()/when()/otherwise()
Retry a failing step before giving uponException().maximumRedeliveries()
Hand a message that exhausted retries to a separate pathonException().handled(true).to(...) — a dead-letter endpoint
Test a route’s endpoints without AdviceWithProperty-placeholder endpoint URIs, overridden in src/test/resources

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.