Event-Driven Architecture · lesson 10 of 10
Beginner
3Intermediate
6- 4 Event Sourcing: Snapshots So Replay Doesn't Get Slower Forever
- 5 CQRS: Separate Write and Read Models, and the Lag Between Them
- 6 The Dual-Write Problem: A DB Commit and a Publish Can Fail Independently
- 7 Transactional Outbox: Committing the Event With the Data
- 8 Idempotent Consumers: Surviving a Duplicate Delivery
- 9 Dead-Letter Queues: What Happens When Retries Keep Failing
Also part of Event-Driven Architecture
6- + Saga Pattern: Orchestrating a Multi-Step Transaction from Microservices
- + Saga Pattern: Real Compensation When a Step Fails from Microservices
- + At-Most-Once, At-Least-Once, and Exactly-Once from Kafka
- + Serialization and the Schema Registry from Kafka
- + Pub/Sub: Channels, Patterns, and the Message That Never Arrives from Redis
- + Incremental Loads and Change Data Capture from Data Engineering
Event Schema Evolution: Replaying Events That Predate a Field
Adding a field to new events breaks replay of old ones with a KeyError; an upcast step transforms old event shapes before applying them.
By SkillByExample · Published
An event log that’s been running for a while accumulates events written under more than one schema — replay has to understand all of them, not just the newest one.
A field that didn’t exist yet
old_events = [
{"type": "Deposited", "amount": 100},
{"type": "Withdrawn", "amount": 30},
]
new_events = [
{"type": "Deposited", "amount_cents": 5000},
]
full_log = old_events + new_events
def apply_naive(events):
balance = 0
for event in events:
if event["type"] == "Deposited":
balance += event["amount_cents"]
elif event["type"] == "Withdrawn":
balance -= event["amount_cents"]
return balance
try:
apply_naive(full_log)
except KeyError as e:
print(f"Replay crashed on an old event: missing key {e}")
def upcast(event):
if "amount" in event and "amount_cents" not in event:
event = {**event, "amount_cents": event["amount"] * 100}
return event
def apply_with_upcasting(events):
balance = 0
for raw_event in events:
event = upcast(raw_event)
if event["type"] == "Deposited":
balance += event["amount_cents"]
elif event["type"] == "Withdrawn":
balance -= event["amount_cents"]
return balance
print(f"Balance replaying old and new events together: {apply_with_upcasting(full_log)}")
$ python event_schema_evolution.py
Replay crashed on an old event: missing key 'amount_cents'
Balance replaying old and new events together: 12000
amount became amount_cents (dollars to integer cents, a real reason to change a stored
event’s shape) at some point after the first two events were already written. apply_naive
only knows the new field, so it crashes the instant it meets an event from before the change.
upcast fills in the missing field on the way in, computed from the old one, so both event
generations produce a correct combined balance — 100*100 - 30*100 + 5000 = 12000.
Practice
1. Add a second schema change — renaming "Deposited" to "MoneyDeposited" — and upcast events from all three generations.
v1_events = [{"type": "Deposited", "amount": 100}]
v2_events = [{"type": "Deposited", "amount_cents": 5000}]
v3_events = [{"type": "MoneyDeposited", "amount_cents": 2000}]
def upcast(event):
if event["type"] == "Deposited" and "amount_cents" not in event:
event = {**event, "amount_cents": event["amount"] * 100}
if event["type"] == "Deposited":
event = {**event, "type": "MoneyDeposited"}
return event
def apply_all(events):
balance = 0
for raw_event in events:
event = upcast(raw_event)
if event["type"] == "MoneyDeposited":
balance += event["amount_cents"]
return balance
full_log = v1_events + v2_events + v3_events
print(f"Balance across three event shapes: {apply_all(full_log)}")
$ python two_schema_changes.py
Balance across three event shapes: 17000
Both changes live in the same upcast function, applied in sequence — the field rename runs
first (conditioned on still being "Deposited"), then the type rename runs unconditionally for
anything that started as "Deposited", whichever generation it came from.
2. Split the two changes into separate upcast functions, chained together.
def upcast_v1_to_v2(event):
if "amount" in event and "amount_cents" not in event:
return {**event, "amount_cents": event["amount"] * 100}
return event
def upcast_v2_to_v3(event):
if event.get("type") == "Deposited":
return {**event, "type": "MoneyDeposited"}
return event
def upcast_chain(event):
event = upcast_v1_to_v2(event)
event = upcast_v2_to_v3(event)
return event
v1_event = {"type": "Deposited", "amount": 100}
print(f"v1 event: {v1_event}")
print(f"after chain: {upcast_chain(v1_event)}")
$ python chained_upcasts.py
v1 event: {'type': 'Deposited', 'amount': 100}
after chain: {'type': 'MoneyDeposited', 'amount': 100, 'amount_cents': 10000}
Each function only knows about one schema transition, and upcast_chain runs them in the order
the schema actually changed — a new transition means adding one function and one line to the
chain, not editing the logic for every version at once.
3. Compare an upcast that mutates its input against one that returns a new dict, and see the mutation corrupt the stored event.
events = [{"type": "Deposited", "amount": 100}]
def upcast_mutates(event):
if "amount_cents" not in event:
event["amount_cents"] = event["amount"] * 100
return event
first_pass = [upcast_mutates(e) for e in events]
print(f"Stored log after first replay: {events}")
def upcast_safe(event):
if "amount_cents" not in event:
return {**event, "amount_cents": event["amount"] * 100}
return event
events2 = [{"type": "Deposited", "amount": 100}]
second_pass = [upcast_safe(e) for e in events2]
print(f"Stored log after safe replay: {events2}")
$ python mutation_bug.py
Stored log after first replay: [{'type': 'Deposited', 'amount': 100, 'amount_cents': 10000}]
Stored log after safe replay: [{'type': 'Deposited', 'amount': 100}]
upcast_mutates writes amount_cents directly onto the dict that’s still sitting in events —
the “original” log is no longer what was actually recorded, just from calling a function that’s
supposed to be read-only. upcast_safe’s {**event, ...} builds a new dict every time, so the
source list is untouched no matter how many times replay runs.
4. Add an explicit schema_version field and dispatch on it instead of inferring version from which keys exist.
unversioned_event = {"type": "Deposited", "amount": 100}
versioned_event = {"type": "Deposited", "schema_version": 2, "amount_cents": 5000}
def upcast(event):
version = event.get("schema_version", 1)
if version == 1:
return {**event, "schema_version": 2, "amount_cents": event["amount"] * 100}
return event
print(f"Unversioned (implicit v1): {upcast(unversioned_event)}")
print(f"Explicitly versioned v2: {upcast(versioned_event)}")
$ python explicit_versioning.py
Unversioned (implicit v1): {'type': 'Deposited', 'amount': 100, 'schema_version': 2, 'amount_cents': 10000}
Explicitly versioned v2: {'type': 'Deposited', 'schema_version': 2, 'amount_cents': 5000}
event.get("schema_version", 1) treats any event with no version field as version 1, so
existing events never need to be touched to adopt this — only new events start carrying the
field, and old ones are still correctly recognized by its absence.