JSON Serialization: JsonSerializer, JsonDeserializer, and Type Mapping
The Serialization Problem
Kafka stores bytes. KafkaTemplate<String, OrderPlacedEvent> needs to turn your Java object into bytes for the producer, and @KafkaListener needs to turn those bytes back into the right Java class on the consumer. Spring Kafka ships JsonSerializer and JsonDeserializer built on Jackson to handle this — but they have several sharp edges that break in real multi-service deployments.
How Spring Kafka JSON Serialization Works
flowchart LR
subgraph Producer["Order Service"]
Event["OrderPlacedEvent"] -->|"JsonSerializer"| Bytes["bytes + __TypeId__ header"]
end
subgraph Broker["Kafka"]
Bytes --> Topic["orders topic"]
end
subgraph Consumer["Inventory Service"]
Topic -->|"JsonDeserializer"| Deserialized["OrderPlacedEvent\nor mapped type"]
end
By default, JsonSerializer adds a __TypeId__ header containing the fully-qualified class name. JsonDeserializer reads this header and instantiates the correct class. This breaks immediately when producer and consumer are in different services with different package names.
Basic Configuration (application.properties)
# Producer
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer
# Consumer
spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer
spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.JsonDeserializer
spring.kafka.consumer.properties.spring.json.trusted.packages=com.example.events
spring.kafka.consumer.properties.spring.json.value.default.type=com.example.inventory.events.OrderPlacedEvent
trusted.packages is a security guard — JsonDeserializer refuses to instantiate classes from untrusted packages. Set it to your event package, or * (trust all, not recommended in production).
The TypeId Header Problem
sequenceDiagram
participant OrderSvc as "Order Service\ncom.example.order"
participant Kafka
participant InvSvc as "Inventory Service\ncom.example.inventory"
OrderSvc->>Kafka: send event\n__TypeId__: com.example.order.events.OrderPlacedEvent
Kafka->>InvSvc: deliver record
InvSvc->>InvSvc: look up com.example.order.events.OrderPlacedEvent
Note over InvSvc: ClassNotFoundException!\nClass is in a different package
Solution 1 — Type mapping: Map the producer’s class name to the consumer’s class name.
Solution 2 — Ignore the TypeId header: Tell the deserializer to always use a specific target class, ignoring the header entirely.
Solution 1: Type Mapping
On the producer, set a logical type name instead of the full class name:
@Bean
public ProducerFactory<String, OrderPlacedEvent> producerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
// use a logical name, not the full class name
props.put(JsonSerializer.TYPE_MAPPINGS,
"orderPlaced:com.example.order.events.OrderPlacedEvent");
return new DefaultKafkaProducerFactory<>(props);
}
On the consumer, map the same logical name to the local class:
props.put(JsonDeserializer.TYPE_MAPPINGS,
"orderPlaced:com.example.inventory.events.OrderPlacedEvent");
props.put(JsonDeserializer.TRUSTED_PACKAGES, "com.example.inventory.events");
Now __TypeId__: orderPlaced resolves correctly on both sides regardless of package names.
Solution 2: Ignore TypeId Header (Simplest for Single Consumer)
When you always want OrderPlacedEvent regardless of what the header says:
@Bean
public ConsumerFactory<String, OrderPlacedEvent> consumerFactory() {
JsonDeserializer<OrderPlacedEvent> deserializer =
new JsonDeserializer<>(OrderPlacedEvent.class);
deserializer.addTrustedPackages("*");
deserializer.ignoreTypeHeaders(); // ignore __TypeId__ header
return new DefaultKafkaConsumerFactory<>(
Map.of(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"),
new StringDeserializer(),
deserializer
);
}
ignoreTypeHeaders() disables the header lookup entirely — the deserializer always produces OrderPlacedEvent. Best when one consumer always expects one type.
Full @Bean Configuration
@Configuration
public class KafkaSerializationConfig {
@Bean
public ProducerFactory<String, Object> producerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
props.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, true);
props.put(JsonSerializer.TYPE_MAPPINGS,
"orderPlaced:com.example.order.events.OrderPlacedEvent," +
"orderCancelled:com.example.order.events.OrderCancelledEvent");
return new DefaultKafkaProducerFactory<>(props);
}
@Bean
public ConsumerFactory<String, Object> consumerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class);
props.put(JsonDeserializer.TRUSTED_PACKAGES, "com.example.inventory.events");
props.put(JsonDeserializer.TYPE_MAPPINGS,
"orderPlaced:com.example.inventory.events.OrderPlacedEvent," +
"orderCancelled:com.example.inventory.events.OrderCancelledEvent");
return new DefaultKafkaConsumerFactory<>(props);
}
}
Polymorphic Events on One Topic
When a topic carries multiple event types, use a sealed interface or base class:
// Shared event contract (can live in a shared library)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "eventType")
@JsonSubTypes({
@JsonSubTypes.Type(value = OrderPlacedEvent.class, name = "PLACED"),
@JsonSubTypes.Type(value = OrderCancelledEvent.class, name = "CANCELLED"),
@JsonSubTypes.Type(value = OrderShippedEvent.class, name = "SHIPPED")
})
public abstract class OrderEvent {
private String orderId;
private Instant occurredAt;
}
Configure the consumer factory for the base type:
JsonDeserializer<OrderEvent> deserializer = new JsonDeserializer<>(OrderEvent.class);
deserializer.addTrustedPackages("com.example.events");
The listener receives the concrete type:
@KafkaListener(topics = "orders")
public void onOrderEvent(OrderEvent event) {
switch (event) {
case OrderPlacedEvent e -> inventoryService.reserveStock(e);
case OrderCancelledEvent e -> inventoryService.releaseStock(e);
case OrderShippedEvent e -> notificationService.notifyShipped(e);
default -> log.warn("Unknown event type: {}", event.getClass());
}
}
JsonDeserializer Configuration Reference
| Property | Constant | Description |
|---|---|---|
spring.json.trusted.packages | TRUSTED_PACKAGES | Comma-separated packages to trust. Use * for all. |
spring.json.value.default.type | VALUE_DEFAULT_TYPE | Fully qualified default class if no type header present |
spring.json.type.mapping | TYPE_MAPPINGS | Logical name → class mappings (comma-separated) |
spring.json.use.type.headers | USE_TYPE_INFO_HEADERS | Whether to read __TypeId__ header (default: true) |
Common Mistakes
Forgetting trusted packages — JsonDeserializer throws IllegalArgumentException: The class ... is not in the trusted packages at runtime. Always set TRUSTED_PACKAGES explicitly.
Using full class names across services — produces ClassNotFoundException. Use logical type mappings.
Not disabling ADD_TYPE_INFO_HEADERS on the producer — if your consumer ignores type headers, the producer still sends them. This is harmless but adds bytes to every message; disable with props.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false).
Key Takeaways
JsonSerializeradds a__TypeId__header with the full class name by default — this breaks cross-service deserialization- Use
TYPE_MAPPINGSon both producer and consumer to decouple class names from the wire format ignoreTypeHeaders()is the simplest fix when a consumer always expects one type- Set
TRUSTED_PACKAGESexplicitly — never use*in production unless all packages are internal - For polymorphic events, use Jackson’s
@JsonTypeInfo+@JsonSubTypeson a base class and deserialize to the base type in the listener
Next: Avro Serialization with Confluent Schema Registry — enforce schema compatibility across services using Avro schemas and Confluent Schema Registry.