Skip to content
Apache Kafka kf serialization 4 min read

JSON Serialization

JSON is the most popular value format in Kafka pipelines because it is human-readable, every language can parse it, and your domain objects map onto it with almost no ceremony. The catch is that plain JSON carries no schema: the bytes on the topic describe the data but nothing enforces what shape the next producer will send. This page shows how to serialize JSON with Jackson — both by hand and through Spring Kafka’s built-in JsonSerializer — and then explains the schemaless risk that pushes serious systems toward JSON-with-Schema-Registry.

A Jackson-based serializer and deserializer

At the wire level, JSON serialization is just “turn the object into a UTF-8 byte array of JSON, and parse it back.” Jackson’s ObjectMapper does both. Implementing the Kafka Serializer<T> and Deserializer<T> interfaces directly gives you full control over the mapper configuration — registering the JavaTime module, ignoring unknown fields, and so on.

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.apache.kafka.common.errors.SerializationException;
import org.apache.kafka.common.serialization.Deserializer;
import org.apache.kafka.common.serialization.Serializer;

public class JacksonSerializer<T> implements Serializer<T> {

    private final ObjectMapper mapper = new ObjectMapper()
            .registerModule(new JavaTimeModule());

    @Override
    public byte[] serialize(String topic, T data) {
        if (data == null) {
            return null;
        }
        try {
            return mapper.writeValueAsBytes(data);
        } catch (Exception e) {
            throw new SerializationException("Failed to serialize value for topic " + topic, e);
        }
    }
}

public class JacksonDeserializer<T> implements Deserializer<T> {

    private final ObjectMapper mapper = new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

    private final Class<T> type;

    public JacksonDeserializer(Class<T> type) {
        this.type = type;
    }

    @Override
    public T deserialize(String topic, byte[] data) {
        if (data == null) {
            return null;
        }
        try {
            return mapper.readValue(data, type);
        } catch (Exception e) {
            throw new SerializationException("Failed to deserialize value from topic " + topic, e);
        }
    }
}

The event itself is a plain Java record — immutable, concise, and Jackson-friendly out of the box.

import java.time.Instant;

public record OrderPlaced(String orderId, String customerId, long amountCents, Instant placedAt) {}

Spring Kafka’s JsonSerializer

Spring for Apache Kafka ships org.springframework.kafka.support.serializer.JsonSerializer and JsonDeserializer, which wrap Jackson and integrate with the auto-configured KafkaTemplate and listener container. You rarely need a hand-rolled serializer in a Spring app — configure the built-ins in application.yml instead.

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-service
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      properties:
        spring.json.trusted.packages: "com.devcraftly.orders.events"
        spring.json.value.default.type: com.devcraftly.orders.events.OrderPlaced

Producing and consuming then look like ordinary typed code:

import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;

@Service
public class OrderEventService {

    private final KafkaTemplate<String, OrderPlaced> template;

    public OrderEventService(KafkaTemplate<String, OrderPlaced> template) {
        this.template = template;
    }

    public void publish(OrderPlaced event) {
        template.send("orders", event.orderId(), event);
    }

    @KafkaListener(topics = "orders")
    public void handle(OrderPlaced event) {
        System.out.println("Received order " + event.orderId());
    }
}

Type headers and the trusted-packages guard

By default JsonSerializer writes the fully-qualified Java class name into a record header (__TypeId__), and JsonDeserializer reads that header to decide which class to instantiate. This is convenient inside a single Java codebase but couples your topic to Java types and breaks non-Java consumers that do not understand the header.

You can suppress the header and pin the target type explicitly:

JsonSerializer<OrderPlaced> serializer = new JsonSerializer<>();
serializer.setAddTypeInfo(false); // do not emit __TypeId__

JsonDeserializer<OrderPlaced> deserializer = new JsonDeserializer<>(OrderPlaced.class);
deserializer.setUseTypeHeaders(false); // ignore __TypeId__, always use OrderPlaced

Because JsonDeserializer can instantiate whatever class the __TypeId__ header names, leaving spring.json.trusted.packages open (*) is a deserialization-gadget risk. Always restrict it to the packages that hold your event types.

The schemaless risk

Plain JSON has no enforced contract. Nothing on the broker stops a producer from renaming amountCents to amount, changing its type from a number to a string, or dropping a required field. Consumers only discover the breakage at runtime — often as a flood of deserialization exceptions or, worse, silently wrong data when a field quietly defaults to null.

ConcernPlain JSONJSON + Schema Registry
Schema enforced on writeNoYes (compatibility check)
Cross-team compatibilityManual / by conventionValidated automatically
Payload sizeLarger (field names repeated)Same JSON, plus a schema-id prefix
Schema evolution rulesNoneBACKWARD / FORWARD / FULL
Best forInternal, fast-moving, single teamShared topics, many consumers

When JSON-with-Schema-Registry is better

If a topic is consumed by more than one team, or you need to evolve events safely over time, register a JSON Schema in the Confluent Schema Registry and use KafkaJsonSchemaSerializer. Producers are then rejected at publish time if a payload violates the registered schema or breaks the configured compatibility mode, turning runtime surprises into build-and-publish failures. You keep JSON’s readability while gaining the same governance Avro and Protobuf provide.

Best Practices

  • Use plain JSON only for internal, single-team topics where speed of iteration outweighs strict governance.
  • Always quote and configure a shared ObjectMapper (JavaTime module, FAIL_ON_UNKNOWN_PROPERTIES = false) so additive changes do not break consumers.
  • Restrict spring.json.trusted.packages to your event packages — never leave it as *.
  • Disable type headers (setAddTypeInfo(false)) when non-Java services read the topic.
  • Model events as immutable Java records to keep payloads predictable and serialization deterministic.
  • Move shared, multi-consumer topics to JSON-with-Schema-Registry (or Avro/Protobuf) once a contract needs to be enforced and evolved.
Last updated June 1, 2026
Was this helpful?