Skip to content

Evolving Event Schemas Without Breaking Consumers

How to add fields, rename properties, and restructure events in async systems without coordinating deployments or silently breaking downstream consumers.

4 min read
Diagram showing producer and consumer compatibility across event schema versions

Event-driven systems decouple services beautifully — until you need to change an event schema. Suddenly the decoupling works against you. Producers and consumers deploy independently, messages sit in a queue for hours or days, and there's no HTTP 422 to tell you something broke. Schema changes done wrong silently corrupt state or crash workers at 2 AM.

This isn't a tooling problem you solve by picking the right message broker. It's a discipline problem. Here's the framework I apply before touching any event schema in production.

Why Schema Evolution Is Harder Than API Evolution

With REST APIs, breaking changes are visible. The client gets a 400, retries fail, dashboards turn red. The feedback loop is tight.

With event-driven systems, the feedback loop is broken:

  • Consumers lag behind: A consumer might be processing messages produced days ago when you deploy a schema change. Old messages already in the queue now fail validation.
  • Multiple consumers: One schema change can break three different services — only one of which your team owns.
  • No synchronous handshake: There's no moment where producer and consumer negotiate a contract. The contract lives in documentation, or worse, tribal knowledge.

The solution isn't to avoid schema changes. It's to make only compatible changes, and to have a deliberate pattern for the incompatible ones.

The Four Compatibility Modes

Before touching a schema, classify the change:

Change TypeExampleCompatibility
Add optional field+ correlationId?: stringBackward ✅ Forward ✅
Remove optional field- deprecatedField?Forward only ⚠️
Add required field+ requiredAt: stringNeither ❌
Rename or change typestatus: boolean → stringNeither ❌

Backward compatible means old consumers can read new messages. Forward compatible means new consumers can read old messages. Full compatibility in both directions is the only safe mode for long-lived event systems where you can't coordinate deployments across teams.

The Expand-Contract Pattern

For breaking changes, never mutate the schema in one step. Use three phases:

  1. Expand: Add the new field alongside the old one. Produce both.
  2. Migrate: Update all consumers to read the new field. Deprecate the old.
  3. Contract: Remove the old field once no consumer references it.
tstypescript
// Phase 1 — Expand: produce both old and new field simultaneously
interface OrderPlacedExpanded {
  orderId: string;
  customerId: string;   // kept for old consumers still reading this field
  accountId: string;    // new canonical field for new consumers
  totalCents: number;
}
 
function produceOrderPlaced(order: Order): OrderPlacedExpanded {
  return {
    orderId: order.id,
    customerId: order.account.id,  // backward compat
    accountId: order.account.id,   // forward compat
    totalCents: order.totalCents,
  };
}
tstypescript
// Phase 2 — Consumers prefer the new field with a fallback
function handleOrderPlaced(event: OrderPlacedExpanded): void {
  // ✅ graceful fallback during the transition window
  const accountId = event.accountId ?? event.customerId;
  processOrder(accountId, event.orderId, event.totalCents);
}

Only after every consumer is deployed with the fallback do you move to Phase 3 and drop customerId. Skip Phase 2 and you'll have consumers crashing on messages already sitting in the queue — messages produced before your deployment.

The Envelope Pattern for Incompatible Changes

When a truly incompatible change is unavoidable — restructuring an event, changing the semantics of a field — introduce a new schema version. The envelope pattern makes version routing explicit instead of relying on shape-guessing:

tstypescript
interface EventEnvelope<T = unknown> {
  eventId: string;
  eventType: string;
  schemaVersion: number;
  occurredAt: string;     // ISO 8601
  payload: T;
}
 
interface OrderPlacedV1Payload {
  orderId: string;
  customerId: string;
  totalCents: number;
}
 
interface OrderPlacedV2Payload {
  orderId: string;
  account: { id: string; email: string };
  total: { cents: number; currency: string };
}
 
type OrderPlacedEvent =
  | (EventEnvelope<OrderPlacedV1Payload> & { schemaVersion: 1 })
  | (EventEnvelope<OrderPlacedV2Payload> & { schemaVersion: 2 });

The consumer dispatches on version, not on field-presence heuristics:

tstypescript
function handleOrderPlaced(envelope: OrderPlacedEvent): void {
  if (envelope.schemaVersion === 1) {
    const { orderId, customerId, totalCents } = envelope.payload;
    processOrderV1(orderId, customerId, totalCents);
    return;
  }
 
  if (envelope.schemaVersion === 2) {
    const { orderId, account, total } = envelope.payload;
    processOrderV2(orderId, account.id, total.cents, total.currency);
    return;
  }
 
  // exhaustive check — compiler warns if a new version is added without handling it
  const _exhaustive: never = envelope;
}
!

Avoid embedding version in the event type name (order.placed.v2). It couples routing to schema concerns and proliferates topic names. Prefer a schemaVersion field in the envelope with a single stable topic per logical event type.

Runtime Validation at the Consumer Boundary

TypeScript types disappear at runtime. When a message arrives from the broker, you don't know its shape until you validate it. Pair your interfaces with a runtime schema:

tstypescript
import { z } from "zod";
 
const OrderPlacedV2Schema = z.object({
  eventId: z.string().uuid(),
  eventType: z.literal("order.placed"),
  schemaVersion: z.literal(2),
  occurredAt: z.string().datetime(),
  payload: z.object({
    orderId: z.string(),
    account: z.object({ id: z.string(), email: z.string().email() }),
    total: z.object({
      cents: z.number().int().positive(),
      currency: z.string().length(3),
    }),
  }),
});
 
// ❌ Trusting the message shape — the compiler is lying to you
async function consumeRaw(message: Buffer): Promise<void> {
  const event = JSON.parse(message.toString()) as OrderPlacedV2; // cast, not safety
  processOrder(event.payload.orderId);                            // crashes if shape is wrong
}
 
// ✅ Validate at the boundary before touching the payload
async function consume(message: Buffer): Promise<void> {
  const parsed = JSON.parse(message.toString());
  const result = OrderPlacedV2Schema.safeParse(parsed);
 
  if (!result.success) {
    logger.error("Schema validation failed", {
      errors: result.error.flatten(),
      rawEvent: parsed,
    });
    await deadLetterQueue.send(message); // park it, don't drop it
    return;
  }
 
  processOrder(result.data.payload.orderId);
}

Failed validation belongs in a dead-letter queue, not in a crash or a silent drop. Both outcomes are worse than parking the message for manual inspection with full context.

Knowing When the Transition Is Done

The hardest part of schema evolution isn't the code — it's knowing when it's safe to complete Phase 3. A few signals that actually work:

Producer-side deprecation metrics: Increment a counter every time the old field is written. When the counter flatlines after migration, the field is safe to drop.

Consumer-side fallback counters: Track how often event.accountId ?? event.customerId hits the fallback branch. A counter reaching zero is a reliable migration signal — not a deploy timestamp.

Schema registry enforcement: Tools like Confluent Schema Registry or AWS Glue Schema Registry can reject incompatible schema publishes at CI time, before anything reaches production.

shbash
# Validate schema compatibility before publishing — runs in CI
curl -s -o /dev/null -w "%{http_code}" \
  -X POST \
  -H "Content-Type: application/vnd.schemaregistry.v1+json" \
  --data '{"schema":"{...}"}' \
  "http://registry:8081/compatibility/subjects/order-placed-value/versions/latest"
 
# Returns 200 with {"is_compatible":true} or fail the pipeline

Using a registry shifts compatibility validation left — you catch the breaking change in the pull request, not in production, and not at 2 AM.

Key Takeaways

  1. Classify before you change: Every schema modification is backward-compatible, forward-compatible, or neither. Know which before writing a single line.
  2. Expand before you contract: Breaking changes require three deployment phases — expand, migrate, contract. Skipping the migrate phase corrupts messages already in the queue.
  3. Envelope every event: A schemaVersion field in a standard envelope turns implicit shape-guessing into explicit, compiler-checked version dispatch.
  4. Validate at the consumer boundary: Runtime schema validation catches the mismatch. Dead-letter the failures — never drop them silently.
  5. Track the transition with metrics: Deprecation counters and fallback branch hits tell you when migration is complete. Don't rely on deployment timestamps.
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX