Your First Aggregate¶
In this tutorial you will build a complete event-sourced aggregate from scratch: an
Order that can be created, shipped, and cancelled.
An aggregate is the consistency boundary of an event-sourced system. Instead of
storing the current row of an order in a table, you store the sequence of things that
happened to it -- OrderCreated, OrderShipped -- and rebuild the current state by
folding those events together. In eventsource, the recommended way to write that is
the decider style: the domain is two pure functions, decide and evolve, and a
thin subclass of DeciderAggregate[TState, TCommand] adapts them to the library's replay,
snapshot, and repository machinery.
You will write the state model, the events, and the commands that request them; write
decide() as the one place business rules live, and see it reject a command with
CommandRejectedError; write evolve() as the fold that turns an event into the next
state; call execute() to run a command end to end; watch state, version, and
uncommitted_events change as you go; rebuild the order from its history with
load_from_history(); and finally see the same domain sketched in the other two
aggregate styles this library supports.
Everything here runs in plain Python -- no database, no Docker, no event store. You are working with the aggregate in memory, which is exactly how aggregates are unit tested. Persisting the events comes later.
By the end you will have a working OrderAggregate and a clear picture of the three
things every decider aggregate needs: a state to start from, a function that decides
whether a command is allowed, and a function that turns the events it produces into new
state.
What you'll build¶
A single Python file containing an OrderAggregate and the pieces it needs:
OrderState-- a PydanticBaseModelholding the order's current status, its customer, its total, and its tracking number.- Three domain events --
OrderCreated,OrderShipped, andOrderCancelled, each aDomainEventsubclass carrying only the fields that event needs. - Three commands --
CreateOrder,ShipOrder, andCancelOrder, each aDomainCommandsubclass carrying the intent's payload. OrderAggregate(DeciderAggregate[OrderState, OrderCommand])-- withaggregate_type = "Order", a staticinitial_state()that seeds a fresh order, a staticdecide()that turns a command plus the current state into the events it produces (or raisesCommandRejectedError), and a staticevolve()that folds each event type into a newOrderState.
By Step 8 you will run the file and watch three things move together: state (the
folded OrderState), version (which advances to match each event's
aggregate_version), and uncommitted_events (the list the repository will later
persist).
The later steps sharpen it. Step 6 makes decide() refuse to ship a cancelled order --
CommandRejectedError is raised and no event is produced, no version bump, nothing
queued. Step 9 looks at what execute() stamped onto the events for free: causation_id
linking each event back to the command that caused it, plus correlation_id,
actor_id, and tenant_id. Step 10 throws away the in-memory instance and rebuilds an
identical order from its event list with load_from_history() -- the moment event
sourcing pays off.
Then a look at the other two ways to write an aggregate in this library, for when you run into an existing codebase that uses them.
Here is the shape you are heading toward:
class OrderAggregate(DeciderAggregate[OrderState, OrderCommand]):
aggregate_type = "Order"
@staticmethod
def initial_state() -> OrderState: ...
@staticmethod
def decide(command: OrderCommand, state: OrderState) -> list[DomainEvent]: ...
@staticmethod
def evolve(state: OrderState, event: DomainEvent) -> OrderState: ...
Three static methods, none of them touching self. That is the whole domain; everything
else -- version tracking, replay, provenance stamping -- is inherited.
Prerequisites¶
Before you start you need:
- Python 3.13 or newer -- the package declares
requires-python = ">=3.13". eventsource-pyinstalled. From a clone of the repository,uv sync --all-extras; otherwisepip install eventsource-py. Nothing in this tutorial needs an optional extra -- the core install (pydantic + sqlalchemy) is enough.- Comfort with pydantic v2 models.
OrderState, every event, and every command you write areBaseModelsubclasses, and bothDomainEventandDomainCommandsetmodel_config = ConfigDict(frozen=True), so you will update state withmodel_copy(update={...})rather than by assignment. - Your First Event, or its equivalent. This tutorial assumes you
can already declare a
DomainEventsubclass and know that events are immutable records of something that happened.
You do not need Docker, a database, or a message broker. An aggregate is plain
Python: it holds state in memory, folds events into it, and collects the events it
raised in uncommitted_events for someone else to persist later. Every snippet below can
be pasted into a single file and run top to bottom with python, and none of the code is
async -- DeciderAggregate's methods are all synchronous.
Check your install before you begin:
If that prints a version, you are ready.
Step 1: Model the state (a Pydantic BaseModel)¶
Start with the answer to a single question: what does the code need to know about an
order right now? Not the history -- the history is the events. Just the current facts
decide() will read before it decides whether to allow something.
For our order that is four things: who the customer is, how much it is for, what status it is in, and (once shipped) the tracking number.
Create a file called first_aggregate.py and start it like this:
from decimal import Decimal
from uuid import UUID
from pydantic import BaseModel
class OrderState(BaseModel):
customer_id: UUID | None = None
total: Decimal = Decimal("0")
status: str = "pending"
tracking_number: str | None = None
That is the whole state model. A few things about it are deliberate.
It is a Pydantic BaseModel, and that is a hard requirement. AggregateRoot (which
DeciderAggregate extends) is declared as class AggregateRoot[TState: BaseModel](ABC),
an inline PEP 695 type parameter rather than a module-level TypeVar. Anything you plug
in as TState must be a
BaseModel subclass. A dataclass or a plain dict will not type check, and the snapshot
machinery would break on it: _serialize_state() calls self._state.model_dump(mode="json"),
and _restore_from_snapshot() calls state_type.model_validate(state_dict). Those two
methods only exist because the state is a pydantic model.
Every field has a default, and there is no id field. In Step 4 you will write
initial_state(), which constructs an OrderState before any event has been applied --
a blank order that exists only so evolve() has something to fold the first event into.
Defaults are what make that no-argument constructor possible. The order's identity is
not among them: which order a command is about is carried by the command (Step 3),
which is where decide() reads it from when it builds an event. Fields that are unknown until
an event arrives (customer_id, tracking_number) are typed as optional; fields with a
sensible zero value (total, status) get one.
status is a plain str here to keep the tutorial short. In a real aggregate you
would reach for an enum:
from enum import StrEnum
class OrderStatus(StrEnum):
PENDING = "pending"
CREATED = "created"
SHIPPED = "shipped"
CANCELLED = "cancelled"
Pydantic validates enum members on assignment and serializes them as strings, so
swapping status: str for status: OrderStatus = OrderStatus.PENDING costs nothing and
buys you a typo-proof invariant check when decide() matches on it in Step 6.
total is a Decimal, not a float. Money in a float accumulates rounding error,
and this value will be reconstructed from JSON on every replay. Pydantic handles
Decimal round-tripping for you.
State is not the event, and it is not the database row¶
Two habits from other architectures are worth unlearning right now.
State is not a mirror of the events. OrderCreated will carry customer_id and
total; OrderShipped will carry tracking_number. OrderState holds all of those
together, flattened, with no notion of which event put them there. If you find yourself
adding a last_event_type field, that is the version counter's job, not the state's.
State is not the read model. Resist putting anything in OrderState that decide()
will never read. Display names, formatted currency strings, denormalized customer
addresses -- those belong in a projection (see
Projections). The aggregate's state exists to answer the question
"is this command allowed?", and it is loaded and rebuilt on every single command, so
keep it small.
A useful test: for each field, name the decide() branch that reads it. status is
read by every branch to reject invalid transitions. customer_id and total are there because a real system would check them
(a refund limit, an ownership check) and because they make the folded state visible when
you print it in Step 8. tracking_number records the shipment. Nothing else earns a
slot.
Should the state be frozen?¶
DomainEvent (and, as you will see in the next step, DomainCommand) sets
model_config = ConfigDict(frozen=True) because both are records of an intent or a fact
that must never change after the fact. OrderState is different -- it is a value
evolve() replaces on every applied event, so freezing it is optional.
Leaving it mutable is fine as long as you keep the discipline of building a new state
object in evolve() rather than mutating fields in place, which is what the
model_copy(update={...}) pattern in Step 5 does. If you would rather have pydantic
enforce that discipline for you, add the config:
from pydantic import BaseModel, ConfigDict
class OrderState(BaseModel):
model_config = ConfigDict(frozen=True)
customer_id: UUID | None = None
# ... as above
With frozen=True, state.status = "shipped" raises a ValidationError instead of
silently succeeding, and model_copy(update={...}) remains the only way forward. The
rest of this tutorial works identically either way; the snippets use the unfrozen
version and never mutate in place.
With the state modelled, the next step is the facts that move it from one OrderState
to the next.
Step 2: Define the domain events (OrderCreated, OrderShipped, OrderCancelled)¶
Now write the facts. Each event is a DomainEvent subclass, and it carries only the
fields that this thing happening introduces -- not a snapshot of the whole order.
Add to first_aggregate.py:
from eventsource import DomainEvent
class OrderCreated(DomainEvent):
aggregate_type: str = "Order"
customer_id: UUID
total: Decimal
class OrderShipped(DomainEvent):
aggregate_type: str = "Order"
tracking_number: str
class OrderCancelled(DomainEvent):
aggregate_type: str = "Order"
reason: str
Three classes, six payload lines. Everything else an event needs -- who it belongs to,
when it happened, what version it is -- comes from DomainEvent.
What you get for free¶
DomainEvent already declares the envelope fields, so you never redeclare them:
| Field | Default | Notes |
|---|---|---|
event_id |
uuid4() |
unique per event instance |
event_type |
class name | auto-derived, see below |
event_version |
1 |
schema version, for later migrations |
occurred_at |
datetime.now(UTC) |
timezone-aware |
aggregate_id |
required | filled by decide() in Step 6 |
aggregate_type |
required | we default it to "Order" above |
aggregate_version |
1 |
filled by execute()'s stamping, see Step 9 |
tenant_id, actor_id, causation_id |
None |
filled from the command by execute(), see Step 9 |
correlation_id |
uuid4() |
links events across aggregates and commands |
metadata |
{} |
free-form dict |
Two of those are declared with ... (required) on the base class: aggregate_id and
aggregate_type. Giving aggregate_type a default of "Order" on each event class
means you can construct one in a test without repeating it, and it documents which
aggregate the event belongs to right at the class definition. aggregate_id stays
required -- there is no sensible default for "which order" -- and Step 6 shows decide()
supplying it from the state it was handed.
event_type derives itself from the class name¶
You do not write event_type = "OrderCreated". DomainEvent.__init_subclass__ sets the
field default to the class name at class-definition time, and a
model_validator(mode="before") does the same when an event is built from a dict
(model_validate, from_dict). So:
event = OrderCreated(aggregate_id=uuid4(), customer_id=uuid4(), total=Decimal("42.00"))
assert event.event_type == "OrderCreated"
If you do set event_type explicitly and it differs from the class name -- which is
what you want when renaming a class without rewriting stored history -- the library logs
a warning. Silence it with the class variable:
class OrderCreated(DomainEvent):
event_type: str = "order.created.v2"
suppress_event_type_warning = True
aggregate_type: str = "Order"
customer_id: UUID
total: Decimal
Leave it auto-derived for this tutorial.
Events are frozen¶
DomainEvent sets model_config = ConfigDict(frozen=True). An event records something
that already happened, so mutating it is meaningless:
This is one of two places the library forces immutability on you (the other is the
command you are about to write), and it is the reason evolve() in Step 5 builds a new
OrderState instead of editing the event.
Choosing the payload¶
Each event answers "what changed?", and nothing more:
OrderCreatedintroduces the customer and the total -- the two facts that exist only once the order does.OrderShippedintroduces the tracking number. It does not repeatcustomer_id; the fold in Step 5 already has that fromOrderCreated.OrderCancelledcarries areason, because "why" is a fact worth keeping and there is nowhere else to keep it.
Notice what is absent: no status field. The status is derived -- evolve() sets it
to "shipped" when it sees an OrderShipped. Putting a status field on the event
would let the two disagree. The same rule rules out new_total-style fields that restate
the whole aggregate: store the delta or the new fact, and let the fold compute the rest.
Also notice the names are past tense. OrderShipped, not ShipOrder. ShipOrder is the
command you write next -- a request that might be refused (Step 6 refuses one). An event
is what remains after the refusal could no longer happen.
Keep the payload types the same as the state types -- total: Decimal here matches
total: Decimal on OrderState, so no conversion happens in evolve(), and the value
round-trips through JSON with pydantic's Decimal handling intact.
A note on the registry¶
Nothing above registers these classes anywhere. Auto-derivation of event_type happens
at subclass creation, but adding the class to the global EventRegistry is explicit --
you apply the @register_event decorator, or call registry.register(OrderCreated).
You need that only when something has to turn a stored event_type string back into a
class, which is a concern for the event store, not for the in-memory aggregate you are
building here. Registering a second class under a name already taken raises
DuplicateEventTypeError, so it is a deliberate step rather than a silent one.
With the facts defined, the next step is the requests that might produce them.
Step 3: Define the commands (CreateOrder, ShipOrder, CancelOrder)¶
An event is a fact: it happened, full stop. A command is a request that the domain is
free to refuse. eventsource gives requests their own base class, DomainCommand, so
the distinction is visible in the type system and not just in a naming convention.
Add to first_aggregate.py:
from eventsource import DomainCommand
class CreateOrder(DomainCommand):
order_id: UUID
customer_id: UUID
total: Decimal
class ShipOrder(DomainCommand):
order_id: UUID
tracking_number: str
class CancelOrder(DomainCommand):
order_id: UUID
reason: str
OrderCommand = CreateOrder | ShipOrder | CancelOrder
Three classes, and every one of them names the order it targets. Commands carry no
aggregate_type -- a command does not belong to a stream the way an event does -- but
they do carry the aggregate id, because that is the only route by which decide(), a
pure function with no self, learns which order it is deciding about. decide() copies
it onto every event it returns as aggregate_id, which is why initial_state() in
Step 4 needs no arguments.
OrderCommand, the union of all three, is the vocabulary of everything this order can
be asked to do. Step 4 hands it to DeciderAggregate as a second type parameter, which
is what lets your type checker reject a command this aggregate was never meant to
receive -- see Why the second type parameter.
What you get for free¶
Like DomainEvent, DomainCommand declares an envelope so you only write the payload:
| Field | Default | Notes |
|---|---|---|
command_id |
uuid4() |
becomes the causation_id of every event this command produces |
issued_at |
datetime.now(UTC) |
timezone-aware |
correlation_id |
uuid4() |
a fresh chain by default; see caused_by() below |
actor_id |
None |
who issued the command, if you track that |
tenant_id |
None |
falls back to the ambient tenant context if unset -- see Step 9 |
Commands are never persisted¶
This is the load-bearing difference from events. CreateOrder and ShipOrder are never
written to an event store, never registered, never serialized for storage. A rejected
command -- Step 6 rejects one -- leaves no trace at all: no version bump, no event, no
row anywhere. Only what decide() decided to allow becomes a stored fact. That is why
there is no @register_event-equivalent for commands and no command bus in this
library: a command's entire lifetime is the one decide() call that consumes it.
Commands are frozen, and have no causation_id of their own¶
command = ShipOrder(order_id=order_id, tracking_number="1Z999")
command.tracking_number = "1Z000" # pydantic ValidationError: instance is frozen
Notice DomainCommand has no causation_id field, even though events do. That is
deliberate: within one workflow, event -> command -> event linkage is expressed
through correlation_id, not causation_id. If a saga reacts to an event by issuing a
new command, it calls command.caused_by(event) to copy that event's correlation_id
onto the new command, continuing the chain without needing an event-to-command
causation_id field. You will see the other end of that chain -- command to event -- in
Step 9, where execute() copies command_id onto each event's causation_id.
With state, events, and commands all defined, you have every ingredient the domain needs. Next you connect them with an aggregate class.
Step 4: Subclass DeciderAggregate[OrderState, OrderCommand] and set aggregate_type¶
Now the class itself. Add this to first_aggregate.py:
from eventsource import DeciderAggregate
class OrderAggregate(DeciderAggregate[OrderState, OrderCommand]):
aggregate_type = "Order"
@staticmethod
def initial_state() -> OrderState:
return OrderState()
Three lines of substance, and each one carries weight.
The [OrderState] parameter is not decoration¶
DeciderAggregate subclasses AggregateRoot, declared [TState: BaseModel](ABC). Writing
DeciderAggregate[OrderState, OrderCommand] tells the type checker that self.state is an
OrderState, so state.status in Step 6 is checked rather than guessed at. It also
does real work at runtime: it is how _get_state_type() resolves the class snapshots
get rehydrated into. Always write the parameter.
Why the second type parameter¶
TCommand is what makes execute() and decide() speak your domain's vocabulary
rather than accepting anything at all. It defaults to object, so
DeciderAggregate[OrderState] on its own is valid and still type checks -- but the
default is the permissive one:
With DeciderAggregate[OrderState], that line passes a type check. Nothing in the
signature says an order only accepts order commands, so the mistake survives until
runtime, where it falls through decide()'s final case _ and raises
CommandRejectedError("unknown command: ...").
With DeciderAggregate[OrderState, OrderCommand], your type checker rejects it before
you run anything:
error: Argument 1 to "execute" of "DeciderAggregate" has incompatible type
"Unrelated"; expected "CreateOrder | ShipOrder | CancelOrder" [arg-type]
That is the whole benefit, and it costs one union declaration. Write the second parameter whenever your commands form a closed set -- which is nearly always. The one-parameter form stays available for the case where an aggregate genuinely accepts commands it cannot enumerate ahead of time.
aggregate_type is the stream's name¶
AggregateRoot declares aggregate_type: ClassVar[str] with no default, and you are
required to set it -- a subclass that doesn't raises AggregateTypeNotSetError at
construction. It is a plain string on the class -- not a property, not derived from
the class name -- so OrderAggregate becomes "Order" only because you said so. That
string ends up in three places: on every event execute() stamps (it must agree with
the aggregate_type you defaulted on each event class in Step 2), in the event
store's stream identity (get_events(aggregate_id, aggregate_type)), and in
AggregateRepository, which refuses to build a repository for a class whose
aggregate_type is empty.
Because it is baked into stored events, treat aggregate_type as permanent. Renaming
the Python class from OrderAggregate to PurchaseOrderAggregate is free; changing the
string from "Order" to "PurchaseOrder" orphans every event already written under the
old name. Pick a short domain noun -- "Order", not "OrderAggregate" and not
"orders" -- and leave it alone.
initial_state() is a static method, and it is called for you¶
This is the first real departure from the two-abstract-method AggregateRoot you might
have already met (or will meet in the "other styles" section below). There,
_get_initial_state() is a factory the base class never invokes on your behalf -- your
own _apply() has to call it, and self.state is None until it does. Here,
DeciderAggregate.__init__ calls self.initial_state() immediately and
assigns the result:
That prints OrderState(customer_id=None, total=Decimal('0'),
status='pending', tracking_number=None) -- calling a @staticmethod needs no aggregate
instance, which is why this runs even though OrderAggregate cannot be instantiated
yet (decide and evolve are still unimplemented; more on that in Step 6). Once the
class is complete, DeciderAggregate.__init__ calls exactly this method for you, so
order.state is never None on a DeciderAggregate -- accessing it before any event
has been applied hands back the blank order initial_state() built, not an exception
and not None. That is what lets decide() in Step 6 pattern-match on state.status
unconditionally, with no if state is None guard anywhere in the domain code.
initial_state() is a @staticmethod for the same reason decide() and evolve()
will be: none of the three touch self, only their arguments. That is what makes them
independently testable -- OrderAggregate.initial_state() is a plain function
call, no aggregate instance required.
Keep it cheap and deterministic¶
initial_state() runs once per instance, in __init__, and (as you will see in Step
10) once more per load_from_history() call on a fresh instance. Two rules follow:
- No I/O. No database reads, no clock lookups that end up in state, no random values. The initial state must be identical every time it is built, or replaying the same history twice would produce two different aggregates.
- It takes no arguments, and that is the point. "Before anything has happened" is one value for the whole aggregate type, not one per order. There is nothing to derive from, and nothing you need to: everything else arrives as an event.
If you catch yourself wanting a parameter here -- a tenant, a currency, a customer --
that is a fact about the order, and facts arrive in OrderCreated. If you want the
order id, it is on the command that decide() is holding.
With a starting state defined, the aggregate needs a way to fold events into it.
Step 5: Implement evolve() to fold events into state¶
evolve() takes the current state and one event, and returns the next state. Add it to
the class:
class OrderAggregate(DeciderAggregate[OrderState, OrderCommand]):
aggregate_type = "Order"
@staticmethod
def initial_state() -> OrderState:
return OrderState()
@staticmethod
def evolve(state: OrderState, event: DomainEvent) -> OrderState:
match event:
case OrderCreated(customer_id=customer_id, total=total):
return state.model_copy(
update={"customer_id": customer_id, "total": total, "status": "created"}
)
case OrderShipped(tracking_number=tracking_number):
return state.model_copy(
update={"tracking_number": tracking_number, "status": "shipped"}
)
case OrderCancelled():
return state.model_copy(update={"status": "cancelled"})
case _:
return state
Read it as a fold: one match arm per event type, each returning a new state derived
from the old one plus the event's payload, and a fallthrough that returns the state
unchanged.
match/case, not isinstance¶
Structural pattern matching does two things an isinstance chain cannot: it binds the
fields you need (case OrderCreated(customer_id=customer_id, total=total): both checks
the type and unpacks the payload in one line) and it makes "no case matched" a single
explicit case _: rather than an implicit fallthrough at the end of an if/elif chain.
Every event class this aggregate emits gets one case; the wildcard exists for events a
future version of the system might append that this class does not yet know about.
model_copy(update={...}) replaces the state, it doesn't mutate it¶
state.model_copy(update={...}) builds a new OrderState and returns it; state
itself is untouched. This is the discipline Step 1 mentioned, and it is why freezing
OrderState costs you nothing: the pattern already never assigns to a field.
Two properties fall out of it. Folding an event is atomic -- either the whole new state is built and returned, or nothing changes -- and any state object a caller captured earlier (in a test, in a snapshot, in a log line) stays valid.
One sharp edge to know about: model_copy(update=...) does not run validation.
Pydantic copies the values in as-is:
state = OrderState()
state.model_copy(update={"total": "not-a-number"}).total
# 'not-a-number' -- a str sitting in a Decimal field, no error
In practice this is fine, because the values you pass come off an event that pydantic
did validate when it was constructed. That is the real reason Step 2 told you to keep
event field types identical to state field types: event.total is already a Decimal,
so copying it into total: Decimal needs no conversion and no re-validation. If you ever
find yourself computing or coercing a value inside evolve(), construct the state
directly (OrderState(...)) instead, which does validate.
evolve() must not decide anything¶
This is the rule that keeps event sourcing honest: evolve() never rejects an event
and never raises. By the time an event reaches the fold it is a historical fact -- it
either already happened in production, or decide() just decided it should. Refusing to
fold it in would make the aggregate's state disagree with its own history.
So no validation, no raise, no invariant checks. Those live entirely in decide()
(Step 6), which runs before an event exists. The division of labour is:
| Decides whether it may happen | Updates state | |
|---|---|---|
decide() (Step 6) |
yes -- may raise | no |
evolve() |
no -- never raises | yes |
Two more things evolve() must not do, for the same reason it must be deterministic:
- No I/O and no side effects. No database writes, no HTTP calls, no email.
evolve()runs again for every event on every single load of the aggregate; anything with an external effect would fire once per replay. Reacting to events belongs in a projection or subscriber, not in the fold. - No
datetime.now(), nouuid4(). If a timestamp matters, the event carries it --event.occurred_atis right there. Replaying the same history must always produce the same state, and a clock read inside the fold breaks that.
Because evolve() is a @staticmethod that only touches its two arguments, both of
these rules are easy to audit: there is no self to smuggle a side effect through.
With a fold in place, the aggregate can turn history into state. Next it needs a way to decide what history to add.
Step 6: Implement decide() -- the business-rule home¶
This is where the domain logic lives, and it is the one method allowed to say no. Add
it to the class, alongside evolve():
from eventsource import CommandRejectedError
class OrderAggregate(DeciderAggregate[OrderState, OrderCommand]):
# ... aggregate_type, initial_state, evolve as above
@staticmethod
def decide(command: OrderCommand, state: OrderState) -> list[DomainEvent]:
match command, state:
case CreateOrder(
order_id=order_id, customer_id=customer_id, total=total
), OrderState(status="pending"):
return [
OrderCreated(
aggregate_id=order_id, customer_id=customer_id, total=total
)
]
case CreateOrder(), _:
raise CommandRejectedError("order already exists", command=command)
case ShipOrder(order_id=order_id, tracking_number=tracking_number), OrderState(
status="created"
):
return [
OrderShipped(aggregate_id=order_id, tracking_number=tracking_number)
]
case ShipOrder(), OrderState(status="cancelled"):
raise CommandRejectedError("cannot ship a cancelled order", command=command)
case ShipOrder(), _:
raise CommandRejectedError("order is not ready to ship", command=command)
case CancelOrder(), OrderState(status="cancelled"):
raise CommandRejectedError("order is already cancelled", command=command)
case CancelOrder(reason=reason), _:
return [OrderCancelled(aggregate_id=order_id, reason=reason)]
case _:
raise CommandRejectedError(f"unknown command: {command!r}", command=command)
The class is now concrete. initial_state, decide, and evolve are all declared
@staticmethod and @abstractmethod on DeciderAggregate, so until this step
OrderAggregate(uuid4()) would have raised:
TypeError: Can't instantiate abstract class OrderAggregate without an
implementation for abstract method 'decide'
At this point initial_state and evolve are already complete from the previous steps,
so only decide is outstanding. DeciderAggregate asks for one more abstract method
than the base AggregateRoot (which only requires _apply() and
_get_initial_state()), because the decide/evolve split is itself the contract.
With all three written, OrderAggregate(uuid4()) builds cleanly.
Matching on (command, state) together¶
Each case matches a pair: what was asked, and what is true right now. That pairing
is the whole reason decide() takes state as an argument instead of reading
self.state -- the function is pure, so every branch is a plain, testable fact about
"given this command and this state, what happens?" You can call
OrderAggregate.decide(CreateOrder(...), OrderState()) directly in a
unit test, no aggregate instance, no event store, no repository.
The order of the case arms matters, as it does in any match: put the arm that should
succeed before the arms that reject the same command type for other reasons. ShipOrder
has three arms -- succeed when status="created", a specific rejection message when the
order is cancelled, and a generic rejection for every other status -- so the error
message a caller sees is as precise as the arms you bothered to write.
Rejecting with CommandRejectedError¶
raise CommandRejectedError("cannot ship a cancelled order", command=command) is the
convention this library ships for "the domain said no" -- not a requirement, just one
catchable type your application code can rely on instead of inventing its own per
aggregate. It takes a message and, optionally, the command that was rejected, which
execute() (Step 8) never sees because it does not catch the exception -- that is your
caller's job:
order_id = uuid4()
order = OrderAggregate(order_id)
order.execute(CreateOrder(order_id=order_id, customer_id=uuid4(), total=Decimal("10.00")))
order.execute(ShipOrder(order_id=order_id, tracking_number="1Z999"))
try:
order.execute(ShipOrder(order_id=order_id, tracking_number="1Z000"))
except CommandRejectedError as e:
print(f"rejected: {e}")
print(e.command)
Running that prints rejected: order is not ready to ship followed by the second
ShipOrder command's repr -- the order shipped once already, so the state's status
is "shipped", matching the generic ShipOrder(), _: arm rather than the
OrderState(status="created") arm that succeeds.
Rejection is atomic¶
The reason decide() is a separate function from evolve(), rather than one method
that both validates and mutates, is what happens when it raises: nothing. execute()
(Step 8) calls decide() to completion before touching the aggregate at all. If it
raises, no evolve() call ever happens, self.version does not advance, and
uncommitted_events gains nothing. A rejected command leaves the aggregate exactly as
it found it -- which is also why, as Step 3 noted, a rejected command is never persisted:
there is nothing to persist.
decide() returns events, it does not apply them¶
Notice decide() never touches self._state or calls evolve() itself -- it returns a
plain list[DomainEvent], and it is the aggregate's execute() method (Step 8) that
takes that list, stamps each event, and folds it in via evolve(). That separation is
what makes decide() safe to call in a test without an aggregate instance at all, and
it is what makes multi-event commands (a command that produces two or three events)
just as easy to write as single-event ones: return however many events belong together.
With the domain fully specified, the next step is watching it run.
Step 7: Run it and watch state, version, and uncommitted_events¶
Add a small driver to the bottom of first_aggregate.py:
from uuid import uuid4
order_id = uuid4()
order = OrderAggregate(order_id)
print(order.state)
print(order.version)
print(order.uncommitted_events)
order.execute(CreateOrder(order_id=order_id, customer_id=uuid4(), total=Decimal("42.00")))
print(order.state)
print(order.version)
print(len(order.uncommitted_events))
order.execute(ShipOrder(order_id=order_id, tracking_number="1Z999"))
print(order.state)
print(order.version)
Run the file. The first three lines show the aggregate before anything has happened:
state is the blank OrderState initial_state() built (status "pending", not
None), version is 0, and uncommitted_events is empty. After execute(CreateOrder(...)),
state.status is "created", version is 1, and there is one uncommitted event.
After execute(ShipOrder(...)), state.status is "shipped" and version is 2.
state and version move together because execute() (which you will look inside in
Step 8) does the same three things every time: run decide(), stamp the resulting
events with the next version number, and apply each one -- which both advances
self._version and calls evolve(). uncommitted_events accumulates every stamped
event across every execute() call until something clears it -- typically the
repository, after persisting them.
Step 8: What execute() does under the hood¶
You have been calling execute() since Step 6 without looking inside it. It is
inherited from DeciderAggregate, and it is worth reading once so "run decide(), stamp
the events, fold them in" stops being a black box:
def execute(self, command: TCommand) -> list[DomainEvent]:
events = self.decide(command, self.state)
applied: list[DomainEvent] = []
for event in events:
stamped = self._stamp(event, command)
self.apply_event(stamped, is_new=True)
applied.append(stamped)
return applied
Four things happen, in order: decide() runs to completion first (so a raised
CommandRejectedError never reaches the loop); each returned event is stamped (Step 9
covers exactly what that fills in); apply_event(stamped, is_new=True) -- the same
method every aggregate style shares -- sets self._version to the stamped
aggregate_version, calls evolve() to fold the event in, and appends it to
uncommitted_events; and the stamped events are returned, in case a caller wants them
(a test asserting on exactly what was produced, say).
TCommand in that signature is the second type parameter you supplied in Step 4, so on
OrderAggregate it reads execute(self, command: OrderCommand) -- that substitution is
what produces the type error shown in
Why the second type parameter.
You never call evolve() or touch self._version yourself. execute() is the one
front door, and it is the only method on DeciderAggregate that is not a @staticmethod
-- it needs self to read the current state, advance the version counter, and mutate
uncommitted_events.
Step 9: What gets stamped onto each event¶
Look again at what decide() builds: OrderCreated(aggregate_id=order_id,
customer_id=customer_id, total=total) -- the order_id captured off the command. No aggregate_version, no aggregate_type, no
causation_id. Those are exactly the fields execute()'s stamping step (_stamp())
fills in before the event is applied, and it is worth seeing what lands where. Add this
after the ShipOrder call from Step 7:
created_event, shipped_event = order.uncommitted_events
print(shipped_event.causation_id is not None)
print(shipped_event.causation_id != created_event.causation_id)
Both print True. Every stamped event gets aggregate_version (from
get_next_version()) and aggregate_type (from the class attribute) if decide()
didn't already set them explicitly -- which is why Step 2's aggregate_type: str =
"Order" default and Step 4's aggregate_type = "Order" class attribute have to agree;
stamping only fills in what model_fields_set shows as not already set.
Because both CreateOrder and ShipOrder are DomainCommand instances, stamping goes
further: each event's causation_id becomes the command's command_id, so
created_event.causation_id points at the CreateOrder that produced it and
shipped_event.causation_id points at the separate ShipOrder -- two different
commands, two different causation_ids, which is exactly what the second print above
confirms. correlation_id, actor_id, and tenant_id are copied across the same way,
with tenant_id falling back to the ambient tenant context (see
Multi-Tenancy) when neither the command nor the event set it.
If decide() had built an event with aggregate_version already set -- useful in tests
that assert on an exact version number -- stamping would leave it alone; the check is
always "did decide() already decide this field," never "overwrite unconditionally."
This is the traceability ADR-0022 introduced DomainCommand for: every event can be
traced back to the command that caused it, and every command in a workflow can be traced
back to the event that triggered it, via caused_by() (Step 3). Neither the imperative
nor the declarative style (see the end of this tutorial) gets this for free from a plain
method call -- both need an explicit command= argument to create_event() to opt in.
Step 10: Rebuild the order with load_from_history()¶
Everything so far has lived in one OrderAggregate instance. The payoff of event
sourcing is that the same history replayed into a different instance produces
identical state. Add this to the bottom of the file:
history = order.uncommitted_events
replayed = OrderAggregate(order.aggregate_id)
replayed.load_from_history(history)
assert replayed.state == order.state
assert replayed.version == order.version
print("replay matches")
load_from_history() is unchanged from the base AggregateRoot -- it is not part of
the decider contract at all. It loops over the events you hand it and calls
apply_event(event, is_new=False) for each: version tracking happens, evolve() runs
just as it did live, but nothing is appended to uncommitted_events and version
validation is skipped, so a history starting at any version replays cleanly. The is_new
flag is the only thing that distinguishes "this event is new, queue it for persistence"
(execute(), Step 8) from "this event already happened, just fold it" (load_from_history(),
here).
replayed starts from initial_state() -- the same blank order
order itself started from -- and folds the identical two events through the identical
evolve(). There is exactly one place that turns events into state on this class, and
you wrote it once in Step 5; that is why the assertion holds.
Other styles¶
DeciderAggregate is the style this library recommends and the one its own examples and
tests lead with, but it is not the only way to write an aggregate, and you will likely
run into the other two in an existing codebase.
The imperative style subclasses AggregateRoot[TState] directly and hand-writes an
_apply() method as an isinstance chain, with command methods that call
create_event() instead of returning events from a pure function. It has no decide()/
evolve() split -- validation and folding both happen inside command methods and
_apply() -- and self.state is None until the first event lands. See
examples/imperative_example.py for a complete BankAccountAggregate built this way.
The declarative style subclasses DeclarativeAggregate[TState] and replaces the
isinstance chain with one @handles(EventType)-decorated method per event type,
dispatched through a per-subclass registry instead of a hand-written chain. It shares
the imperative style's command-method-plus-create_event() shape, but scales better once
an aggregate handles half a dozen event types. See examples/aggregate_example.py for a
complete ShoppingCartAggregate built this way.
Both styles are still AggregateRoot subclasses underneath, so everything you learned
here about aggregate_type, load_from_history(), snapshots, and the repository applies
to them unchanged -- only how state gets mutated differs. For a full comparison,
including how to move an aggregate from one style to another, read
Aggregate Styles.
Key Takeaways¶
DeciderAggregate[TState, TCommand]needs three static methods:initial_state(),decide(command, state), andevolve(state, event). None of them touchself.initial_state()takes no arguments. The aggregate a command targets is named by the command, anddecide()stamps that id onto the events it returns.stateis neverNoneon aDeciderAggregate--initial_state()runs eagerly in__init__, sodecide()andevolve()never need a null check.- Commands are
DomainCommandsubclasses: frozen, never persisted, and structurally distinct from events because they might be refused. decide()is the only place allowed to raise. RaiseCommandRejectedErrorfor a refusal; a rejected command leaves the aggregate untouched and nothing is persisted.evolve()must be total and side-effect free: no raising, no I/O, no clocks -- just the next state.execute(command)runsdecide(), stamps the resulting events (version, type, and -- forDomainCommands --causation_id/correlation_id/actor_id/tenant_id), and folds each one in withevolve().load_from_history()is shared by every aggregate style unchanged: replay the events,is_new=False, nothing queued for persistence.- The imperative (
AggregateRoot+_apply()) and declarative (DeclarativeAggregate+@handles) styles remain available for existing code and for aggregates that don't fit the decider shape; see Aggregate Styles.
Next Steps¶
With a working aggregate, the next step is testing it properly. Continue to Testing Your Aggregates, or read ahead to Building Projections to see these events drive a read model.