Event Schema Evolution
Event schema evolution without breaking consumers
Schema evolution is inevitable in long-lived event streams. The goal is to add fields and behaviors without breaking downstream services or losing the ability to replay historical data.
Prerequisites
- Schema Registry (Confluent or compatible)
- Node.js 20+
@kafkajs/confluent-schema-registryandkafkajs
Compatibility strategies
- Backward compatible: new consumers can read old data.
- Forward compatible: old consumers can read new data.
- Full compatible: both directions, safest for replay.
Use backward or full compatibility for event streams that are replayed.
Versioning rules that scale
- Never remove or change the meaning of existing fields.
- Add new fields with defaults to preserve backward compatibility.
- Prefer additive changes and keep old fields until all consumers migrate.
Registering schemas from Node.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Kafka } from "kafkajs";
import { SchemaRegistry, SchemaType } from "@kafkajs/confluent-schema-registry";
const registry = new SchemaRegistry({ host: "http://localhost:8081" });
const schemaV1 = {
type: "record",
name: "OrderCreated",
namespace: "com.example.orders",
fields: [
{ name: "orderId", type: "string" },
{ name: "total", type: "double" }
]
};
const { id: schemaId } = await registry.register({
type: SchemaType.AVRO,
schema: JSON.stringify(schemaV1)
});
Evolving a schema safely
Add optional fields with defaults so old consumers can continue to deserialize.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const schemaV2 = {
type: "record",
name: "OrderCreated",
namespace: "com.example.orders",
fields: [
{ name: "orderId", type: "string" },
{ name: "total", type: "double" },
{ name: "currency", type: "string", default: "USD" }
]
};
await registry.register({
type: SchemaType.AVRO,
schema: JSON.stringify(schemaV2)
});
Publishing with schema-aware serialization
1
2
3
4
5
6
7
8
9
10
11
const kafka = new Kafka({ clientId: "orders-api", brokers: ["localhost:9092"] });
const producer = kafka.producer();
await producer.connect();
const payload = { orderId: "o-1001", total: 420.55, currency: "EUR" };
const encoded = await registry.encode(schemaId, payload);
await producer.send({
topic: "orders",
messages: [{ value: encoded }]
});
Operational guardrails
- Enforce compatibility in the registry, not in code reviews.
- Run consumer contract tests against recorded payloads.
- Track schema IDs in logs to help with incident triage.
Things to remember
- Use additive, backward-compatible changes as the default.
- Keep old fields until all consumers have migrated and historical replays are no longer needed.
- Store schema IDs alongside payloads to ease debugging.
This post is licensed under CC BY 4.0 by the author.