Concepts / Modeling and API shape / Domain events

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

02Used in these problems