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
JsonDeserializercan instantiate whatever class the__TypeId__header names, leavingspring.json.trusted.packagesopen (*) 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.
| Concern | Plain JSON | JSON + Schema Registry |
|---|---|---|
| Schema enforced on write | No | Yes (compatibility check) |
| Cross-team compatibility | Manual / by convention | Validated automatically |
| Payload size | Larger (field names repeated) | Same JSON, plus a schema-id prefix |
| Schema evolution rules | None | BACKWARD / FORWARD / FULL |
| Best for | Internal, fast-moving, single team | Shared 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.packagesto 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.