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

PropertyConstantDescription
spring.json.trusted.packagesTRUSTED_PACKAGESComma-separated packages to trust. Use * for all.
spring.json.value.default.typeVALUE_DEFAULT_TYPEFully qualified default class if no type header present
spring.json.type.mappingTYPE_MAPPINGSLogical name → class mappings (comma-separated)
spring.json.use.type.headersUSE_TYPE_INFO_HEADERSWhether to read __TypeId__ header (default: true)

Common Mistakes

Forgetting trusted packagesJsonDeserializer 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

  • JsonSerializer adds a __TypeId__ header with the full class name by default — this breaks cross-service deserialization
  • Use TYPE_MAPPINGS on 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_PACKAGES explicitly — never use * in production unless all packages are internal
  • For polymorphic events, use Jackson’s @JsonTypeInfo + @JsonSubTypes on 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.