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 realmaven-metadata.xmlrather than assumed, andmvn dependency:treeconfirmed 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.
What this post didn’t cover
- No real transport. Every endpoint here is
direct:,mock:, orlog:— in-memory or console-only. A production order-intake route would more likely start fromcamel-jmsorcamel-kafka, which this post doesn’t wire up. - No exponential backoff.
redeliveryDelay(0)keeps the test fast; a real redelivery policy almost always wantsbackOffMultiplier()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
AdviceWithremains the right tool when a route’s endpoints can’t be externalized as cleanly as they were here.
When this is the right tool
| Need | What to use |
|---|---|
| Route to different destinations based on message content | choice()/when()/otherwise() |
| Retry a failing step before giving up | onException().maximumRedeliveries() |
| Hand a message that exhausted retries to a separate path | onException().handled(true).to(...) — a dead-letter endpoint |
| Test a route’s endpoints without AdviceWith | Property-placeholder endpoint URIs, overridden in src/test/resources |
Further reading
- Apache Camel documentation: Camel User Manual
- Camel error handling: onException — Camel Manual
- Camel testing with Spring Boot: Testing — Camel Spring Boot
No Comments yet!