Skip to main content
Event-Driven Architecture · lesson 2 of 10
Event-Driven Architecture beginner Lesson 2 of 10

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

event-driven architecture python

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.

Frequently Asked Questions

Why does it matter if I name an event like a command?
Because the name is the only signal a reader (or another service) has about whether something can still be rejected. A consumer that receives 'PlaceOrder' might reasonably try to validate and refuse it; a consumer that receives 'OrderPlaced' has no business refusing anything — the order is already placed. Naming an event like a command invites code that tries to veto something that already happened.
Is a fact event just an event with more fields?
No — the distinction is what it means to apply two of them. A fact event carries the resulting state directly (StockSet: quantity is now 90), so applying the same one twice is harmless. A delta event carries a relative change (StockAdjusted: by -10), so applying it twice changes the outcome. The fields can look similar; the redelivery behavior is what actually differs.
Can a single command produce more than one event?
Yes, and it often should — a command represents one request, but the resulting facts can be more than one. Placing a large order might produce both OrderPlaced and a separate HighValueOrderFlagged, each meaningful to a different downstream consumer.
Does every command have to succeed or fail with one event?
No — a command can also be rejected outright and produce zero business events, only a rejection notice (like CancelRejected in the practice below). Rejection is itself worth representing explicitly rather than silently doing nothing, since a caller or downstream consumer usually needs to know a command didn't take effect.