Event-Driven Architecture · lesson 3 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 Sourcing: Storing What Happened Instead of What's True Now
A mutable balance field loses its own history the moment you overwrite it. An append-only event log rebuilds the same number by replaying every step.
By SkillByExample · Published
Update a balance field in place and the previous value is gone the instant the new one is written — an event log keeps every step that produced the current number.
Two ways to end up with the same balance
class MutableAccount:
def __init__(self):
self.balance = 0
def deposit(self, amount):
self.balance += amount
def withdraw(self, amount):
self.balance -= amount
account = MutableAccount()
account.deposit(100)
account.withdraw(30)
account.deposit(50)
print(f"Mutable balance: {account.balance}")
account.balance = 0
print(f"After 'corruption', balance: {account.balance} (no way to recover it)")
print()
events = []
def apply(events):
balance = 0
for event in events:
if event["type"] == "Deposited":
balance += event["amount"]
elif event["type"] == "Withdrawn":
balance -= event["amount"]
return balance
events.append({"type": "Deposited", "amount": 100})
events.append({"type": "Withdrawn", "amount": 30})
events.append({"type": "Deposited", "amount": 50})
print(f"Event-sourced balance: {apply(events)}")
balance_after_two_events = apply(events[:2])
print(f"Balance after only the first two events: {balance_after_two_events}")
print(f"Full event log: {events}")
print(f"Rebuilt from the log after 'corruption': {apply(events)}")
$ python event_sourcing_basics.py
Mutable balance: 120
After 'corruption', balance: 0 (no way to recover it)
Event-sourced balance: 120
Balance after only the first two events: 70
Full event log: [{'type': 'Deposited', 'amount': 100}, {'type': 'Withdrawn', 'amount': 30}, {'type': 'Deposited', 'amount': 50}]
Rebuilt from the log after 'corruption': 120
Setting account.balance = 0 destroys the mutable version’s only copy of the truth — there is
nothing left to recompute from. The event-sourced version never actually stores a balance at
all; apply(events) derives it fresh every time, which is exactly why “corrupting” a variable
that isn’t the real state has no effect on what apply returns from the log.
Practice
1. Add an Adjusted event type that sets the balance directly (a bank correction), and confirm it composes with the existing events.
def apply(events):
balance = 0
for event in events:
if event["type"] == "Deposited":
balance += event["amount"]
elif event["type"] == "Withdrawn":
balance -= event["amount"]
elif event["type"] == "Adjusted":
balance = event["new_balance"]
return balance
events = [
{"type": "Deposited", "amount": 100},
{"type": "Withdrawn", "amount": 30},
{"type": "Adjusted", "new_balance": 500, "reason": "bank correction"},
{"type": "Deposited", "amount": 20},
]
print(f"Balance: {apply(events)}")
$ python adjusted_event.py
Balance: 520
Adjusted doesn’t add or subtract — it overrides the running total outright, and every event
after it still applies normally on top of the new baseline. Adding a new event type required
one new elif branch, not a schema migration on a stored column.
2. Compute the balance after every single event, not just the final one.
def apply(events):
balance = 0
for event in events:
if event["type"] == "Deposited":
balance += event["amount"]
elif event["type"] == "Withdrawn":
balance -= event["amount"]
return balance
def balance_history(events):
return [apply(events[:i + 1]) for i in range(len(events))]
events = [
{"type": "Deposited", "amount": 100},
{"type": "Withdrawn", "amount": 30},
{"type": "Deposited", "amount": 50},
{"type": "Withdrawn", "amount": 80},
]
print(f"Balance after each event: {balance_history(events)}")
$ python balance_history.py
Balance after each event: [100, 70, 120, 40]
balance_history is just apply called on every prefix of the list — a full timeline of the
account’s balance falls out of the same function used to compute the current value, something
a mutable field with no history simply cannot answer after the fact.
3. Persist events to a real SQLite table instead of a Python list, and replay from a query.
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE events (type TEXT, amount INTEGER)")
conn.execute("INSERT INTO events VALUES ('Deposited', 100)")
conn.execute("INSERT INTO events VALUES ('Withdrawn', 30)")
conn.execute("INSERT INTO events VALUES ('Deposited', 50)")
conn.commit()
def apply_from_db(conn):
balance = 0
for event_type, amount in conn.execute("SELECT type, amount FROM events ORDER BY rowid"):
if event_type == "Deposited":
balance += amount
elif event_type == "Withdrawn":
balance -= amount
return balance
print(f"Balance replayed from SQLite: {apply_from_db(conn)}")
conn.execute("INSERT INTO events VALUES ('Withdrawn', 40)")
conn.commit()
print(f"Balance after one more real INSERT: {apply_from_db(conn)}")
$ python sqlite_event_log.py
Balance replayed from SQLite: 120
Balance after one more real INSERT: 80
The table only ever receives INSERTs — nothing ever UPDATEs or DELETEs a row — and
ORDER BY rowid guarantees replay sees events in the order they were written, which is the one
invariant this whole pattern depends on.
4. Confirm that replaying the same events twice gives the same answer, and contrast it with calling a mutable method twice by accident.
def apply(events):
balance = 0
for event in events:
if event["type"] == "Deposited":
balance += event["amount"]
elif event["type"] == "Withdrawn":
balance -= event["amount"]
return balance
events = [
{"type": "Deposited", "amount": 100},
{"type": "Withdrawn", "amount": 30},
]
print(f"First replay: {apply(events)}")
print(f"Second replay: {apply(events)}")
print(f"Third replay: {apply(events)}")
class MutableAccount:
def __init__(self):
self.balance = 0
def deposit(self, amount):
self.balance += amount
account = MutableAccount()
account.deposit(100)
account.deposit(100)
print(f"Calling deposit(100) twice by accident: {account.balance}")
$ python idempotent_replay.py
First replay: 70
Second replay: 70
Third replay: 70
Calling deposit(100) twice by accident: 200
apply is a pure function of the event list, so calling it any number of times can never
change the result. deposit, by contrast, mutates state every time it’s called — calling it
twice by mistake really does double the balance, which is a class of bug replay is naturally
immune to.