Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Enterprise Integration Patterns

The vocabulary of system integration: routers, splitters, aggregators, content enrichers and the claim check, implemented with Apache Camel and Spring Integration.

Advanced5 min readUpdated
On this page

Enterprise Integration Patterns gave the industry a shared vocabulary for connecting systems. The value today is less in the implementations — brokers and frameworks provide those — than in the names, which let a team describe a design precisely in a sentence.

Key Takeaways

  • Content-based router sends a message to a destination chosen by its content.
  • Splitter and aggregator decompose a composite message and reassemble the results.
  • Content enricher adds data a message lacks by calling elsewhere.
  • Claim check stores a large payload externally and passes a reference.
  • Aggregators need a completion condition and a timeout, or they leak.

The patterns worth naming

PatternWhat it does
Content-Based RouterChooses a destination from message content
Message FilterDiscards messages that do not match
SplitterBreaks a composite message into parts
AggregatorCombines related messages into one
ResequencerRestores order to out-of-sequence messages
Scatter-GatherBroadcasts, then collects responses
Content EnricherAdds missing data from an external source
Content FilterRemoves unneeded or sensitive fields
Claim CheckStores the payload, passes a reference
NormalizerConverts varied formats to a canonical one
Dead Letter ChannelWhere undeliverable messages go

One caution about the vocabulary before reaching for a framework: naming a pattern is not the same as needing an engine to implement it. A content-based router is an if on a field. A splitter is a loop. Camel earns its keep when you have many routes across varied transports and genuinely want its error handling, redelivery and adapters — not when a single consumer branches three ways, where it adds a DSL, a runtime and a debugging experience your team does not yet have.

Routing and splitting with Camel

OrderRoutes.java
@Component
public class OrderRoutes extends RouteBuilder {
 
    @Override
    public void configure() {
 
        // Errors are part of the design, not an afterthought.
        errorHandler(deadLetterChannel("kafka:orders.dlq")
                .maximumRedeliveries(3)
                .redeliveryDelay(1000)
                .backOffMultiplier(2)
                .retryAttemptedLogLevel(LoggingLevel.WARN));
 
        // Content-based router
        from("kafka:incoming-orders")
            .routeId("order-router")
            .unmarshal().json(OrderMessage.class)
            .choice()
                .when(simple("${body.totalMinorUnits} > 100000"))
                    .to("direct:high-value")
                .when(simple("${body.region} == 'EU'"))
                    .to("direct:eu-processing")
                .otherwise()
                    .to("direct:standard");
 
        // Splitter with an aggregation strategy: one message per line, then
        // recombine the per-line results into one summary.
        from("direct:high-value")
            .split(simple("${body.lines}"), new OrderLineAggregationStrategy())
                .parallelProcessing()
                .to("direct:process-line")
            .end()
            .to("kafka:orders-processed");
 
        // Content enricher: the message lacks customer detail, so fetch it.
        from("direct:process-line")
            .enrich("direct:fetch-customer", (original, resource) -> {
                OrderLine line = original.getIn().getBody(OrderLine.class);
                Customer customer = resource.getIn().getBody(Customer.class);
                original.getIn().setBody(line.enrichedWith(customer));
                return original;
            })
            .to("bean:pricingService?method=calculate");
    }
}

The choice block is a content-based router, and its readability is the argument for a routing DSL: the whole decision is visible in six lines rather than distributed across a service class.

parallelProcessing() on the splitter is worth noting — splits are independent by definition, so processing them concurrently is usually free throughput.

Aggregation

Split, process independently, aggregate by correlation id. The timeout branch is what stops partial groups accumulating in memory.
Aggregation.java
from("kafka:line-results")
    .aggregate(header("orderId"), new OrderResultAggregationStrategy())
        .completionSize(header("expectedLineCount"))
        // Without this, a lost message means this group is held forever.
        .completionTimeout(30_000)
        .aggregationRepository(persistentRepository)   // survives a restart
    .to("direct:finalise-order");

Two production requirements that get missed. The timeout bounds memory — an aggregator with only a size condition holds incomplete groups indefinitely when one part never arrives. And a persistent repository means a restart does not lose in-flight groups; the in-memory default silently discards them.

Spring Integration

IntegrationFlows.java
@Configuration
public class OrderIntegrationConfig {
 
    @Bean
    public IntegrationFlow orderProcessingFlow() {
        return IntegrationFlow
                .from(Kafka.messageDrivenChannelAdapter(consumerFactory, "incoming-orders"))
                .transform(Transformers.fromJson(OrderMessage.class))
                .filter((OrderMessage order) -> !order.lines().isEmpty())
                .route(OrderMessage::region, mapping -> mapping
                        .subFlowMapping("EU", sub -> sub.channel("euChannel"))
                        .subFlowMapping("US", sub -> sub.channel("usChannel"))
                        .defaultOutputChannel("standardChannel"))
                .get();
    }
 
    @Bean
    public IntegrationFlow claimCheckFlow(ObjectStorage storage) {
        return IntegrationFlow.from("largePayloads")
                // Brokers are optimised for small messages. Store the payload,
                // pass a reference, and let the consumer fetch it.
                .handle((payload, headers) -> {
                    String key = storage.put((byte[]) payload);
                    return MessageBuilder.withPayload(new PayloadReference(key))
                            .copyHeaders(headers).build();
                })
                .channel("processedPayloads")
                .get();
    }
}

The claim check is worth adopting whenever payloads exceed a few hundred kilobytes. A 50MB message on a queue degrades throughput for everything sharing that broker, and a reference plus an object-store fetch is both faster and cheaper.

Do you need a framework?

Be honest about the threshold. Two systems exchanging JSON over HTTP need a client class, not a routing engine. The framework earns its cost when there are many endpoints, several protocols, and routing rules that would otherwise be scattered across services with no single place to read them.

What you gain is a declarative description of the integration, 300+ prebuilt components, and error handling as a first-class concern. What you pay is a DSL your team must learn, stack traces that go through framework internals, and testing that needs its own harness.

A reasonable rule: if you can describe the integration in one clear paragraph, write the code. If it takes a diagram, the diagram is probably a Camel route.

Testing routes

OrderRoutesTest.java
@CamelSpringBootTest
@MockEndpoints("kafka:*")
class OrderRoutesTest {
 
    @Autowired ProducerTemplate producer;
    @EndpointInject("mock:kafka:orders-processed") MockEndpoint processed;
 
    @Test
    void routesHighValueOrdersSeparately() throws Exception {
        processed.expectedMessageCount(1);
        processed.expectedHeaderReceived("priority", "HIGH");
 
        producer.sendBody("direct:incoming", highValueOrder());
 
        processed.assertIsSatisfied(5_000);
    }
}

Mock endpoints let you assert on what reached a destination without a broker running, and assertIsSatisfied with a timeout handles the asynchrony without a sleep.

What to take away

Learn the pattern names — they make integration designs describable in a sentence. Use a content-based router to keep decisions in one readable place, always give aggregators a completion timeout and a persistent repository, and reach for the claim check before large payloads reach a broker. Then check whether you need a framework at all.

Frequently Asked Questions

Camel or Spring Integration?
Camel for breadth — 300+ components covering almost every protocol and SaaS API, and a DSL built for routing. Spring Integration when you are already deep in Spring and the integration is modest; it composes naturally with the rest of the context. Both implement the same patterns.
Do I need an integration framework at all?
Not for two systems exchanging JSON over HTTP — that is a client class. A framework earns its place when you have many endpoints, several protocols, and routing logic that would otherwise be scattered across services. Below that threshold it is abstraction without payoff.
How does an aggregator know when it is done?
A completion condition, plus a timeout. The condition is usually a known count carried in a header — three of three splits received. The timeout handles the case where one never arrives, and without it the aggregator holds partial groups in memory forever.

Related tutorials