Schema Evolution and Consumer Compatibility

LESSON

Event-Driven Architecture and Streaming Foundations

012 30 min intermediate

Schema Evolution and Consumer Compatibility

By the end of this lesson, you will be able to...

  • Classify event schema changes as safe, risky, or breaking for independent consumers.

  • Design a compatibility path for an event that must change while old consumers still run.

  • Review the difference between syntactic compatibility and semantic compatibility.

Idea in one sentence: An event schema is a long-lived promise, so changing it safely means protecting consumers you do not deploy with the producer.

Core Insight

An order service publishes OrderPlaced.

Three consumers use it:

fulfillment-service   -> reserves stock
email-service         -> sends a confirmation
analytics-pipeline    -> builds revenue reports

The producer team wants to improve the event.

The first version looks like this:

{
  "event_id": "evt-901",
  "event_type": "OrderPlaced",
  "order_id": "O-812",
  "customer_id": "C-44",
  "total_cents": 4999
}

Now the business starts selling in multiple currencies. The producer team wants to publish:

{
  "event_id": "evt-901",
  "event_type": "OrderPlaced",
  "order_id": "O-812",
  "customer_id": "C-44",
  "amount": {
    "value": 4999,
    "currency": "USD"
  }
}

That looks cleaner.

It may also break every consumer that reads total_cents.

The producer did not change a local helper function. It changed a contract in motion. Events are especially sensitive to this because producers and consumers are deployed independently. A producer can publish a new shape today while one consumer is still running code from last month.

Schema evolution is the discipline of changing event contracts without surprising independent consumers. The hard part is not only whether a parser accepts the bytes. The hard part is whether the consumer can still make the same business decision.

The Naive Idea

The naive idea is:

The producer owns the event, so the producer can change the event when its code changes.

That idea works inside one deployable unit. If one service owns both the writer and all readers, it can update them together.

It breaks in an event-driven system.

The producer owns the fact that the order was placed. It does not own all the ways that fact is used. Fulfillment may depend on the order id. Email may depend on the customer id. Analytics may depend on money fields. A fraud model may subscribe later and read old events from the log.

So the event is not just a data structure.

It is a published promise.

Plain meaning:

Compatibility means old and new code can still work during a change.

In this scenario:

The order service can add useful information without forcing fulfillment, email, and analytics to deploy at the same minute.

Technical name:

That promise is consumer compatibility. Schema evolution is the set of rules and release steps that preserve it.

Where Compatibility Breaks

A schema can be compatible at one layer and broken at another.

Syntactic compatibility asks:

Can the consumer decode or parse this event?

Semantic compatibility asks:

Can the consumer still make the same meaning from this event?

Those are different questions.

Suppose the producer changes total_cents from an integer to a string:

{
  "total_cents": "4999"
}

Some consumers may parse it. Others may fail. This is a syntactic risk because the field type changed.

Now suppose the producer keeps the integer type but changes the meaning:

{
  "total_cents": 4999
}

Before, total_cents meant "price paid by the customer after discounts." After the change, it means "catalog subtotal before discounts."

Every parser may still work.

Analytics is now wrong.

That is a semantic break. It is more dangerous because the system may not fail loudly. It may produce plausible but false reports.

Check: A producer keeps a field named customer_id, keeps it as a string, but changes it from the account id to the shipping-contact id. Is this compatible?

Think first, then reveal.

Answer: It is syntactically compatible, but semantically risky or breaking. Existing consumers may still parse the value, but they may join it to the wrong customer records or apply the wrong business rules.

A Compatibility Review Table

Use a small review table before changing an event.

The table should make four things visible:

input -> proposed change -> consumer interpretation -> decision

Here is the OrderPlaced change reviewed step by step.

Proposed change Old consumer sees New consumer sees Compatibility decision Naive failure contrast
Add optional currency with default assumption of USD Ignores unknown field and keeps using total_cents Reads currency when present Usually safe if old meaning stays true A naive producer may assume adding any field is always safe, but consumers can still fail if they reject unknown fields.
Rename total_cents to amount.value immediately Missing total_cents Reads structured amount Breaking A naive producer sees a cleaner model; old consumers see missing data.
Publish both total_cents and amount for a transition window Keeps reading total_cents Moves to amount Safer migration path A naive one-step replacement forces every consumer to move at once.
Change total_cents meaning from paid total to subtotal Parses the same field Parses the same field Semantic break A naive parser check passes, while business meaning changes under consumers.
Add schema_version: 2 and document the new money model Can route or reject if needed Can select the new contract Useful, but not enough alone A naive version field does not protect consumers unless release rules and meanings are clear.

The output of this review is not "use versioning" or "never change events."

The output is a migration plan.

For the money change, a safer path is:

1. Keep publishing `total_cents` with its old meaning.
2. Add `amount.value` and `amount.currency`.
3. Document that new consumers should read `amount`.
4. Watch which consumers still read `total_cents`.
5. Remove `total_cents` only after the event retention window and consumer migration policy allow it.

The transition costs more than the one-step change. It also avoids converting independent deployment into a hidden lockstep release.

Designing the Change

A useful event compatibility policy separates changes into common buckets.

Usually Safe

These changes are usually safe when consumers ignore unknown fields and the new fields are optional:

"Usually" matters. If a consumer uses strict decoding and rejects unknown fields, even an added field can break it. If a consumer treats every enum value as exhaustive, a new value can crash it or send work to the wrong branch.

Risky

These changes need review, migration, or consumer evidence:

These changes often preserve the rough shape of the data while changing the consumer's decision.

Breaking

These changes should usually require a new versioned contract or a planned migration:

The main design question is not:

Can the producer generate this new shape?

The better question is:

Can every supported consumer either keep working, safely ignore the change, or deliberately reject it?

Consumer-Driven Compatibility

The producer cannot guess every consumer's use of an event.

So compatibility needs evidence.

Consumer-driven contract review asks each important consumer to state what it depends on:

Consumer Depends on Decision made from event Break signal
fulfillment-service order_id, line items, purchasable status reserve stock reservation failures or rejected messages
email-service customer_id, locale, order summary send confirmation missing template data
analytics-pipeline amount, currency, discount meaning revenue reporting metric jump or reconciliation drift

This table changes the conversation.

Without it, a schema review can become a producer-only design meeting.

With it, the team can ask:

This does not mean every consumer gets veto power over every producer change. It means the producer needs a compatibility policy that matches the promise it made by publishing the event.

Trade-offs and Limits

Compatibility improves independent deployment. It lets producers add behavior without coordinating every consumer release.

It costs time and contract discipline.

You may need to publish old and new fields together. You may need schema checks in CI. You may need consumer contract tests. You may need a deprecation window that lasts as long as your retention and replay policy.

Compatibility does not solve every event problem.

It does not guarantee that a consumer handles duplicates correctly. That was the outbox and inbox boundary from the previous lesson. It does not guarantee that replay is safe. Replay can re-expose old schemas to new code, which is why the next lesson treats replay and backfills as operational actions.

You can see the boundary when:

The trade-off is direct:

More compatibility discipline buys safer independent deployment.
It also creates longer-lived contracts and slower removal of old shapes.

Common Confusions

Confusion: Versioning makes changes safe

Why it is tempting:

A schema_version field feels like a clear boundary between old and new events.

Better model:

Versioning helps consumers choose logic. It does not decide whether the meaning is safe. A version number without a migration rule is only a label.

Confusion: Adding fields is always safe

Why it is tempting:

Many formats allow readers to ignore unknown fields.

Better model:

Adding a field is safe only when old consumers can ignore it and the meaning of existing fields stays the same. A strict consumer, exhaustive enum match, or changed emission rule can still break.

Confusion: Schema compatibility is a broker concern

Why it is tempting:

Schema registries and broker tooling can enforce useful rules.

Better model:

Tooling can check many syntactic rules. Application teams still own semantic meaning, consumer promises, and release policy.

Check Your Understanding

Check: The order service wants to remove total_cents after publishing amount.value and amount.currency for two weeks. The event log keeps events for 90 days, and a reporting job can replay the last 60 days. What question should the team ask before removal?

Think first, then reveal.

Answer: They should ask whether every supported consumer, including replay jobs, has migrated away from total_cents for the full retention and replay window that matters. Two weeks of dual publishing may be too short if old events can still be replayed by code that expects the old field.

Practice

Review this proposed change.

Current event:

{
  "event_type": "SubscriptionRenewed",
  "subscription_id": "S-17",
  "customer_id": "C-44",
  "renewed_at": "2026-07-08T10:15:00Z",
  "plan": "pro"
}

Proposed change:

{
  "event_type": "SubscriptionRenewed",
  "subscription_id": "S-17",
  "account_id": "C-44",
  "renewed_at": "2026-07-08T10:15:00Z",
  "plan": {
    "code": "pro",
    "billing_period": "monthly"
  }
}

Three consumers exist:

Classify the change and propose a safer migration path.

Model answer:

This is breaking if published as a one-step replacement. customer_id is renamed to account_id, and plan changes from a string to an object. Billing, email, and analytics may all fail or make wrong decisions.

A safer path is to publish both old and new fields for a transition:

{
  "customer_id": "C-44",
  "account_id": "C-44",
  "plan": "pro",
  "plan_details": {
    "code": "pro",
    "billing_period": "monthly"
  }
}

Document the meaning of each field. Ask consumers to declare when they have migrated. Keep the old fields through the supported replay or retention window. Remove them only when the compatibility policy says old consumers and replay paths no longer depend on them.

Resources

Key Takeaways

PREVIOUS Outbox, Inbox, and Dual-Write Avoidance NEXT Replay, Backfills, DLQs, and Poison Events