Event-Driven Architecture · lesson 2 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
Events vs Commands: Naming Facts You Can't Reject
A command can be refused before anything happens; an event is a fact about something that already did. Naming them wrong hides which is which.
By SkillByExample · Published
A command asks for something and can still be refused; an event reports something that already happened and can’t be un-happened — mixing up the two in naming hides which is which.
Commands can be rejected, events can’t
class PlaceOrder:
def __init__(self, order_id, item_count):
self.order_id = order_id
self.item_count = item_count
class OrderPlaced:
def __init__(self, order_id, item_count):
self.order_id = order_id
self.item_count = item_count
def __repr__(self):
return f"OrderPlaced(order_id={self.order_id!r}, item_count={self.item_count})"
class OrderRejected:
def __init__(self, order_id, reason):
self.order_id = order_id
self.reason = reason
def __repr__(self):
return f"OrderRejected(order_id={self.order_id!r}, reason={self.reason!r})"
def handle(command):
if command.item_count <= 0:
return [OrderRejected(command.order_id, "item_count must be positive")]
return [OrderPlaced(command.order_id, command.item_count)]
for cmd in [PlaceOrder("ord-1", 3), PlaceOrder("ord-2", 0)]:
print(f"Command: PlaceOrder(order_id={cmd.order_id!r}, item_count={cmd.item_count})")
for event in handle(cmd):
print(f" -> {event}")
$ python events_vs_commands.py
Command: PlaceOrder(order_id='ord-1', item_count=3)
-> OrderPlaced(order_id='ord-1', item_count=3)
Command: PlaceOrder(order_id='ord-2', item_count=0)
-> OrderRejected(order_id='ord-2', reason='item_count must be positive')
PlaceOrder is named as an imperative — a request to do something, which handle is free to
refuse. OrderPlaced and OrderRejected are both named as facts in the past tense: something
that already happened, whether that something was a successful placement or a rejection.
Neither event asks permission; they report an outcome that already occurred.
Fact events survive redelivery, delta events don’t
class StockAdjusted: # delta event: relative change
def __init__(self, sku, by):
self.sku = sku
self.by = by
class StockSet: # fact event: absolute value
def __init__(self, sku, quantity):
self.sku = sku
self.quantity = quantity
def apply_delta_events(events):
stock = 100
for e in events:
stock += e.by
return stock
def apply_fact_events(events):
stock = 100
for e in events:
stock = e.quantity
return stock
delta_once = [StockAdjusted("widget", -10)]
delta_redelivered = delta_once + delta_once # the same event, delivered twice
print("Delta event delivered once: ", apply_delta_events(delta_once))
print("Delta event redelivered: ", apply_delta_events(delta_redelivered))
fact_once = [StockSet("widget", 90)]
fact_redelivered = fact_once + fact_once # the same event, delivered twice
print("Fact event delivered once: ", apply_fact_events(fact_once))
print("Fact event redelivered: ", apply_fact_events(fact_redelivered))
$ python fact_vs_delta.py
Delta event delivered once: 90
Delta event redelivered: 80
Fact event delivered once: 90
Fact event redelivered: 90
Both events describe the same outcome — stock ends up at 90 — but only once. A message broker
that delivers a message twice (which most brokers can do under at-least-once delivery) silently
corrupts the delta version to 80, while the fact version lands on 90 no matter how many times
it’s applied. This is the same idea idempotent-consumers in this topic builds on directly.
Practice
1. Add a CancelOrder command that's rejected if the order was never placed.
class PlaceOrder:
def __init__(self, order_id, item_count):
self.order_id = order_id
self.item_count = item_count
class CancelOrder:
def __init__(self, order_id):
self.order_id = order_id
class OrderPlaced:
def __init__(self, order_id):
self.order_id = order_id
def __repr__(self):
return f"OrderPlaced(order_id={self.order_id!r})"
class OrderCancelled:
def __init__(self, order_id):
self.order_id = order_id
def __repr__(self):
return f"OrderCancelled(order_id={self.order_id!r})"
class CancelRejected:
def __init__(self, order_id, reason):
self.order_id = order_id
self.reason = reason
def __repr__(self):
return f"CancelRejected(order_id={self.order_id!r}, reason={self.reason!r})"
placed_orders = set()
def handle_place(command):
placed_orders.add(command.order_id)
return [OrderPlaced(command.order_id)]
def handle_cancel(command):
if command.order_id not in placed_orders:
return [CancelRejected(command.order_id, "no such order")]
placed_orders.remove(command.order_id)
return [OrderCancelled(command.order_id)]
for event in handle_place(PlaceOrder("ord-1", 2)):
print(f"-> {event}")
for event in handle_cancel(CancelOrder("ord-1")):
print(f"-> {event}")
for event in handle_cancel(CancelOrder("ord-1")):
print(f"-> {event}")
$ python cancel_order.py
-> OrderPlaced(order_id='ord-1')
-> OrderCancelled(order_id='ord-1')
-> CancelRejected(order_id='ord-1', reason='no such order')
The second cancel attempt is rejected for a different reason than the first succeeded — the
order was already cancelled, so it’s no longer in placed_orders by the time the duplicate
CancelOrder arrives.
2. Redeliver the same delta event 2, 3, and 4 times and watch the drift compound.
class StockAdjusted:
def __init__(self, sku, by):
self.sku = sku
self.by = by
class StockSet:
def __init__(self, sku, quantity):
self.sku = sku
self.quantity = quantity
def apply_delta_events(events):
stock = 100
for e in events:
stock += e.by
return stock
def apply_fact_events(events):
stock = 100
for e in events:
stock = e.quantity
return stock
event = StockAdjusted("widget", -10)
for redeliveries in [1, 2, 3, 4]:
result = apply_delta_events([event] * redeliveries)
print(f"Delta event delivered {redeliveries}x: {result}")
print()
fact_event = StockSet("widget", 90)
for redeliveries in [1, 2, 3, 4]:
result = apply_fact_events([fact_event] * redeliveries)
print(f"Fact event delivered {redeliveries}x: {result}")
$ python compounding_drift.py
Delta event delivered 1x: 90
Delta event delivered 2x: 80
Delta event delivered 3x: 70
Delta event delivered 4x: 60
Fact event delivered 1x: 90
Fact event delivered 2x: 90
Fact event delivered 3x: 90
Fact event delivered 4x: 90
Every extra redelivery of the delta event subtracts another 10 — there’s no upper bound on how wrong the final stock count can get, since nothing about the event itself records that it was already applied.
3. Write a naming-convention checker that classifies a class name as command-shaped or event-shaped.
IMPERATIVE_HINTS = ("Place", "Cancel", "Charge", "Reserve", "Create", "Update", "Delete", "Send")
PAST_TENSE_SUFFIXES = ("ed", "d")
def classify_name(name):
if name.startswith(IMPERATIVE_HINTS):
return "looks like a command (imperative verb)"
if name.endswith(PAST_TENSE_SUFFIXES):
return "looks like an event (past tense)"
return "unclear - doesn't follow either convention"
names = ["PlaceOrder", "OrderPlaced", "CancelOrder", "OrderCancelled", "OrderStatus"]
for name in names:
print(f"{name}: {classify_name(name)}")
$ python naming_checker.py
PlaceOrder: looks like a command (imperative verb)
OrderPlaced: looks like an event (past tense)
CancelOrder: looks like a command (imperative verb)
OrderCancelled: looks like an event (past tense)
OrderStatus: unclear - doesn't follow either convention
OrderStatus is the interesting case — it’s neither an imperative verb nor past tense, which is
exactly the kind of ambiguous name that makes a reader guess whether something can still be
rejected. A checker this simple won’t catch every real name, but it catches the two conventions
this lesson actually cares about.
4. Make one command produce two events instead of one.
class PlaceOrder:
def __init__(self, order_id, item_count):
self.order_id = order_id
self.item_count = item_count
class OrderPlaced:
def __init__(self, order_id, item_count):
self.order_id = order_id
self.item_count = item_count
def __repr__(self):
return f"OrderPlaced(order_id={self.order_id!r}, item_count={self.item_count})"
class HighValueOrderFlagged:
def __init__(self, order_id):
self.order_id = order_id
def __repr__(self):
return f"HighValueOrderFlagged(order_id={self.order_id!r})"
def handle(command):
events = [OrderPlaced(command.order_id, command.item_count)]
if command.item_count >= 10:
events.append(HighValueOrderFlagged(command.order_id))
return events
for cmd in [PlaceOrder("ord-1", 3), PlaceOrder("ord-2", 15)]:
print(f"Command: PlaceOrder(order_id={cmd.order_id!r}, item_count={cmd.item_count})")
for event in handle(cmd):
print(f" -> {event}")
$ python multiple_events.py
Command: PlaceOrder(order_id='ord-1', item_count=3)
-> OrderPlaced(order_id='ord-1', item_count=3)
Command: PlaceOrder(order_id='ord-2', item_count=15)
-> OrderPlaced(order_id='ord-2', item_count=15)
-> HighValueOrderFlagged(order_id='ord-2')
handle returns a list precisely so the number of events isn’t fixed at one — a consumer that
only cares about HighValueOrderFlagged can ignore OrderPlaced entirely, without the producer
needing to know that consumer exists.