A business model’s state isn’t just data; it’s a history of decisions and actions.

Imagine a simple e-commerce order. It starts as Pending, then moves to Processing, then Shipped, and finally Delivered. Each of these transitions isn’t just a status update; it’s a domain event.

Here’s what that looks like in practice. Let’s say we have a Order aggregate.

from uuid import uuid4

class Order:
    def __init__(self, customer_id: str, items: list[str]):
        self.id = str(uuid4())
        self.customer_id = customer_id
        self.items = items
        self.status = "Pending"
        self.domain_events = []

    def place_order(self):
        if self.status != "Pending":
            raise Exception("Order cannot be placed, status is not Pending")
        self.status = "Processing"
        self.domain_events.append(OrderPlaced(order_id=self.id, customer_id=self.customer_id, items=self.items))

    def ship_order(self):
        if self.status != "Processing":
            raise Exception("Order cannot be shipped, status is not Processing")
        self.status = "Shipped"
        self.domain_events.append(OrderShipped(order_id=self.id))

    def deliver_order(self):
        if self.status != "Shipped":
            raise Exception("Order cannot be delivered, status is not Shipped")
        self.status = "Delivered"
        self.domain_events.append(OrderDelivered(order_id=self.id))

    def get_uncommitted_events(self):
        events = list(self.domain_events)
        self.domain_events = []
        return events

class OrderPlaced:
    def __init__(self, order_id: str, customer_id: str, items: list[str]):
        self.order_id = order_id
        self.customer_id = customer_id
        self.items = items
        self.type = "OrderPlaced"

class OrderShipped:
    def __init__(self, order_id: str):
        self.order_id = order_id
        self.type = "OrderShipped"

class OrderDelivered:
    def __init__(self, order_id: str):
        self.order_id = order_id
        self.type = "OrderDelivered"

# --- How it's used ---
order = Order("cust-123", ["book-abc", "pen-xyz"])
order.place_order()
uncommitted = order.get_uncommitted_events()
print(uncommitted[0].__dict__)
# Output: {'order_id': '...', 'customer_id': 'cust-123', 'items': ['book-abc', 'pen-xyz'], 'type': 'OrderPlaced'}

order.ship_order()
uncommitted = order.get_uncommitted_events()
print(uncommitted[0].__dict__)
# Output: {'order_id': '...', 'type': 'OrderShipped'}

This Order object now holds a list of domain_events. When a state change happens (like place_order), we don’t just update self.status; we also record that an OrderPlaced event occurred. This event object contains all the relevant information about that specific change.

The core problem this solves is decoupling. When an order is placed, many things might need to happen: send an email to the customer, update inventory, notify the warehouse, charge the credit card. If the place_order method directly called all these services, it would become a huge, brittle function.

By emitting a OrderPlaced event, we can have separate "listeners" or "subscribers" that react to this event. The Order aggregate doesn’t need to know how the email is sent or how inventory is updated; it just declares that an order was placed.

This fits into a larger pattern called Domain-Driven Design (DDD). The "domain" is the core business logic. Domain events are the language of the domain. They represent significant occurrences within the business that other parts of the system (or even other systems) care about.

When you save the Order aggregate to your database, you’d typically also persist its domain_events. After the aggregate is saved, a separate process (an "event bus" or "message broker") picks up these events and dispatches them to interested parties.

Here’s where it gets powerful: instead of the Order aggregate knowing about EmailService and InventoryService, you have a system that listens for OrderPlaced events and triggers those services. This makes your Order aggregate simpler, more focused, and easier to test. You can add new services that react to OrderPlaced (like a fraud detection service) without ever touching the Order code.

The truly counterintuitive part is how this structure inherently promotes eventual consistency. Because events are processed asynchronously, a listener might not see the OrderPlaced event immediately after it’s emitted. This means the system state might be temporarily inconsistent across different services. For example, the inventory count might not update for a few milliseconds. However, the guarantee is that eventually, all interested parties will receive the event and the state will converge. This is a deliberate trade-off for increased decoupling and resilience.

The next logical step is to think about how to handle failures when processing these domain events, especially in distributed systems.

Want structured learning?

Take the full Event-driven course →