Skip to content

Domain Events and Aggregates#

In Domain-Driven Design, an aggregate is a cluster of related entities treated as a single unit for consistency. A domain event is a fact about something that happened, emitted by the aggregate.

flowchart LR
  subgraph Aggregate[Order Aggregate]
    Root[Order root] --- I1[OrderLine]
    Root --- I2[ShippingAddress]
  end
  Cmd([Command: PlaceOrder]) --> Root
  Root -->|emits| Ev[OrderPlaced]
  Ev --> Bus[(Event bus)]
  Bus --> H1[Inventory handler]
  Bus --> H2[Email handler]

    classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
    classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
    class Root,I1,I2,H1,H2 service;
    class Bus queue;

    classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
    classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
    classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
    classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
    classDef cache fill:#fed7aa,stroke:#9a3412,stroke-width:1px,color:#0f172a;
    classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
    classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
    classDef storage fill:#e5e7eb,stroke:#374151,stroke-width:1px,color:#0f172a;
    classDef external fill:#fce7f3,stroke:#9d174d,stroke-width:1px,color:#0f172a;
    classDef obs fill:#f3e8ff,stroke:#6b21a8,stroke-width:1px,color:#0f172a;

The aggregate guarantees its own invariants; events propagate facts to anyone interested. Consumers react asynchronously, decoupled from the write path.

Two of the most important tactical patterns from DDD. Used together, they give you a clean write path and a flexible read/integration path.

Aggregate rules#

An aggregate is a graph of objects that change together. It has one aggregate root through which all access happens.

classDiagram
  class Order {
    -id: OrderId
    -customerId: CustomerId
    -lines: List~OrderLine~
    -status: Status
    +addLine(productId, qty)
    +submit()
    +cancel()
  }
  class OrderLine {
    -productId
    -qty
    -priceAtAdd
  }
  Order "1" *-- "many" OrderLine

The five aggregate rules#

  1. One root: external code references only the root.
  2. Invariants enforced at root: e.g. total can't exceed limit; only the root checks.
  3. Reference other aggregates by id, not by pointer.
  4. One aggregate per transaction: don't update Order and Customer in the same DB tx. Use events for the second.
  5. Small aggregates: design around invariants, not "everything related". Smaller aggregates have fewer lock conflicts.

Domain events#

A domain event is a past-tense, immutable fact:

data class OrderPlaced(
    val orderId: OrderId,
    val customerId: CustomerId,
    val total: Money,
    val placedAt: Instant,
)

Events vs commands#

Command Event
Tense Imperative ("PlaceOrder") Past ("OrderPlaced")
Can be rejected? Yes No - it already happened
Audience Single handler Zero-to-many
Generated by Caller Aggregate

The transactional outbox#

Events emitted by the aggregate must be durably delivered even if a process crashes between DB commit and broker publish. The outbox pattern:

flowchart TB
  App[App txn] -->|same DB tx| DB[(DB:<br/>orders table<br/>outbox table)]
  CDC[CDC / poller] --> DB
  CDC --> Kafka[(Kafka)]
  Kafka --> H1[Handler 1]
  Kafka --> H2[Handler 2]

    classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
    classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
    classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
    class App,H1,H2,CDC service;
    class DB datastore;
    class Kafka queue;
  1. Aggregate change + event row written in the same DB transaction.
  2. A poller (or CDC) reads the outbox table and publishes to Kafka.
  3. On publish success, mark the outbox row as delivered.

Application service example#

class PlaceOrderService:
    def __init__(self, orders: OrderRepo, outbox: Outbox):
        self.orders = orders
        self.outbox = outbox

    def execute(self, cmd: PlaceOrderCommand):
        order = Order.create(cmd.items, cmd.customer)  # invariants checked here
        with self.unit_of_work:
            self.orders.save(order)
            for e in order.pull_events():
                self.outbox.append(e)
        # commit triggers the poller; nothing async inside the txn

Relationship to event sourcing and CQRS#

Pattern Relationship
Domain events The events here
Event sourcing Store the events instead of state; reconstruct aggregates by replay
CQRS Separate write model (aggregates) from read model (projections from events)

You can use domain events without event sourcing - just store state and emit events as facts.

Common anti-patterns#

  • Anaemic aggregates: pure data; logic in services. Drift back to procedural code.
  • God aggregates: an Order with 50 lines and 10 collections. Lock contention, slow loads.
  • Events as RPC: "DoX" instead of "XHappened". Becomes a brittle async API.
  • Cross-aggregate transactions: tempts you back into one giant aggregate. Use eventual consistency between aggregates.

Where domain events fit#

flowchart TB
  DE((Domain events<br/>+ aggregates))
  DDD[DDD tactical<br/>parent]
  ES[Event Sourcing + CQRS<br/>follow-on]
  RP[Repository pattern<br/>persistence boundary]
  CDC[Change Data Capture<br/>outbox shipper]
  DDD --> DE
  DE --> ES
  RP --> DE
  DE --> CDC

    classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
    classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
    classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
    classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
    classDef cache fill:#fed7aa,stroke:#9a3412,stroke-width:1px,color:#0f172a;
    classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
    classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
    classDef storage fill:#e5e7eb,stroke:#374151,stroke-width:1px,color:#0f172a;
    classDef external fill:#fce7f3,stroke:#9d174d,stroke-width:1px,color:#0f172a;
    classDef obs fill:#f3e8ff,stroke:#6b21a8,stroke-width:1px,color:#0f172a;
    class DE service;
    class DDD,ES,RP,CDC datastore;

Glossary & fundamentals#

Tag Concept What it is Page
LLD DDD tactical parent concept ddd-tactical
HLD Event Sourcing CQRS natural follow-on event-sourcing-cqrs
LLD Repository pattern aggregate persistence boundary repository-pattern
HLD Change Data Capture the outbox shipper change-data-capture

Quick reference#

Five aggregate rules#

  1. One root; external code references only the root.
  2. Invariants enforced at the root.
  3. Reference other aggregates by id, not pointer.
  4. One aggregate per DB transaction.
  5. Small aggregates around invariants, not relationships.

Event vs command#

Command Event
Tense Imperative Past
Reject? Yes No
Audience 1 0..N

Outbox pattern#

  1. Save aggregate + event row in same DB txn
  2. Poller / CDC reads outbox
  3. Publish to broker, mark delivered

Anti-patterns#

  • Anaemic aggregate (data only)
  • God aggregate (50 children)
  • Events as RPC ("DoX")
  • Cross-aggregate single txn
  • Event sourcing: store events as state
  • CQRS: separate write/read models
  • DDD repositories: persistence per aggregate

Refs#

  • Evans - Domain-Driven Design
  • Vernon - Implementing DDD; Effective Aggregate Design
  • Fowler - DDD Aggregate

FAQ#

What is a domain event?#

A domain event is a past-tense fact that something important happened inside the domain, like OrderPlaced or InventoryReserved. Other parts of the system react to it.

What is the difference between a command and a domain event?#

A command is a request to do something and can be rejected. A domain event is a record that something already happened and cannot be denied by listeners.

How do I publish domain events reliably with a database write?#

Use the outbox pattern: write the event to an outbox table in the same transaction as the state change, then a relay forwards it to the broker. This prevents lost events.

Are domain events the same as Kafka events?#

Not quite. Domain events live inside one bounded context. Integration events on Kafka or another broker are usually a translated subset published outside the service.

Should aggregates publish events directly to a message bus?#

No. Aggregates collect events in memory and the application layer dispatches them after the transaction commits. This keeps the domain free of infrastructure.

Further reading#