Skip to content
Apache Kafka kf spring 4 min read

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

PropertySidePurpose
spring.json.add.type.headersProducerWhether to emit __TypeId__ (default true)
spring.json.trusted.packagesConsumerAllow-list of packages safe to deserialize
spring.json.value.default.typeConsumerFallback type when no type header is present
spring.json.use.type.headersConsumerHonor __TypeId__ (default true)
spring.json.type.mappingBothMap logical names to classes across services
spring.deserializer.value.delegate.classConsumerReal deserializer wrapped by ErrorHandlingDeserializer

The ErrorHandlingDeserializer does not throw on bad data. Instead it passes a null value plus a DeserializationException in the record headers to your error handler (such as a DefaultErrorHandler with 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 ErrorHandlingDeserializer so poison messages route to a dead-letter topic instead of blocking the partition.
  • Scope spring.json.trusted.packages to 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.mapping instead of raw class names so producers and consumers can evolve package structures independently.
  • Set spring.json.value.default.type when 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=false so 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.
Last updated June 1, 2026
Was this helpful?