Loading...
āœ“

12-Hour Money-Back Guarantee

šŸ“˜ Event Versioning Without Breaking Consumers

šŸ“˜ Event Versioning Without Breaking Consumers

šŸ“˜ Event Versioning Without Breaking Consumers

30 Mar 20224 min read

How to evolve events that live forever

In event sourcing, your data is not ā€œthe databaseā€.
Your data is history.
And history cannot be rewritten cheaply.

1) Why Event Versioning Is Harder Than API Versioning

APIs are ephemeral:

  • Clients upgrade

  • Old versions can be deprecated

Events are permanent:

  • Consumers may replay from 3 years ago

  • New services may bootstrap from day 0

  • Old events are still part of truth

Events are not requests.
They are contracts across time.

2) The Core Rule (Non-Negotiable)

Never change the meaning of an existing event.

Changing meaning is worse than changing schema.

Example of a ā€œmeaning changeā€ (very bad)

amount used to mean ā€œtotal in INRā€
now means ā€œtotal in centsā€

This corrupts replay forever.

3) What Actually Breaks Consumers

Consumers break when:

  • Required fields disappear

  • Field types change

  • Event name changes

  • Semantics change

  • Ordering assumptions change

4) The 4 Safe Evolution Moves

āœ… Safe Move A — Add an optional field

OrderCreated { orderId, total, currency? }

āœ… Safe Move B — Add a new event type

OrderCreatedV2 { orderId, total, currency }

āœ… Safe Move C — Deprecate but keep old fields

Keep old fields for years.

āœ… Safe Move D — Create ā€œcorrectionā€ events

Instead of editing history.

5) The Most Practical Strategy (Best Default)

Prefer additive schema changes.

Why?

  • Old consumers ignore unknown fields

  • New consumers can use them

  • Replay remains consistent

6) Versioning Strategies

Your system must support multiple consumer generations at once.

7) Strategy 1 — Schema Evolution in the Same Event (Additive)

Example: V1 → V2 (Add a field)

V1

{
  "type": "OrderCreated",
  "orderId": "o1",
  "total": 1000
}

V2

{
  "type": "OrderCreated",
  "orderId": "o1",
  "total": 1000,
  "currency": "INR"
}

Consumer Code (JS)

function handleOrderCreated(e) {
  const currency = e.currency ?? "INR"; // default
  // ...
}

Why this works

āœ” No consumer breaks
āœ” Replay works
āœ” Gradual adoption

When it fails

āŒ If currency is required for correctness
(then it’s not optional)

8) Strategy 2 — Versioned Event Names (Most Explicit)

Event types

  • OrderCreatedV1

  • OrderCreatedV2

Producer emits only V2 after cutover.

Consumer supports both:

function handleEvent(e) {
  switch (e.type) {
    case "OrderCreatedV1":
      return handleV1(e);
    case "OrderCreatedV2":
      return handleV2(e);
  }
}

Why it works

āœ” Clear semantics
āœ” No ambiguity
āœ” Safe for big changes

Tradeoff

  • More code paths

  • More long-term maintenance

9) Strategy 3 — Upcasting (Best for Large Systems)

Store old events as-is, but convert them at read time.

Example: Upcaster

function upcast(e) {
  if (e.type === "OrderCreated" && e.currency == null) {
    return { ...e, currency: "INR" };
  }
  return e;
}

Consumer always assumes ā€œlatest schemaā€.

const event = upcast(rawEvent);
process(event);

Why this is powerful

āœ” Old history stays immutable
āœ” Consumers stay simple
āœ” Centralizes version logic

Risk

  • Upcaster becomes a critical dependency

  • Bugs in upcaster corrupt projections

10) Upcasting Pipeline

11) Strategy 4 — Dual Publish (Zero Downtime Migration)

Used when you must do a big event redesign.

Pattern

Producer emits both:

  • Old event

  • New event

For a while.

OrderCreatedV1
OrderCreatedV2

Consumers migrate gradually.

Dual Publish

Why it works

āœ” Safe rollout
āœ” Allows backfills
āœ” No flag day

Tradeoff

  • Double volume

  • More storage

  • More complexity

12) Strategy 5 — Event Translation Layer (Enterprise Pattern)

A platform team maintains:

  • canonical events internally

  • translated versions for external consumers

Think:

ā€œAPI gateway, but for events.ā€

13) The Big One: Semantic Versioning for Events

Good approach

  • schemaVersion: for shape

  • eventVersion: for meaning

Example:

{
  "type": "PaymentCaptured",
  "schemaVersion": 2,
  "eventVersion": 1
}

Why both?

Because sometimes the JSON changes without meaning changing.

14) Breaking Change Examples (Don’t Do These)

āŒ Rename field

userId → customerId

Old consumers break.

āŒ Change type

amount: "100" → amount: 100

Breaks parsers.

āŒ Change meaning

total was inclusive of tax, now exclusive.

Kills correctness forever.

15) Handling ā€œRequired Fieldsā€ Safely

If a new field is required for correctness, you have 3 safe options:

Option A — New event type

OrderCreatedV2

Option B — Correction event

OrderCurrencyResolved

Option C — Dual publish + migration window

16) How to Migrate Without Breaking Projections

Projections are the biggest victims.

Correct migration plan

  1. Update projection to handle both V1 and V2

  2. Deploy projection

  3. Start emitting V2

  4. Wait

  5. Stop emitting V1

  6. Keep V1 support for replay forever (or upcaster)

17) ā€œBut I Want to Fix Old Eventsā€

You can’t. Not safely.

If you must:

  • Append a correction event

  • Or rebuild from a new canonical source

  • Or fork the log (very expensive)

The event log is your ledger.

18) Production Checklist

If you want event versioning to be safe, you need:

  • Schema registry (or equivalent)

  • Compatibility rules enforced in CI

  • Consumer contract tests

  • Upcasters for long-lived systems

  • Replay test pipelines