Not every consumer needs every field or data given on the message.
An enriched order flowing through your system might carry a dozen fields: customer ID, product name, product category, weight, shipping zone, hazmat flag, and more. That is useful for the fulfillment service. But the analytics pipeline only needs the order ID, the SKU, the quantity, the amount, the destination country, and the status. More important sensitive data should not be exposed to external services. Sending the full order to analytics wastes bandwidth, increases storage costs, and — more importantly — risks leaking sensitive data like customer identifiers or internal fields that external systems should never see.
The Content Filter pattern, described by Hohpe and Woolf, strips unwanted fields from a message before forwarding it to a downstream consumer. It is the inverse of the Content Enricher: where the enricher adds data from an external source, the filter removes data that the receiver does not need.
Content filters serve four purposes:
- Security — Remove internal fields before sending to external systems.
- Privacy — Strip personally identifiable information before sending to analytics or logging.
- Efficiency — Reduce message size when downstream consumers need only a subset of fields.
- Compatibility — Remove fields that an older consumer does not understand.
Apache Camel implements content filtering through process() blocks that operate on the message body.
The approach is straightforward — iterate over the fields in the message and keep only the ones that belong in the output.
Testing a content filter is deceptively simple. The test sends a message with many fields and asserts that the output contains only the allowed ones. But the real value of an integration test is proving what is absent. A field-by-field assertion that checks only the expected fields will pass even if the filter accidentally lets extra fields through. A template-based validation in Citrus validates the entire JSON structure, catching both missing fields and unexpected ones.
The scenario
Enriched orders arrive on the eip.orders.enriched topic with the full set of fields: order details, product catalog data, hazmat flags, and customer references.
The content filter route reads each order, applies an allowlist of fields safe for the analytics pipeline, and publishes the stripped-down order to eip.orders.analytics.
Allowlist vs. blocklist
There are two ways to implement a content filter:
- Allowlist — Only specified fields pass. Everything else is dropped. New fields added to the source schema are automatically excluded.
- Blocklist — Specified fields are removed. Everything else passes through. New fields added to the source schema automatically pass through.
The two approaches have different safety properties. An allowlist is safer for security-sensitive outputs: you cannot accidentally leak a field you forgot to block, because only explicitly listed fields survive. A blocklist is more convenient when you want most fields and only need to exclude a few specific ones.
This example uses an allowlist — the right choice when the output goes to an analytics pipeline that should never receive customer identifiers or internal operational fields.
The Camel route
Quarkus
@ApplicationScoped
public class ContentFilterRoute extends RouteBuilder {
private static final Set<String> ALLOWED_FIELDS = Set.of(
"order_id", "item_sku", "quantity", "amount",
"destination_country", "shipping_priority", "status"
);
@Override
public void configure() {
from("kafka:eip.orders.enriched?brokers={{kafka.brokers}}&groupId=filter-demo")
.routeId("content-filter")
.unmarshal().json()
.log("Filtering PII from order ${body[order_id]}")
.process(exchange -> {
var order = exchange.getIn().getBody(Map.class);
var filtered = new LinkedHashMap<String, Object>();
for (var entry : ((Map<String, Object>) order).entrySet()) {
if (ALLOWED_FIELDS.contains(entry.getKey())) {
filtered.put(entry.getKey(), entry.getValue());
}
}
exchange.getIn().setBody(filtered);
})
.log("Filtered: ${body}")
.marshal().json()
.to("kafka:eip.orders.analytics?brokers={{kafka.brokers}}");
}
}
The route has three stages:
-
Deserialize —
unmarshal().json()converts the Kafka message bytes into a JavaMap. -
Filter — The
process()block iterates over every field in the order and copies only those present inALLOWED_FIELDSinto a new map. Fields likecustomer_id,contains_hazmat,product_name,product_category,weight_kg, andshipping_zoneare silently dropped. -
Serialize and publish —
marshal().json()converts the filtered map back to JSON and theto()endpoint publishes it to the analytics topic.
The allowlist is defined as a static final Set<String>.
This makes it easy to review in code review and easy to test — the set is the single source of truth for what survives the filter.
Spring Boot
@Component
public class ContentFilterRoute extends RouteBuilder {
private static final Set<String> ALLOWED_FIELDS = Set.of(
"order_id", "item_sku", "quantity", "amount",
"destination_country", "shipping_priority", "status"
);
@Override
public void configure() {
from("kafka:eip.orders.enriched?brokers={{kafka.brokers}}&groupId=filter-demo")
.routeId("content-filter")
.unmarshal().json()
.log("Filtering PII from order ${body[order_id]}")
.process(exchange -> {
var order = exchange.getIn().getBody(Map.class);
var filtered = new LinkedHashMap<String, Object>();
for (var entry : ((Map<String, Object>) order).entrySet()) {
if (ALLOWED_FIELDS.contains(entry.getKey())) {
filtered.put(entry.getKey(), entry.getValue());
}
}
exchange.getIn().setBody(filtered);
})
.log("Filtered: ${body}")
.marshal().json()
.to("kafka:eip.orders.analytics?brokers={{kafka.brokers}}");
}
}
@Component replaces @ApplicationScoped — the route logic is identical.
Test templates
The test uses two JSON templates that make the filtering behavior visible at a glance.
The input template is the enriched order — a message with the full set of fields including product catalog data and customer references:
{
"order_id": "ORD-org.citrusframework:citrus-website:pom:1.1.0",
"customer_id": "CLIENT-org.citrusframework:citrus-website:pom:1.1.0",
"item_sku": "${sku}",
"quantity": ${quantity},
"amount": ${amount},
"destination_country": "${country}",
"contains_hazmat": ${hazardous},
"status": "NEW",
"product_name": "${productName}",
"product_category": "${productCategory}",
"weight_kg": "${weightKg}",
"shipping_zone": "${shippingZone}"
}
This template contains twelve fields.
The content filter should strip five of them: customer_id, contains_hazmat, product_name, product_category, weight_kg, and shipping_zone.
The expected output template is the filtered order — only the fields allowed by the route’s allowlist:
{
"order_id": "ORD-org.citrusframework:citrus-website:pom:1.1.0",
"item_sku": "${sku}",
"quantity": ${quantity},
"amount": ${amount},
"destination_country": "${country}",
"status": "NEW"
}
Six fields survive. Placing these two templates side by side is the clearest possible documentation of what the filter does — no prose needed.
Why template-based validation matters for content filters
A content filter’s correctness has two dimensions:
-
The right fields are present. The output must contain
order_id,item_sku,quantity,amount,destination_country, andstatus. -
The wrong fields are absent. The output must not contain
customer_id,contains_hazmat,product_name,product_category,weight_kg, orshipping_zone.
Field-by-field assertions naturally cover the first dimension.
But they are blind to the second.
A test that only asserts the six expected fields will pass even if customer_id leaks through — because it never checks for the absence of fields it does not expect.
Citrus’s template-based validation covers both dimensions. When the test receives a message and validates it against the filtered order template, it compares the entire JSON structure. If the actual output contains a field that the template does not, the assertion fails. This makes the template a structural contract: everything in the template must be present, and nothing outside the template is allowed.
For a content filter — especially one whose purpose is security or privacy — proving what is absent is more important than proving what is present.
Test infrastructure
The Docker Compose stack includes Kafka and Redis (Redis is used by the content enricher in the same example project; the content filter test only needs Kafka).
Quarkus infrastructure setup
@CitrusConfiguration
public class EipInfraSetup implements TestActionSupport {
@BindToRegistry
public BeforeSuite startInfra() {
return beforeSuite().actions(
testcontainers().compose()
.up("_infra/compose.yaml")
.containerName("eip-infra")
.autoRemove(false),
waitFor()
.http()
.url("http://localhost:8090")
.seconds(25)
).build();
}
@BindToRegistry
public AfterSuite stopInfra() {
return afterSuite().actions(
camel().camelContext().stop(),
testcontainers().compose()
.down()
.containerName("eip-infra")
).build();
}
}
Spring Boot infrastructure setup
@Configuration
public class EipInfraSetup implements TestActionSupport {
@Bean
public BeforeSuite startInfra() {
return beforeSuite().actions(
testcontainers().compose()
.up("_infra/compose.yaml")
.containerName("eip-infra")
.autoRemove(false),
waitFor()
.http()
.url("http://localhost:8090")
.seconds(25)
).build();
}
@Bean
public AfterSuite stopInfra() {
return afterSuite().actions(
camel().camelContext().stop(),
testcontainers().compose()
.down()
.containerName("eip-infra")
).build();
}
}
The lifecycle pattern is the same as in the other transformation examples: Citrus’s testcontainers() DSL brings up the Docker Compose stack, waitFor().http() blocks until the infrastructure is ready, and afterSuite tears everything down after the tests complete.
The content filter test
Quarkus test
@QuarkusTest
@CitrusSupport
class EipTests implements EipTestSupport {
@CitrusResource
TestCaseRunner t;
@Inject
@BindToRegistry
CamelContext camelContext;
@Nested
class ContentFilterTest {
@Test
public void shouldStripNonAllowedFieldsFromOrder() {
t.given(
createVariables()
.variable("id", "citrus:randomNumber(4)")
.variable("sku", "SKU-DEF-77")
.variable("quantity", 1)
.variable("amount", 89.99)
.variable("country", "GB")
.variable("hazardous", false)
.variable("productName", "Running Shoes")
.variable("productCategory", "Footwear")
.variable("weightKg", "1.2")
.variable("shippingZone", "ZONE-2")
);
t.given(waitForCamelRouteStarted("content-filter", camelContext));
t.when(
send()
.endpoint("kafka:eip.orders.enriched")
.message()
.fork(true)
.body(Resources.create("templates/enriched-order.json"))
.header(KafkaMessageHeaders.MESSAGE_KEY, "org.citrusframework:citrus-website:pom:1.1.0")
);
t.then(
receive()
.endpoint("kafka:eip.orders.analytics?consumerGroup=citrus-analytics-group")
.message()
.body(Resources.create("templates/filtered-order.json"))
);
}
}
}
The test defines ten variables, but the filtered output template only uses six of them. This asymmetry is the test’s assertion in disguise.
Given — set up variables and wait for the route.
The first six variables (id, sku, quantity, amount, country, hazardous) describe the core order.
The last four (productName, productCategory, weightKg, shippingZone) are product enrichment fields that should not survive the filter.
These four variables are needed to populate the input template (the enriched order), but they should be absent from the output (the filtered order).
The filtered order template does not reference ${productName}, ${productCategory}, ${weightKg}, or ${shippingZone} at all.
It also does not reference ${hazardous}, because contains_hazmat and customer_id are stripped by the allowlist.
When — send the enriched order.
The test sends a fully enriched order to kafka:eip.orders.enriched — all twelve fields are present in the message body.
This simulates the output of the content enricher route upstream in the pipeline.
Then — receive and validate the filtered order.
The test consumes from kafka:eip.orders.analytics and validates the body against the filtered order template.
Citrus compares the entire JSON structure: the six fields in the template must be present with the correct values, and no additional fields are allowed.
If the route’s ALLOWED_FIELDS set accidentally included customer_id, the actual output would contain seven fields while the template expects six — the assertion would fail.
If a developer later added a new field to the enriched order (say, supplier_cost) without updating the filter’s allowlist, the field would be automatically excluded by the allowlist approach and the test would continue to pass.
But if the same developer used a blocklist and forgot to add supplier_cost to the exclusion list, the field would leak through and the template assertion would catch it.
The fork=true option on the send operation avoids a blocking situation and handles the asynchronous processing:
the enricher needs time to consume the message, call the Redis lookup route, merge the results, and publish the enriched order.
While all of that is done the receiving operation initializes the Kafka consumer with a proper offset.
There is no racing condition between the order processing and the consumer initialization.
Spring Boot test
@SpringBootTest(classes = TransformationApplication.class)
@CamelSpringBootTest
@CitrusSpringSupport
@ContextConfiguration(classes = { EipInfraSetup.class, CitrusSpringConfig.class })
class EipTests implements EipTestSupport {
@Autowired
CamelContext camelContext;
@Nested
class ContentFilterTest {
@CitrusResource
TestCaseRunner t;
@Test
public void shouldStripNonAllowedFieldsFromOrder() {
t.given(
createVariables()
.variable("id", "citrus:randomNumber(4)")
.variable("sku", "SKU-DEF-77")
.variable("quantity", 1)
.variable("amount", 89.99)
.variable("country", "GB")
.variable("hazardous", false)
.variable("productName", "Running Shoes")
.variable("productCategory", "Footwear")
.variable("weightKg", "1.2")
.variable("shippingZone", "ZONE-2")
);
t.given(waitForCamelRouteStarted("content-filter", camelContext));
t.when(
send()
.endpoint("kafka:eip.orders.enriched")
.message()
.fork(true)
.body(Resources.create("templates/enriched-order.json"))
.header(KafkaMessageHeaders.MESSAGE_KEY, "org.citrusframework:citrus-website:pom:1.1.0")
);
t.then(
receive()
.endpoint("kafka:eip.orders.analytics?consumerGroup=citrus-analytics-group")
.message()
.body(Resources.create("templates/filtered-order.json"))
);
}
}
}
The test logic is identical. The differences are confined to the framework annotations:
| Concern | Quarkus | Spring Boot |
|---|---|---|
| Test bootstrap | @QuarkusTest |
@SpringBootTest + @CamelSpringBootTest
|
| Citrus integration | @CitrusSupport |
@CitrusSpringSupport |
| CamelContext injection |
@Inject + @BindToRegistry
|
@Autowired |
| TestCaseRunner scope | Class-level field | Nested class field with @CitrusResource
|
| Infrastructure config |
@CitrusConfiguration auto-discovered |
@ContextConfiguration explicit |
The content filter in the transformation pipeline
The content filter does not operate in isolation. In the example project, it is part of a three-stage transformation pipeline:
-
Message Translator — Reads orders from
eip.orders.externalin the partner’s format, translates them to the canonical format, and publishes toeip.orders.placed. -
Content Enricher — Reads from
eip.orders.placed, looks up product data from Redis, merges the fields, and publishes toeip.orders.enriched. -
Content Filter — Reads from
eip.orders.enriched, strips non-allowed fields, and publishes toeip.orders.analytics.
Each test in the suite is independent — it sends directly to the route’s input topic and receives from the route’s output topic. But the templates form a chain: the enriched order template that the content filter test uses as input is the same template that the content enricher test uses as expected output. This consistency ensures that the templates accurately represent the data flowing between stages.
Testing each stage independently rather than testing the full pipeline end-to-end has a practical advantage: when a test fails, you know exactly which stage broke. If the content filter test fails, the problem is in the filter’s allowlist or its processing logic — not in the translator or the enricher upstream.
Evolving the allowlist safely
Content filters have a maintenance risk: the allowlist can become stale when the upstream schema evolves.
If a new field is added to the enriched order (say, estimated_delivery), the filter will automatically exclude it — which is the safe default for a security-oriented allowlist, but might not be the desired behavior for the analytics consumer.
The integration test acts as a safety net for this evolution.
When you add estimated_delivery to the enriched order schema:
- If the analytics consumer should receive it, update the allowlist in the route and add the field to the filtered order template. The test will fail until both are updated, ensuring they stay in sync.
- If the analytics consumer should not receive it, do nothing. The allowlist excludes it automatically, and the template-based assertion confirms the field is absent.
This is the advantage of an allowlist over a blocklist: new fields default to excluded, and you must explicitly opt in. With a blocklist, new fields default to included, and you must remember to add them to the exclusion list — a task that is easy to forget and whose failure is silent until someone audits the data flow.
Key takeaways
- Template comparison validates both presence and absence. A filtered order template that contains six fields implicitly asserts that the other six fields from the input are stripped. Field-by-field assertions cannot make this guarantee.
- Allowlists are safer than blocklists for security-sensitive filters. New fields are automatically excluded by an allowlist. A blocklist requires manual updates whenever the upstream schema adds a field — a maintenance burden that is easy to overlook.
-
Input variables that don’t appear in the output template are the assertion. The test defines
${productName},${productCategory},${weightKg}, and${shippingZone}to populate the input, but the output template never references them. Their absence from the output template is what proves the filter works. - Independent stage tests pinpoint failures. Testing the content filter in isolation — sending to its input topic and receiving from its output topic — means a failure points directly to the filter, not to the translator or enricher upstream.
- Two runtimes, one test pattern. The test logic is identical across Quarkus and Spring Boot. The allowlist, the templates, and the given-when-then structure are runtime-agnostic — only the framework annotations differ.