The most common build request we get is some version of the same sentence: "we need a REST API in front of this." Behind "this" is usually a SOAP service written in 2011, a JMS queue, a mainframe file drop, or all three. Camel is a good fit for that job — it speaks those protocols natively and the mediation logic stays in one place. It is also easy to build the façade in a way that works in the demo and falls apart in production. Here is how we approach it.
Specify the contract before you write a route
The REST DSL makes it tempting to start coding:
rest("/orders")
.get("/{id}").to("direct:getOrder")
.post().type(OrderRequest.class).to("direct:createOrder");
That reads well, and for a small internal API it is fine. The problem arrives when the API has consumers you do not control. The shape of the contract then becomes a negotiation artifact, and a contract that only exists as generated output from route code is a contract nobody can review before it ships.
So for anything with external consumers we go contract-first: write the OpenAPI document, review it with the consuming team, and then generate the REST DSL skeleton from it. Camel supports both directions — camel-openapi-java will expose a generated spec from your REST DSL definitions, and the generator tooling will produce route stubs from an existing document. Pick one direction and make it the source of truth. The failure we see repeatedly is teams doing both: hand-edited spec, hand-written routes, and a slow drift between them that surfaces as a consumer integration bug six months later.
A contract-first document is also where you settle the things that are painful to change later: media types, pagination, date formats, the error envelope, and whether PUT is idempotent in fact as well as in principle.
Choose the HTTP component deliberately
The REST DSL is a façade over a real component. restConfiguration().component("...") decides which one, and the choice has consequences:
platform-http— the servlet or reactive endpoint provided by the runtime (Spring Boot, Quarkus). This is the default answer when Camel runs inside an application you already deploy and monitor as a web app. Health probes, TLS, and metrics come from the runtime's own stack.netty-http/undertow— standalone HTTP without a servlet container. Useful for slim deployments, but you now own the TLS, thread-pool, and timeout configuration yourself.servlet— classic deployment into an existing container, still common in estates that have not moved off application servers.
On the client side, camel-http sitting on Apache HttpClient is the usual choice for calling downstream services. Whichever you pick: set connect and read timeouts explicitly. An unbounded read timeout on a downstream call is the single most effective way to turn one slow back end into a fully wedged API tier.
Bridging SOAP without leaking it
The SOAP side is usually camel-cxf, in one of two modes. POJO mode binds to generated JAX-WS classes and is comfortable if the WSDL is stable and the generated model is sane. PAYLOAD mode hands you the SOAP body as XML and lets you transform it directly — which is what we reach for when the WSDL is enormous, when regenerating stubs on every schema change is a burden, or when you only touch three fields out of two hundred.
The part that takes discipline is not the plumbing, it is the boundary. A REST façade that returns the SOAP fault string, the vendor namespace, or a field named SAP_CUST_NR_X has not decoupled anything; it has just changed the transport. Every consumer you gain is a consumer coupled to a system you were trying to hide. Define the outward-facing model on its own terms, map to it explicitly, and accept that the mapping code is the product.
Two specifics worth writing down:
- Namespaces and XPath. If you transform SOAP payloads with XPath or XSLT, declare namespaces properly. Namespace-unaware XPath that happens to work against one server's output is a defect waiting for a vendor upgrade.
MTOMand large payloads. Attachments and multi-megabyte bodies belong in streaming paths, not in a full in-memory DOM. Check this before someone uploads a 40 MB document and the heap graph tells you about it.
Error mapping is part of the contract
A façade has at least three classes of failure, and they are not the same HTTP status:
- The caller sent something invalid. Validate against the schema at the edge —
.type()binding plus a JSON schema or XSD check — and return400with a body that says which field. Do it at the edge so bad input never reaches the back end. - The back end rejected a valid request. A SOAP fault for "customer not found" is a
404, not a500. Map known fault codes deliberately; a catch-all that turns every fault into500destroys the caller's ability to handle errors correctly and generates support tickets that land on you. - The back end is unavailable or slow. This is
503or504, and it is the case where retry behaviour matters. Retry only idempotent operations. For a non-idempotentPOSTto a back end that may have already processed the request, retrying is how you create duplicate orders — pair it with an idempotent consumer keyed on a caller-supplied request id instead.
In Camel terms: onException clauses that set Exchange.HTTP_RESPONSE_CODE and a consistent error body, plus a final catch-all that logs with correlation context and returns a generic 500 without echoing internal exception text to the caller. Write the error envelope into the OpenAPI document like any other response.
The failure modes that only appear under load
A façade that passes functional tests can still fail badly in production. The ones we see most:
- Thread starvation. Synchronous HTTP-in to synchronous HTTP-out means one request occupies a thread for the whole downstream call. A back end that slows from 50 ms to 5 s does not slow your API by 100x — it exhausts the pool and takes down every endpoint, including the fast ones. Size pools against downstream latency, use separate pools or bulkheads per back end, and consider Camel's asynchronous routing engine or a circuit breaker (
camel-resilience4j) at the boundary. - Streaming caches and re-reads. Bodies read from a stream are consumed once. If a route logs the body, then routes on it, then sends it, enable stream caching or convert to a re-readable type — and know that stream caching spills large bodies to disk, which has its own capacity implications.
- Unbounded payload sizes. Set a maximum request size at the edge. Without one, your memory ceiling is set by whoever calls you.
- Missing correlation. Propagate a correlation id from the inbound request through the SOAP call and into the logs and traces. Without it, "one customer got a 500 at 14:32" is an unanswerable question. With OpenTelemetry instrumentation on both hops it is a two-minute lookup.
Testing the boundary
The façade's job is translation, so test the translation. AdviceWith lets you replace the CXF endpoint with a mock and assert the outbound mapping; Testcontainers or a WireMock stub gives you a real HTTP hop for the client side, including the timeout and fault cases that mocks quietly skip. Add at least one test per mapped fault code — error mapping is the part that rots first, because it is the part nobody exercises by hand.
When Camel is not the right answer
Honest caveat: if you need one REST endpoint calling one REST back end with no mediation, an API gateway rule or a small service is less machinery than a Camel application. Camel earns its place when there is real mediation — protocol translation, multiple back ends aggregated per request, format conversion, delivery guarantees, or routing logic that changes independently of the callers. The moment the sentence "we need a REST API in front of this" has a this that spans two or more protocols, Camel's REST DSL plus its component set is usually the least-effort path that still holds up at 3 a.m.
If you are putting a REST layer over a legacy estate and want the contract, error mapping, and load behaviour reviewed by someone who has watched a few of these go wrong, that is exactly the shape of work our Camel implementation and architecture review engagements cover.