JSON Serialization (Spring)
Kafka itself only moves bytes, so to ship structured domain objects between services you need a serializer on the producer and a matching deserializer on the consumer. Spring for Apache Kafka ships JsonSerializer and JsonDeserializer, which use Jackson to convert your Java objects to and from JSON, plus a small but important set of headers and configuration that make typed payloads safe and convenient. Getting this right is what separates a demo from a production pipeline: a single deserialization exception on a poison message can otherwise wedge a consumer in an infinite retry loop.
How Spring Kafka resolves the target type
When JsonSerializer writes a record, it adds a __TypeId__ header containing the fully qualified class name of the payload (for example com.devcraftly.orders.OrderEvent). On the consumer side, JsonDeserializer reads that header and uses it to pick the class to deserialize into. This means a single listener can receive different concrete types and Spring will materialize the correct one automatically.
Because the header carries an arbitrary class name, deserializing it blindly would be a remote-code-execution risk. Spring guards against this with trusted packages: it refuses to instantiate any type whose package is not on an allow-list. You configure that with spring.json.trusted.packages (or programmatically via addTrustedPackages). Use a specific package, or * only in tightly controlled internal environments.
DTO and configuration
Define the event as a Java record. Records serialize cleanly with Jackson in Spring Boot 3.x.
package com.devcraftly.orders;
import java.math.BigDecimal;
import java.time.Instant;
public record OrderEvent(String orderId, String customerId,
BigDecimal amount, Instant placedAt) {
}
Configure both sides in application.yml. The producer uses JsonSerializer for values; the consumer wraps JsonDeserializer in an ErrorHandlingDeserializer so a malformed record cannot kill the container.
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
consumer:
group-id: order-processor
auto-offset-reset: earliest
key-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
properties:
spring.deserializer.key.delegate.class: org.apache.kafka.common.serialization.StringDeserializer
spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JsonDeserializer
spring.json.trusted.packages: "com.devcraftly.orders"
spring.json.value.default.type: com.devcraftly.orders.OrderEvent
spring.json.value.default.type tells the deserializer which class to use when the __TypeId__ header is absent — useful for messages produced by non-Spring clients. When the header is present and you set spring.json.use.type.headers: false, Spring ignores the header and always uses the default type, which is handy when producers and consumers disagree on package names.
Key configuration properties
| Property | Side | Purpose |
|---|---|---|
spring.json.add.type.headers | Producer | Whether to emit __TypeId__ (default true) |
spring.json.trusted.packages | Consumer | Allow-list of packages safe to deserialize |
spring.json.value.default.type | Consumer | Fallback type when no type header is present |
spring.json.use.type.headers | Consumer | Honor __TypeId__ (default true) |
spring.json.type.mapping | Both | Map logical names to classes across services |
spring.deserializer.value.delegate.class | Consumer | Real deserializer wrapped by ErrorHandlingDeserializer |
The
ErrorHandlingDeserializerdoes not throw on bad data. Instead it passes anullvalue plus aDeserializationExceptionin the record headers to your error handler (such as aDefaultErrorHandlerwith a dead-letter recoverer). Without it, a single unparseable record is retried forever and blocks the partition.
Producer and consumer
package com.devcraftly.orders;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
@Service
public class OrderPublisher {
private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
public OrderPublisher(KafkaTemplate<String, OrderEvent> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void publish(OrderEvent event) {
kafkaTemplate.send("orders", event.orderId(), event);
}
}
package com.devcraftly.orders;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
@Component
public class OrderConsumer {
@KafkaListener(topics = "orders", groupId = "order-processor")
public void onOrder(OrderEvent event) {
System.out.printf("Received order %s for customer %s: %s%n",
event.orderId(), event.customerId(), event.amount());
}
}
Because JsonDeserializer resolves the concrete type, the listener method can declare OrderEvent directly — no manual parsing. Sending one event prints:
Output:
Received order ORD-1001 for customer CUST-42: 149.99
Cross-service type mapping
When two services own the same logical event under different package names, hard-coding fully qualified class names couples them. Use spring.json.type.mapping to bind a stable logical token to each side’s local class.
# producer
spring.json.type.mapping: order:com.shop.api.OrderEvent
# consumer
spring.json.type.mapping: order:com.devcraftly.orders.OrderEvent
The producer now writes __TypeId__: order, and the consumer maps order to its own class — decoupling the wire contract from internal package structure.
Best Practices
- Always wrap value (and key) deserializers in
ErrorHandlingDeserializerso poison messages route to a dead-letter topic instead of blocking the partition. - Scope
spring.json.trusted.packagesto your real DTO packages; avoid*on internet-facing or multi-tenant consumers. - Prefer immutable Java records for events — they document the contract and serialize predictably with Jackson.
- Use
spring.json.type.mappinginstead of raw class names so producers and consumers can evolve package structures independently. - Set
spring.json.value.default.typewhen consuming from non-Spring producers that do not emit__TypeId__. - Add only optional fields to evolve schemas safely, and configure Jackson with
FAIL_ON_UNKNOWN_PROPERTIES=falseso new producer fields do not break older consumers. - For strong cross-language schema guarantees, consider a Schema Registry with Avro or Protobuf instead of free-form JSON.