š Event Versioning Without Breaking Consumers
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
OrderCreatedV1OrderCreatedV2
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 shapeeventVersion: 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
Update projection to handle both V1 and V2
Deploy projection
Start emitting V2
Wait
Stop emitting V1
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
