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

Also part of: System Design Microservices

Events vs Requests: Why the Order Service Doesn't Wait for Email

A synchronous order flow fails outright when email is down; an event-driven one records the order and lets each handler fail independently.

By SkillByExample · Published

event-driven architecture python

A request-driven order flow calls each step directly and waits for it to finish; an event-driven one just announces what happened and lets independent handlers react.

The same failure, two very different outcomes

class EmailServiceDown(Exception):
    pass

def send_confirmation_email(order_id):
    raise EmailServiceDown("email service is down")

def reserve_inventory(order_id):
    print(f"  inventory reserved for order {order_id}")

def place_order_request_driven(order_id):
    print(f"Placing order {order_id} (request-driven)...")
    reserve_inventory(order_id)
    send_confirmation_email(order_id)
    print(f"Order {order_id} placed successfully")

try:
    place_order_request_driven(101)
except EmailServiceDown as e:
    print(f"Order 101 FAILED: {e}")

print()

event_log = []

def place_order_event_driven(order_id):
    print(f"Placing order {order_id} (event-driven)...")
    event_log.append({"type": "OrderPlaced", "order_id": order_id})
    print(f"Order {order_id} placed successfully")

def inventory_handler(event):
    if event["type"] == "OrderPlaced":
        print(f"  [inventory handler] reserved stock for order {event['order_id']}")

def email_handler(event):
    if event["type"] == "OrderPlaced":
        try:
            send_confirmation_email(event["order_id"])
        except EmailServiceDown as e:
            print(f"  [email handler] failed, will retry later: {e}")

place_order_event_driven(102)
for event in event_log:
    inventory_handler(event)
    email_handler(event)
$ python events_vs_requests.py
Placing order 101 (request-driven)...
  inventory reserved for order 101
Order 101 FAILED: email service is down

Placing order 102 (event-driven)...
Order 102 placed successfully
  [inventory handler] reserved stock for order 102
  [email handler] failed, will retry later: email service is down

Order 101 never gets placed at all — send_confirmation_email raising partway through place_order_request_driven unwinds the whole function, so a message queue outage takes down order placement with it. Order 102 succeeds the moment its event is logged; the email failure happens later, inside a handler that has nothing to do with whether the order itself is considered placed.

Practice

1. Add a third handler, analytics_handler, and confirm it runs independently of the other two.
event_log = [{"type": "OrderPlaced", "order_id": 201}]

def inventory_handler(event):
    if event["type"] == "OrderPlaced":
        print(f"  [inventory handler] reserved stock for order {event['order_id']}")

def email_handler(event):
    if event["type"] == "OrderPlaced":
        print(f"  [email handler] confirmation sent for order {event['order_id']}")

def analytics_handler(event):
    print(f"  [analytics handler] recorded {event['type']} for order {event['order_id']}")

for event in event_log:
    inventory_handler(event)
    email_handler(event)
    analytics_handler(event)
$ python add_analytics_handler.py
  [inventory handler] reserved stock for order 201
  [email handler] confirmation sent for order 201
  [analytics handler] recorded OrderPlaced for order 201

analytics_handler doesn’t check event["type"] at all — it just logs whatever comes through. Adding it required no change to the other two handlers or to how events get produced.

2. Make reserve_inventory fail instead of send_confirmation_email, and compare both flows again.
class InventoryUnavailable(Exception):
    pass

def reserve_inventory_fails(order_id):
    raise InventoryUnavailable("warehouse system unreachable")

def send_confirmation_email(order_id):
    print(f"  confirmation email sent for order {order_id}")

def place_order_request_driven(order_id):
    print(f"Placing order {order_id} (request-driven)...")
    reserve_inventory_fails(order_id)
    send_confirmation_email(order_id)
    print(f"Order {order_id} placed successfully")

try:
    place_order_request_driven(301)
except InventoryUnavailable as e:
    print(f"Order 301 FAILED: {e}")

print()

event_log = []

def place_order_event_driven(order_id):
    print(f"Placing order {order_id} (event-driven)...")
    event_log.append({"type": "OrderPlaced", "order_id": order_id})
    print(f"Order {order_id} placed successfully")

def inventory_handler(event):
    try:
        reserve_inventory_fails(event["order_id"])
    except InventoryUnavailable as e:
        print(f"  [inventory handler] failed, will retry later: {e}")

def email_handler(event):
    send_confirmation_email(event["order_id"])

place_order_event_driven(302)
for event in event_log:
    inventory_handler(event)
    email_handler(event)
$ python fail_inventory_instead.py
Placing order 301 (request-driven)...
Order 301 FAILED: warehouse system unreachable

Placing order 302 (event-driven)...
Order 302 placed successfully
  [inventory handler] failed, will retry later: warehouse system unreachable
  confirmation email sent for order 302

Whichever step fails, the request-driven version loses the entire order; the event-driven version only loses whatever that one handler was responsible for. The failure moves, but the pattern’s behavior doesn’t.

3. Add a second event type, OrderCancelled, and have handlers react differently to each.
event_log = [
    {"type": "OrderPlaced", "order_id": 401},
    {"type": "OrderCancelled", "order_id": 401},
]

def inventory_handler(event):
    if event["type"] == "OrderPlaced":
        print(f"  [inventory handler] reserved stock for order {event['order_id']}")
    elif event["type"] == "OrderCancelled":
        print(f"  [inventory handler] released stock for order {event['order_id']}")

def email_handler(event):
    if event["type"] == "OrderPlaced":
        print(f"  [email handler] confirmation sent for order {event['order_id']}")
    else:
        print(f"  [email handler] ignoring {event['type']}, nothing to send")

for event in event_log:
    print(f"--- handling {event['type']} ---")
    inventory_handler(event)
    email_handler(event)
$ python two_event_types.py
--- handling OrderPlaced ---
  [inventory handler] reserved stock for order 401
  [email handler] confirmation sent for order 401
--- handling OrderCancelled ---
  [inventory handler] released stock for order 401
  [email handler] ignoring OrderCancelled, nothing to send

Each handler decides for itself which event types matter to it — email_handler explicitly ignores OrderCancelled instead of the event log needing to know which handlers care about which types.

4. Count how many events are left unresolved after a batch, to see what a real retry mechanism would need to track.
class EmailServiceDown(Exception):
    pass

def send_confirmation_email(order_id):
    if order_id % 2 == 0:
        raise EmailServiceDown("email service is down")
    print(f"  [email handler] confirmation sent for order {order_id}")

event_log = [
    {"type": "OrderPlaced", "order_id": 501},
    {"type": "OrderPlaced", "order_id": 502},
    {"type": "OrderPlaced", "order_id": 503},
]

unresolved = []

for event in event_log:
    try:
        send_confirmation_email(event["order_id"])
    except EmailServiceDown:
        print(f"  [email handler] failed for order {event['order_id']}, queued for retry")
        unresolved.append(event)

print(f"Unresolved events needing retry: {len(unresolved)}")
print([e["order_id"] for e in unresolved])
$ python count_unresolved.py
  [email handler] confirmation sent for order 501
  [email handler] failed for order 502, queued for retry
  [email handler] confirmation sent for order 503
Unresolved events needing retry: 1
[502]

unresolved is exactly the bookkeeping a real retry mechanism needs — which events a handler never successfully processed — and it exists here as a plain list because there’s only one consumer; a real system with many independent consumers needs this tracked per-consumer, not once globally.

Frequently Asked Questions

Isn't the event-driven version just hiding the email failure instead of solving it?
It defers the failure rather than hiding it — the email handler's exception is caught and reported, and a real system would queue that event for retry. The difference is that placing the order no longer depends on email succeeding, so one dependency's outage doesn't cascade into an outage of an unrelated concern.
What is the event log in a real system, if not a Python list?
It's usually a message broker topic (Kafka, RabbitMQ) or a database table read by multiple consumers. This lesson uses a plain list because the pattern — producers not knowing or caring who reacts — doesn't depend on which transport carries the event, and a list is the smallest thing that demonstrates it.
Does going event-driven mean nothing is synchronous anymore?
No. Real systems mix both: requests for anything the caller needs an immediate, consistent answer to (has this order shipped yet?), events for side effects and fan-out that don't need the caller to wait (send an email, update analytics, adjust inventory).
Why does the inventory handler run before the email handler in this example?
Only because that's the order the for-loop calls them in — nothing in the design depends on it. A real broker with independent consumers might process them concurrently or in a different order, and event-driven systems shouldn't rely on handler ordering unless they explicitly build that guarantee in.