3.1 Serialization & Data Modeling
Module Overview
Goal: Separate data correctness from producer mechanics. Learn how to properly serialize data for Kafka.
What You'll Learn:
- Why serialization matters in distributed systems
- Key vs value serialization
- Built-in serializers
- Custom serializers
- Schema evolution strategies (Avro, JSON Schema, Protobuf)
- Common serialization failures and how to avoid them
- Best practices for data modeling
What's NOT Covered Here:
- Producer configuration details (covered in Module 2.1)
- Partitioning logic (covered in Module 4.1)
- Schema Registry operations (separate topic)
Key Insight: Serialization problems are data problems, not producer problems — mixing them causes confusion.
1. Why Serialization Matters
The Fundamental Problem
Kafka stores and transmits bytes. Your application works with objects.
Application Object → [Serializer] → Bytes → Kafka
Kafka → Bytes → [Deserializer] → Application Object
Serialization: Object → Bytes (Producer side)
Deserialization: Bytes → Object (Consumer side)
What Can Go Wrong?
// Producer sends
producer.send(new ProducerRecord<>("topic", user));
// Consumer expects User but gets...?
User user = consumer.poll(); // What if format changed?
Common Issues:
- Schema changes break consumers
- Type mismatches (producer sends String, consumer expects Integer)
- Version incompatibility
- Data corruption
- Performance degradation (inefficient formats)
2. Serializer Fundamentals
The Serializer Interface
public interface Serializer<T> {
byte[] serialize(String topic, T data);
// Optional lifecycle methods
void configure(Map<String, ?> configs, boolean isKey);
void close();
}
Key Points:
- Converts type
Ttobyte[] - Receives topic name (for topic-specific logic)
- Separate serializers for keys and values
Configuration
Properties props = new Properties();
// Key serializer
props.put("key.serializer",
"org.apache.kafka.common.serialization.StringSerializer");
// Value serializer
props.put("value.serializer",
"org.apache.kafka.common.serialization.StringSerializer");
Why Separate?
- Keys and values can be different types
- Key determines partition (needs consistent serialization)
- Value is payload (can be more flexible)
3. Built-in Serializers
Kafka provides serializers for common Java types.
StringSerializer
props.put("value.serializer",
"org.apache.kafka.common.serialization.StringSerializer");
// Usage
ProducerRecord<String, String> record =
new ProducerRecord<>("topic", "key", "value");
producer.send(record);
Encoding: UTF-8 by default
Use For:
- Text data
- JSON strings (serialize object to JSON string first)
- Simple key-value pairs
IntegerSerializer / LongSerializer
props.put("key.serializer",
"org.apache.kafka.common.serialization.IntegerSerializer");
// Usage
ProducerRecord<Integer, String> record =
new ProducerRecord<>("topic", 12345, "value");
Use For:
- Numeric keys (user IDs, order IDs)
- Counters
- Timestamps (as Long)
ByteArraySerializer
props.put("value.serializer",
"org.apache.kafka.common.serialization.ByteArraySerializer");
// Usage
byte[] data = someObject.toBytes();
ProducerRecord<String, byte[]> record =
new ProducerRecord<>("topic", "key", data);
Use For:
- Already serialized data
- Binary formats (images, files)
- Custom binary protocols
- Protobuf/Avro (serialize first, then send bytes)
ByteBufferSerializer / BytesSerializer
props.put("value.serializer",
"org.apache.kafka.common.serialization.ByteBufferSerializer");
Use For:
- Zero-copy scenarios
- Working with NIO ByteBuffers
Complete List of Built-in Serializers
Kafka provides these serializers out of the box:
| Serializer | Java Type | Use Case |
|---|---|---|
StringSerializer |
String |
Text, JSON strings |
IntegerSerializer |
Integer |
Numeric keys/values |
LongSerializer |
Long |
IDs, timestamps |
ShortSerializer |
Short |
Small numbers |
FloatSerializer |
Float |
Floating-point numbers |
DoubleSerializer |
Double |
High-precision numbers |
ByteArraySerializer |
byte[] |
Binary data, pre-serialized |
ByteBufferSerializer |
ByteBuffer |
NIO buffers |
BytesSerializer |
Bytes |
Kafka Bytes wrapper |
UUIDSerializer |
UUID |
Unique identifiers |
VoidSerializer |
Void |
Null values (tombstones) |
Package: org.apache.kafka.common.serialization
3.5 Pluggable Serializers: The Serializer Architecture
Why Serializers are Pluggable
Kafka's serializer design is pluggable — you can swap serializers without changing producer code.
// Same producer code, different serializers
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
// OR
props.put("value.serializer", "io.confluent.kafka.serializers.KafkaAvroSerializer");
// OR
props.put("value.serializer", "com.mycompany.MyCustomSerializer");
Benefits:
- Separation of concerns: Producer logic separate from serialization
- Easy migration: Change format without rewriting producer
- Reusability: Same serializer across multiple producers
- Testability: Mock serializers for testing
How Pluggable Serializers Work
1. Producer Loads Serializer at Startup
Properties props = new Properties();
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
// Producer instantiates the serializer via reflection
KafkaProducer<String, String> producer = new KafkaProducer<>(props);
// → Loads StringSerializer class
// → Calls configure()
2. Producer Calls Serializer on Every Send
producer.send(new ProducerRecord<>("topic", "key", "value"));
// Internally:
// 1. keySerializer.serialize("topic", "key") → byte[]
// 2. valueSerializer.serialize("topic", "value") → byte[]
// 3. Send bytes to Kafka
3. Serializer Lifecycle
CREATE → CONFIGURE → SERIALIZE (many times) → CLOSE
│ │ │ │
│ │ │ └─ Release resources
│ │ └─ Convert objects to bytes
│ └─ Initialize with configs
└─ Instantiate via reflection
Using Third-Party Serializers
Example: Confluent Avro Serializer
<!-- Maven dependency -->
<dependency>
<groupId>io.confluent</groupId>
<artifactId>kafka-avro-serializer</artifactId>
<version>7.5.0</version>
</dependency>
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "io.confluent.kafka.serializers.KafkaAvroSerializer");
// Avro-specific config
props.put("schema.registry.url", "http://localhost:8081");
KafkaProducer<String, GenericRecord> producer = new KafkaProducer<>(props);
// Send Avro record
GenericRecord avroRecord = ...;
producer.send(new ProducerRecord<>("topic", "key", avroRecord));
The producer doesn't know or care that it's Avro — it just calls serialize().
Example: Protobuf Serializer
<dependency>
<groupId>io.confluent</groupId>
<artifactId>kafka-protobuf-serializer</artifactId>
<version>7.5.0</version>
</dependency>
props.put("value.serializer", "io.confluent.kafka.serializers.protobuf.KafkaProtobufSerializer");
props.put("schema.registry.url", "http://localhost:8081");
KafkaProducer<String, MyProtobufMessage> producer = new KafkaProducer<>(props);
Example: JSON Serializer
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>3.7.0</version>
</dependency>
// Kafka doesn't include a built-in JSON serializer, but you can use:
// 1. Spring Kafka's JsonSerializer
// 2. Write your own (see Custom Serializers section)
// 3. Use StringSerializer + manual JSON conversion
// Option 1: Spring Kafka
props.put("value.serializer", "org.springframework.kafka.support.serializer.JsonSerializer");
// Option 2: Manual JSON with StringSerializer
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(myObject);
producer.send(new ProducerRecord<>("topic", "key", json));
Advantages of Pluggable Design
1. Easy Format Migration
// Phase 1: JSON
props.put("value.serializer", "com.mycompany.JsonSerializer");
// Phase 2: Migrate to Avro (no producer code changes!)
props.put("value.serializer", "io.confluent.kafka.serializers.KafkaAvroSerializer");
props.put("schema.registry.url", "http://localhost:8081");
2. Environment-Specific Serializers
// Development: Human-readable JSON
if (env.equals("dev")) {
props.put("value.serializer", "com.mycompany.JsonSerializer");
}
// Production: Efficient Avro
if (env.equals("prod")) {
props.put("value.serializer", "io.confluent.kafka.serializers.KafkaAvroSerializer");
}
3. Testing with Mock Serializers
// Test with a no-op serializer
public class NoOpSerializer implements Serializer<String> {
public byte[] serialize(String topic, String data) {
return data.getBytes(); // Simple pass-through
}
}
props.put("value.serializer", "com.mycompany.test.NoOpSerializer");
Best Practices for Pluggable Serializers
✅ DO:
- Keep serializers stateless (thread-safe)
- Make serializers configurable via
configure() - Handle null values gracefully
- Release resources in
close() - Document expected configuration keys
❌ DON'T:
- Put business logic in serializers
- Make serializers depend on external state
- Throw unchecked exceptions (wrap in
SerializationException) - Ignore the
topicparameter (useful for topic-specific logic)
4. Custom Serializers
When to Create Custom Serializers
✅ Good Reasons:
- Domain objects that don't fit standard serializers
- Custom binary formats
- Tight integration with existing serialization libraries
- Performance optimization for specific types
❌ Bad Reasons:
- Wrapping JSON serialization (just use JsonSerializer)
- Adding business logic (serializers should be pure)
Creating a Custom Serializer
Example: UserSerializer
public class User {
private long id;
private String name;
private String email;
// Constructor, getters, setters
}
import org.apache.kafka.common.serialization.Serializer;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
public class UserSerializer implements Serializer<User> {
@Override
public byte[] serialize(String topic, User user) {
if (user == null) {
return null;
}
byte[] nameBytes = user.getName().getBytes(StandardCharsets.UTF_8);
byte[] emailBytes = user.getEmail().getBytes(StandardCharsets.UTF_8);
// Calculate total size: 8 (id) + 4 (name length) + name + 4 (email length) + email
ByteBuffer buffer = ByteBuffer.allocate(
8 + 4 + nameBytes.length + 4 + emailBytes.length
);
buffer.putLong(user.getId());
buffer.putInt(nameBytes.length);
buffer.put(nameBytes);
buffer.putInt(emailBytes.length);
buffer.put(emailBytes);
return buffer.array();
}
@Override
public void close() {
// Cleanup if needed
}
}
Usage:
props.put("value.serializer", "com.myapp.UserSerializer");
User user = new User(123L, "Alice", "alice@example.com");
ProducerRecord<String, User> record =
new ProducerRecord<>("users", String.valueOf(user.getId()), user);
producer.send(record);
Custom Serializer Best Practices
✅ Handle Null Values
@Override
public byte[] serialize(String topic, User user) {
if (user == null) {
return null; // Kafka handles null records
}
// ... serialization logic
}
✅ Be Deterministic
// ❌ BAD: Non-deterministic (order matters!)
Map<String, String> map = new HashMap<>(); // Unordered!
// ✅ GOOD: Deterministic
Map<String, String> map = new TreeMap<>(); // Ordered
Why? Same object must always produce same bytes (for idempotence).
✅ Version Your Format
@Override
public byte[] serialize(String topic, User user) {
ByteBuffer buffer = ByteBuffer.allocate(...);
buffer.put((byte) 1); // Format version
buffer.putLong(user.getId());
// ... rest of data
return buffer.array();
}
Why? Enables schema evolution and backward compatibility.
❌ Don't Include Business Logic
// ❌ BAD: Business logic in serializer
@Override
public byte[] serialize(String topic, User user) {
if (user.getEmail() == null) {
user.setEmail("noemail@example.com"); // Modifying object!
}
// ...
}
// ✅ GOOD: Validate before serialization
if (user.getEmail() == null) {
throw new IllegalArgumentException("Email required");
}
producer.send(new ProducerRecord<>("users", user));
5. JSON Serialization
Using JsonSerializer (Kafka's Built-in)
Kafka provides a generic JSON serializer (Kafka 2.6+).
import org.apache.kafka.common.serialization.JsonSerializer;
props.put("value.serializer",
"org.apache.kafka.common.serialization.JsonSerializer");
// Send any POJO
User user = new User(123L, "Alice", "alice@example.com");
ProducerRecord<String, User> record =
new ProducerRecord<>("users", "123", user);
producer.send(record);
Result in Kafka:
{
"id": 123,
"name": "Alice",
"email": "alice@example.com"
}
JSON with Jackson
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.kafka.common.serialization.Serializer;
public class JacksonSerializer<T> implements Serializer<T> {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public byte[] serialize(String topic, T data) {
if (data == null) {
return null;
}
try {
return objectMapper.writeValueAsBytes(data);
} catch (Exception e) {
throw new RuntimeException("Failed to serialize", e);
}
}
}
JSON Pros and Cons
✅ Pros:
- Human-readable (easy debugging)
- Language-agnostic
- Flexible (schema-less)
- Easy to get started
❌ Cons:
- Verbose (larger message size)
- No schema validation (errors caught late)
- Slower than binary formats
- No built-in versioning
- Type safety issues
Use When:
- Rapid prototyping
- Small message volumes
- Human readability is important
- Cross-language compatibility needed
6. Avro Serialization
What is Avro?
Apache Avro is a binary serialization format with:
- Schema definition (JSON format)
- Compact binary encoding
- Schema evolution support
- Strong typing
Avro Schema Example
{
"type": "record",
"name": "User",
"namespace": "com.example",
"fields": [
{"name": "id", "type": "long"},
{"name": "name", "type": "string"},
{"name": "email", "type": "string"}
]
}
Using Avro with Kafka
import io.confluent.kafka.serializers.KafkaAvroSerializer;
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("key.serializer",
"org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer",
"io.confluent.kafka.serializers.KafkaAvroSerializer");
props.put("schema.registry.url", "http://localhost:8081");
KafkaProducer<String, GenericRecord> producer = new KafkaProducer<>(props);
// Create Avro record
Schema schema = new Schema.Parser().parse(schemaString);
GenericRecord user = new GenericData.Record(schema);
user.put("id", 123L);
user.put("name", "Alice");
user.put("email", "alice@example.com");
// Send
ProducerRecord<String, GenericRecord> record =
new ProducerRecord<>("users", "123", user);
producer.send(record);
Avro Pros and Cons
✅ Pros:
- Compact binary format (smaller messages)
- Schema enforcement (catch errors early)
- Schema evolution (backward/forward compatibility)
- Fast serialization/deserialization
- Code generation for strongly-typed objects
❌ Cons:
- Requires Schema Registry
- More setup complexity
- Not human-readable (debugging harder)
- Learning curve
Use When:
- Production systems with high volume
- Schema evolution is needed
- Performance matters
- Strong typing required
7. Protocol Buffers (Protobuf)
What is Protobuf?
Google's Protocol Buffers: binary serialization with schema.
Protobuf Schema (.proto file)
syntax = "proto3";
message User {
int64 id = 1;
string name = 2;
string email = 3;
}
Compile Schema
protoc --java_out=. user.proto
Generates Java classes: User.java
Using Protobuf with Kafka
import com.google.protobuf.Message;
import org.apache.kafka.common.serialization.Serializer;
public class ProtobufSerializer<T extends Message> implements Serializer<T> {
@Override
public byte[] serialize(String topic, T data) {
if (data == null) {
return null;
}
return data.toByteArray();
}
}
props.put("value.serializer", "com.myapp.ProtobufSerializer");
User user = User.newBuilder()
.setId(123L)
.setName("Alice")
.setEmail("alice@example.com")
.build();
ProducerRecord<String, User> record =
new ProducerRecord<>("users", "123", user);
producer.send(record);
Protobuf Pros and Cons
✅ Pros:
- Very compact
- Fast
- Strong typing
- Good language support (not just JVM)
- Schema evolution
❌ Cons:
- Code generation required
- Not human-readable
- Schema management needed
8. Key vs Value Serialization
Why Different Serializers?
// Key: Simple string (for partitioning)
// Value: Complex object (payload)
props.put("key.serializer",
"org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer",
"io.confluent.kafka.serializers.KafkaAvroSerializer");
Common Patterns:
| Key Type | Value Type | Use Case |
|---|---|---|
| String | String/JSON | Simple events, logs |
| String | Avro | User IDs with rich payload |
| Integer/Long | Avro/Protobuf | Numeric IDs with structured data |
| null | JSON/Avro | No partitioning key needed |
Null Keys
// No key (null key)
ProducerRecord<String, String> record =
new ProducerRecord<>("topic", null, "value");
Effect: Round-robin partitioning (no ordering guarantee).
Partitioning details: Module 4.1
9. Schema Evolution
The Problem
// Version 1: User with id and name
class User {
long id;
String name;
}
// Version 2: Added email field
class User {
long id;
String name;
String email; // NEW FIELD
}
Challenge: Old consumers can't read new format (or vice versa).
Evolution Strategies
Backward Compatibility
New consumers can read old data.
// V2 consumer reading V1 data
class User {
long id;
String name;
String email = ""; // Default value for missing field
}
How to Achieve:
- Add fields with default values
- Don't remove fields
- Don't change field types
Forward Compatibility
Old consumers can read new data.
// V1 consumer reading V2 data (ignores email)
class User {
long id;
String name;
// Ignores unknown "email" field
}
How to Achieve:
- Old code ignores unknown fields
- Don't change existing field semantics
Full Compatibility
Both backward and forward compatible.
Best achieved with:
- Avro (built-in schema evolution)
- Protobuf (field numbering)
- JSON Schema (with validation)
Avro Schema Evolution Example
Schema V1:
{
"type": "record",
"name": "User",
"fields": [
{"name": "id", "type": "long"},
{"name": "name", "type": "string"}
]
}
Schema V2 (backward compatible):
{
"type": "record",
"name": "User",
"fields": [
{"name": "id", "type": "long"},
{"name": "name", "type": "string"},
{"name": "email", "type": "string", "default": ""}
]
}
Key: New field has a default value.
10. Common Serialization Failures
Failure 1: Mismatched Serializers
// Producer
props.put("value.serializer", "StringSerializer");
producer.send(new ProducerRecord<>("topic", "key", "value"));
// Consumer
props.put("value.deserializer", "IntegerDeserializer"); // ❌ Mismatch!
Error: SerializationException on consumer side.
Fix: Ensure producer and consumer use compatible serializers.
Failure 2: Null Handling
User user = null;
producer.send(new ProducerRecord<>("topic", "key", user));
What Happens:
- Serializer receives
null - Must return
null(not throw exception) - Kafka stores null value (tombstone in compacted topics)
Fix: Handle nulls in serializer:
if (data == null) return null;
Failure 3: Schema Changes Without Versioning
// Old format: "id,name"
// New format: "id,name,email"
Problem: Consumers expect old format.
Fix:
- Use schema versioning (Avro, Protobuf)
- Include version byte in custom serializers
- Use Schema Registry
Failure 4: Encoding Issues
// Producer uses UTF-8
String message = "Hello 世界";
byte[] bytes = message.getBytes(StandardCharsets.UTF_8);
// Consumer uses ISO-8859-1
String decoded = new String(bytes, StandardCharsets.ISO_8859_1); // ❌ Garbage
Fix: Always specify encoding explicitly.
Failure 5: Non-Deterministic Serialization
// ❌ BAD: HashMap order is non-deterministic
Map<String, String> map = new HashMap<>();
map.put("b", "2");
map.put("a", "1");
return objectMapper.writeValueAsBytes(map); // Order varies!
// ✅ GOOD: TreeMap has consistent order
Map<String, String> map = new TreeMap<>();
Why it matters: Idempotent producer compares byte arrays.
11. Best Practices
✅ Use Schema Registry (Production)
props.put("schema.registry.url", "http://localhost:8081");
Benefits:
- Centralized schema management
- Automatic versioning
- Compatibility checks
- Schema evolution support
✅ Version Your Schemas
// Include version in custom formats
ByteBuffer buffer = ByteBuffer.allocate(size);
buffer.put((byte) 2); // Schema version
// ... rest of data
✅ Test Serialization Round-Trip
@Test
public void testSerializationRoundTrip() {
User original = new User(123L, "Alice", "alice@example.com");
// Serialize
byte[] bytes = serializer.serialize("topic", original);
// Deserialize
User deserialized = deserializer.deserialize("topic", bytes);
// Verify
assertEquals(original, deserialized);
}
✅ Handle Backward Compatibility
// New field with default
String email = user.get("email") != null
? user.get("email")
: "noemail@example.com";
✅ Document Your Schema
/**
* User serialization format (Version 2)
*
* Byte layout:
* 0: Version byte (0x02)
* 1-8: User ID (long)
* 9-12: Name length (int)
* 13-...: Name bytes (UTF-8)
* ...: Email length (int)
* ...: Email bytes (UTF-8)
*/
❌ Avoid Serializing Transient State
class User {
long id;
String name;
transient String sessionToken; // ❌ Don't serialize
}
12. Choosing a Serialization Format
Decision Matrix
| Format | Size | Speed | Schema | Human-Readable | Use Case |
|---|---|---|---|---|---|
| String | Large | Fast | No | Yes | Logs, simple KV |
| JSON | Large | Medium | Optional | Yes | REST APIs, prototyping |
| Avro | Small | Fast | Yes | No | Production, high volume |
| Protobuf | Small | Very Fast | Yes | No | High performance, polyglot |
| Custom Binary | Small | Very Fast | Manual | No | Special cases only |
Recommendation Flow
START
│
├─ Prototyping? → JSON
│
├─ High volume production? → Avro or Protobuf
│
├─ Cross-language? → Protobuf
│
├─ Schema evolution critical? → Avro
│
├─ Simple text data? → String
│
└─ Special requirements? → Custom Binary
Summary
Key Takeaways
✅ Serialization converts objects to bytes (and back)
✅ Producer serializes, consumer deserializes (must match)
✅ Built-in serializers for common types (String, Integer, byte[])
✅ Custom serializers for domain objects
✅ JSON for readability and flexibility
✅ Avro/Protobuf for performance and schema evolution
✅ Schema Registry for production systems
✅ Version your schemas for compatibility
✅ Test round-trip serialization
Serialization Checklist
Before going to production:
- Serializers are deterministic
- Null handling implemented
- Schema versioning in place
- Backward compatibility tested
- Round-trip tests written
- Schema Registry configured (if using Avro)
- Documentation updated with format details
- Performance tested with realistic data
What's Next
Module 4.1: Partitioning Strategy & Message Ordering
Learn how keys affect partitioning and orderingModule 5: Reliability, Delivery Guarantees & Semantics
Ensure your serialized data is delivered reliably
Previous Module: ← 2.1 - Producer APIs & Core Configuration
Next Module: 4.1 - Partitioning Strategy & Message Ordering →