Files
spring-messaging-demo/kafka-basics/docs/03-serialisation.md

3.7 KiB

← Producing · Module README · Keys and partitions →

3. Serialisation: two Jackson families, and the one your IDE will suggest is the wrong one

Kafka moves byte[]. Everything else is a Serializer and a Deserializer, and under Spring Boot 4 there is a fork in the road that no tutorial mentions yet.

Spring Kafka 4.1 ships two complete JSON families:

Class Jackson Mapper type
JsonSerializer / JsonDeserializer / JsonSerde 2.x com.fasterxml.jackson.databind.ObjectMapper
JacksonJsonSerializer / JacksonJsonDeserializer / JacksonJsonSerde 3.x tools.jackson.databind.json.JsonMapper

Spring Boot 4 is a Jackson 3 application. spring-boot-starter-jackson brings tools.jackson.core:jackson-databind 3.1.5. But the class named JsonSerializer — the one every existing example configures, and the one autocomplete offers first — is the Jackson 2 one.

It does not fail at startup. It fails on the first payload containing a java.time value:

org.apache.kafka.common.errors.SerializationException: Can't serialize data
  [OrderEvent[orderId=o-1, ..., placedAt=2026-08-29T10:15:30Z]] for topic [orders]
Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
  Java 8 date/time type `java.time.Instant` not supported by default:
  add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling

The message tells you to add a Jackson 2 module, and doing so works — and leaves you running two Jackson stacks, one for your HTTP layer and one for your messaging layer, with independent configuration. The better fix is one word:

spring:
  kafka:
    producer:
      value-serializer: org.springframework.kafka.support.serializer.JacksonJsonSerializer
    consumer:
      value-deserializer: org.springframework.kafka.support.serializer.JacksonJsonDeserializer

Jackson 3 handles java.time with no module and no configuration, and BigDecimal keeps its scale through a round trip — both asserted in SerialisationTest:

{"orderId":"o-1","customerId":"c-1","amount":10.00,"placedAt":"2026-08-29T10:15:30Z"}

amount is 10.00, not 10.0 and not "10.00". Use BigDecimal for money and this survives; use double and it does not.

The type header

The three-argument serialize(topic, headers, data) overload writes a __TypeId__ header naming the class:

headers.lastHeader("__TypeId__")  ->  "com.ankurm.kafkabasics.OrderEvent"

The consumer reads it and builds that class. Which is convenient and is also a remote-code- selection primitive, so the deserializer refuses any class outside its trusted packages:

... is not in the trusted packages: [java.util, java.lang]

Three ways out, in descending order of preference:

  1. spring.json.trusted.packages: com.ankurm.kafkabasics — an allow-list of your own packages.
  2. spring.json.value.default.type — ignore the header, always build this class. Best when producer and consumer are owned by different teams, because it makes the consumer's contract its own decision rather than the producer's.
  3. spring.json.trusted.packages: "*" — do not.

Option 2 has a second benefit worth stating plainly: the __TypeId__ header couples the two services by fully qualified class name. Renaming or moving a class in the producer breaks every consumer that trusts the header, at runtime, with no compile-time warning. type.mapping (a logical name to class map on both sides) is the version of this that survives a refactor.

Keys and partitions →