Domain events
Recording what happened as objects, so other parts can react later.
01The intuition
The problem it solves
A library program marks a copy returned. The return method also computes a fine, sends an email, updates the member's history, and tells the reservation queue that a copy is free. Each new consequence is another line in return_copy, each with its own failure mode: when the email server is down, the return itself fails and the book shows as still out. Testing the return means faking an email server. And nobody can answer "what happened to this copy last Tuesday?" because the method only changes current state.
class Library:
def return_copy(self, copy: "Copy", today: date) -> None:
loan = copy.loan
copy.loan = None
fine = loan.days_late(today) * 2
loan.member.fines += fine
mailer.send(loan.member.email, f"Returned. Fine: {fine}") # fails -> whole return fails
loan.member.history.append(loan)
reservations.notify_free(copy.book)
Build it one piece at a time
Step one: separate the fact from its consequences. The return method does the one thing it owns (the copy is back) and records that it happened as a small immutable object: a domain event, named in the past tense.
from dataclasses import dataclass
from datetime import date
@dataclass(frozen=True)
class CopyReturned:
copy_id: str
member_id: int
days_late: int
on: date
class Library:
def __init__(self) -> None:
self.pending: list[CopyReturned] = []
def return_copy(self, copy: "Copy", today: date) -> None:
loan = copy.loan
copy.loan = None
self.pending.append(CopyReturned(copy.barcode, loan.member.id, loan.days_late(today), today))
Step two: the consequences become handlers that react to the event. Each one is small, has one reason to exist, and can fail on its own without undoing the return.
def charge_fine(e: CopyReturned) -> None:
if e.days_late: members[e.member_id].fines += e.days_late * 2
def email_member(e: CopyReturned) -> None:
mailer.send(members[e.member_id].email, f"Returned {e.copy_id}")
def wake_reservations(e: CopyReturned) -> None:
reservations.notify_free(copies[e.copy_id].book)
handlers = {CopyReturned: [charge_fine, email_member, wake_reservations]}
Step three: a dispatcher delivers pending events after the state change is complete, and the events themselves are kept. The log of events is now the history, and replaying it rebuilds any past state.
class Dispatcher:
def __init__(self, handlers: dict[type, list]) -> None:
self.handlers = handlers
self.log: list[object] = []
def flush(self, pending: list[object]) -> None:
while pending:
e = pending.pop(0)
self.log.append(e)
for h in self.handlers.get(type(e), []):
try: h(e)
except Exception as err: report(err) # one handler failing is not a failed return
library.return_copy(copy, today)
dispatcher.flush(library.pending)
Why it works
The principle: say what happened, not what to do about it. The core object stays honest about its own state; everything that cares subscribes to the fact. The invariant is that an event is recorded only after the change it describes has been made, and never changed afterwards. Where it breaks: events raised before the state change (a handler reads old data), events that are commands in disguise (SendEmail instead of CopyReturned), handlers that depend on each other's order, and a log that is edited. Interviewers test with "the email fails; is the book returned?" and "how do you know what happened to this copy?"
The trade-off you must say out loud
For two consequences in a small program, direct calls are simpler and the indirection costs clarity. Events shine when consequences multiply, when failures must be isolated, and when an audit trail is a requirement. Delivery inside the same process is simple; the moment events cross a process you inherit queues, retries and duplicates, so the handlers must be idempotent. The follow-up is usually whether events are delivered synchronously or later, and what happens on a crash between the state change and the flush.
Where it shows up in the 100 Google problems
- Library management system: the example above; fines, notifications and holds all react to returns.
- Digital wallet: every transfer is an event; the balance is the sum of the log and the statement is the log itself.
- Order matching engine: fills and cancels are emitted as events for the trade feed and positions.
- Inventory management system: stock reserved, released and shipped events drive reorder rules.
- Customer-support agent dispatcher: agent went on break and request re-queued are events the fairness logic reacts to.