Getting Started¶
In this tutorial you will build a working event-sourced bank account from scratch,
using nothing but Python and the eventsource package. No database, no Docker, no
message broker — everything runs in memory so you can focus on the ideas.
By the end you will have written domain events, a decider — the aggregate style this library recommends — that enforces business rules with a pair of pure functions, and a repository that saves and reloads that aggregate from an event store. Along the way you will watch the decider reject a command that would break its invariants.
Follow the steps in order and type (or copy) each snippet as you go. Every code block shows its own imports, so the finished file at the end of Step 4 runs top to bottom with nothing missing.
Everything you write here uses the in-memory event store. The same code runs unchanged against PostgreSQL, SQLite, Redis, RabbitMQ, or Kafka by swapping the store object you construct in Step 4 — see Optional Backends below.
Expect this to take about 15 minutes.
First, the Smallest Thing That Works¶
Before the walkthrough, here is the whole idea in one runnable file. It is a wallet, not a bank account, and it has exactly one event, one command, and no business rules — but it saves to an event store and rebuilds itself from history, which is the entire point of the library. Everything in the rest of this tutorial is elaboration on this shape.
Save it as wallet.py and run python wallet.py:
import asyncio
from decimal import Decimal
from uuid import UUID, uuid4
from pydantic import BaseModel
from eventsource import (
AggregateRepository,
DeciderAggregate,
DomainCommand,
DomainEvent,
InMemoryEventStore,
register_event,
)
@register_event
class MoneyDeposited(DomainEvent):
aggregate_type: str = "Wallet"
amount: Decimal
class Deposit(DomainCommand):
wallet_id: UUID
amount: Decimal
class WalletState(BaseModel):
balance: Decimal = Decimal("0")
class Wallet(DeciderAggregate[WalletState, Deposit]):
aggregate_type = "Wallet"
@staticmethod
def initial_state() -> WalletState:
return WalletState()
@staticmethod
def decide(command: Deposit, state: WalletState) -> list[DomainEvent]:
return [MoneyDeposited(aggregate_id=command.wallet_id, amount=command.amount)]
@staticmethod
def evolve(state: WalletState, event: DomainEvent) -> WalletState:
match event:
case MoneyDeposited(amount=amount):
return state.model_copy(update={"balance": state.balance + amount})
case _:
return state
async def main() -> None:
repo = AggregateRepository(event_store=InMemoryEventStore(), aggregate_factory=Wallet)
wallet_id = uuid4()
wallet = repo.create_new(wallet_id)
wallet.execute(Deposit(wallet_id=wallet_id, amount=Decimal("25")))
await repo.save(wallet)
reloaded = await repo.load(wallet_id)
print(f"balance: {reloaded.state.balance}")
print(f"version: {reloaded.version}")
asyncio.run(main())
Five pieces: an event (MoneyDeposited), a command (Deposit), a state
(WalletState), a decider that turns one into the other (Wallet), and a
repository that moves events to and from a store. reloaded never saw the deposit
happen — it was rebuilt by replaying the one stored event through evolve().
If that made sense, the walkthrough below adds the parts a real domain needs: multiple events, invariants that can refuse a command, and an explanation of every line.
What You'll Build¶
You will build a BankAccount — the smallest domain that still has real invariants to
protect.
Three events make up the account's history:
| Event | Fields | Meaning |
|---|---|---|
AccountOpened |
owner_name, initial_balance |
the account came into existence |
MoneyDeposited |
amount |
money was added |
MoneyWithdrawn |
amount |
money was taken out |
Each is a frozen pydantic model registered with the library's event registry by the
@register_event decorator so it can be rebuilt from storage later. None of them
declares event_type by hand — see Step 1 for why.
Around those events you will write:
AccountState— a frozen pydanticBaseModelholdingowner_name,balance, andis_open. This is derived data, not the source of truth — it exists sodecide()has something to read.- Three commands —
OpenAccount,Deposit, andWithdraw, each aDomainCommandsubclass carrying the intent's payload. BankAccountAggregate— aDeciderAggregate[AccountState, AccountCommand]withaggregate_type = "BankAccount", a staticinitial_state()that returns an unopened account, a staticdecide()that turns a command plus the current state into the events it produces (or raisesCommandRejectedError), and a staticevolve()that folds each event into a newAccountState. Business rules — insufficient funds, a double-open, a non-positive amount — live indecide()and nowhere else.- An
InMemoryEventStoreand anAggregateRepository— the persistence seam. You will callrepo.create_new(...), run commands withaccount.execute(...),await repo.save(account), andawait repo.load(account_id), and watch the aggregate's version climb by one for every event applied.
Then you will try a command that should fail — withdrawing more than the balance — to
confirm the decider refuses it with CommandRejectedError rather than silently
recording a bad event.
Projections and subscriptions — the read side of the same events — are their own tutorial once you are comfortable here; see Projections.
What you will not need: no database, no Docker, no message broker, no
configuration files. InMemoryEventStore ships in the core package, so the only
install is eventsource-py itself. Optional backends are mentioned at the very end,
once the concepts are in place.
Prerequisites¶
Before you start, make sure of three things.
Python 3.13 or newer. The package declares requires-python = ">=3.13".
Check yours:
A virtual environment to work in. Everything you install below lands in one directory you can throw away afterwards:
Comfort with async/await. The event store and repository APIs are async, and
Step 4's snippet lives inside an async def main() launched with asyncio.run(main()).
decide() and evolve() themselves are plain synchronous functions — a decider is
never async. If you have written an asyncio script before, you know enough.
You do not need prior event sourcing experience — that is what the tutorial is for.
Familiarity with pydantic helps, since events, commands, and aggregate state are all
pydantic BaseModel subclasses, but each model is explained as it appears.
You also do not need a database, a message broker, Docker, or any configuration files.
The in-memory event store ships in the core package, so the entire tutorial runs from
one pip install.
Installation¶
With your virtual environment activated, install the package:
That is the whole install for this tutorial. The distribution is named
eventsource-py on PyPI but you import it as eventsource:
The core package depends on pydantic (v2), sqlalchemy (v2), and orjson, and it brings
along everything the next four steps need: DomainEvent, DomainCommand,
register_event, DeciderAggregate, CommandRejectedError, AggregateRepository,
and InMemoryEventStore.
Check that the install worked:
If that prints ok, you are ready for Step 1.
Optional Backends¶
Storage backends and message brokers are extras — you opt into them by name, and each one pulls in its own driver. You do not need any of them now; they are listed here so you know what to reach for when you swap the in-memory store out at the end of the tutorial.
| Extra | Install | Enables |
|---|---|---|
postgresql |
pip install "eventsource-py[postgresql]" |
PostgreSQL event store, snapshots, outbox, advisory locks (asyncpg) |
sqlite |
pip install "eventsource-py[sqlite]" |
SQLite event store and snapshots (aiosqlite) |
redis |
pip install "eventsource-py[redis]" |
Redis event bus |
rabbitmq |
pip install "eventsource-py[rabbitmq]" |
RabbitMQ event bus (aio-pika) |
kafka |
pip install "eventsource-py[kafka]" |
Kafka event bus (aiokafka) |
telemetry |
pip install "eventsource-py[telemetry]" |
OpenTelemetry tracing integration |
There is also a kafka-schema-registry extra, which adds confluent-kafka on top of
aiokafka for schema-registry-backed Kafka setups, and two convenience bundles:
eventsource-py[all-backends] for both database backends, and eventsource-py[all]
for every extra in the table at once.
Quoting matters — most shells treat unquoted square brackets as glob characters, so
write pip install "eventsource-py[postgresql]" with the quotes.
Because these are optional, the library guards each driver import behind an
availability flag and raises a clear error — RedisNotAvailableError and its siblings —
if you construct a backend whose driver is missing, rather than failing at import time.
Installing an extra later never requires changing the code you write in this tutorial;
only the store object you construct in Step 4 changes.
Step 1: Define Your Events¶
Events are the source of truth. Everything else in this tutorial — state, the decider, the balance you print at the end — is derived by replaying them. So this is the step that matters most: get the events right and the rest follows.
Create a file called bank.py and start with the imports:
import asyncio
from decimal import Decimal
from uuid import UUID, uuid4
from pydantic import BaseModel
from eventsource import (
AggregateRepository,
CommandRejectedError,
DeciderAggregate,
DomainCommand,
DomainEvent,
InMemoryEventStore,
register_event,
)
Now define the three things that can happen to a bank account. Add these classes to
bank.py:
@register_event
class AccountOpened(DomainEvent):
"""Event emitted when a bank account is opened."""
aggregate_type: str = "BankAccount"
owner_name: str
initial_balance: Decimal
@register_event
class MoneyDeposited(DomainEvent):
"""Event emitted when money is deposited."""
aggregate_type: str = "BankAccount"
amount: Decimal
@register_event
class MoneyWithdrawn(DomainEvent):
"""Event emitted when money is withdrawn."""
aggregate_type: str = "BankAccount"
amount: Decimal
Three classes, three facts. Each one is a DomainEvent subclass sharing
aggregate_type = "BankAccount", registered with the library's event registry by the
@register_event decorator sitting above it. The payload fields are the only thing
you had to think about: owner_name and initial_balance for the opening, amount
for the two money movements — and Decimal, never float, for anything that is
money. Notice how short each class is — the base class already carries event_id,
occurred_at, aggregate_id, and aggregate_version, so your definitions stay
focused on the domain.
There is no __init__, no save, no behaviour of any kind. Events are records, not
actors; the decider in Step 3 is what decides when one gets created.
Nothing runs a command yet, but the classes are not inert either: simply importing
this module executes the three @register_event decorators, and that is what lets the
store turn stored rows back into AccountOpened, MoneyDeposited, and
MoneyWithdrawn instances when you reload the account.
What Each Piece Does¶
DomainEvent subclass. DomainEvent is a pydantic BaseModel, so your event is
one too: annotate the payload fields you care about (owner_name: str,
amount: Decimal) and pydantic validates them on construction. The base class already
supplies the plumbing every event needs — event_id, occurred_at (a UTC timestamp),
aggregate_id, aggregate_version, event_version, plus optional tenant_id,
actor_id, correlation_id, causation_id, and a metadata dict. You never declare
those; you just get them.
event_type. The string written into storage to identify this event on the way
back out. You never declare it: DomainEvent.__init_subclass__ auto-derives
event_type from the class name, so class AccountOpened(DomainEvent) gets
"AccountOpened" for free, with no line of code and nothing that can drift out of
sync with the class. Declare it explicitly only for the one case that needs a name
different from the class — pinning a versioned wire name once a schema has shipped,
e.g. event_type: str = "account_opened_v2" on a class named AccountOpenedV2, so
old rows deserialize under a name the class itself no longer matches.
aggregate_type. Which kind of thing this event happened to. All three events use
"BankAccount", which is what groups them into one stream. The decider you write in
Step 3 declares the same string as a class attribute — that's the single source of
truth. The AggregateRepository you build in Step 4 infers its own notion of the
type from that attribute; there is no separate place to declare or override it, so
the two can never drift apart.
@register_event. Adds the class to the library's event registry, keyed by
event_type. When the store loads a stream it finds rows containing a type name and a
JSON payload; the registry is how it turns "MoneyDeposited" back into your
MoneyDeposited class. Forget the decorator and events save fine but come back
unrecognizable. The decorator works bare (@register_event) or called
(@register_event(event_type="account.opened")) when you want the registry key to
differ from the class.
Two Conventions Worth Internalizing¶
Name events in the past tense, commands in the imperative. AccountOpened, not
OpenAccount — that name belongs to the command in Step 2. A command is a request
that may be refused; an event is a fact that already happened and cannot be argued
with. By the time an event exists, the decision is made.
Events are frozen. DomainEvent sets model_config = ConfigDict(frozen=True), so
assigning to a field on an existing event raises a pydantic
ValidationError. That is deliberate: history is append-only. Correcting a mistake
means appending a new event that reverses it, never editing the old one — which is
exactly why an audit trail built from events can be trusted.
One more design note before moving on. MoneyDeposited carries only amount, not the
resulting balance. Events record what happened, not what everything looks like
afterwards. The balance is computed by folding the events together, which is what you
build in the next two steps.
Step 2: Model State and Commands¶
Events are the truth; state is a convenience. AccountState is the answer to "what
does this account look like right now?" — a value decide() can read a balance off
without walking the whole history itself. Commands are the other half: requests that
may or may not be granted.
Add these to bank.py, below the events:
class AccountState(BaseModel):
"""Current, derived view of a bank account."""
model_config = {"frozen": True}
owner_name: str = ""
balance: Decimal = Decimal("0")
is_open: bool = False
class OpenAccount(DomainCommand):
"""Request to open a new bank account."""
account_id: UUID
owner_name: str
initial_balance: Decimal = Decimal("0")
class Deposit(DomainCommand):
"""Request to deposit money into an account."""
account_id: UUID
amount: Decimal
class Withdraw(DomainCommand):
"""Request to withdraw money from an account."""
account_id: UUID
amount: Decimal
AccountCommand = OpenAccount | Deposit | Withdraw
Reading AccountState¶
It is a plain, frozen pydantic BaseModel — not a library base class, not a special
"state" type, just a model you define. The library only asks two things of it:
pydantic validates it on construction, and model_dump(mode="json") /
model_validate(...) can round-trip it (that pair is what snapshotting uses).
Frozen means evolve() in Step 3 produces a new instance with
model_copy(update={...}) on every event rather than mutating one in place — the same
discipline events themselves follow, applied to their fold.
- No id field. The state is the fold of one account's events, and which account
that is comes from the command — every one of the three carries
account_id, anddecide()stamps it onto the events it returns. That is whyinitial_state()takes no arguments: the state before anything has happened is the same value for every account. owner_name,balance,is_open— defaulted, and the defaults describe an account that does not exist yet: nameless, empty, closed. That is whatinitial_state()returns before any event has been applied.
Every field here is recoverable from the event stream: balance is
initial_balance plus the deposits minus the withdrawals, owner_name comes from
AccountOpened. If you deleted this class and rewrote it with different fields, the
stored events would not change at all — you would simply be folding them up a
different way.
Reading the commands¶
OpenAccount, Deposit, and Withdraw each subclass DomainCommand, a frozen
pydantic model like DomainEvent but for requests rather than facts. The base class
manages command_id, correlation_id, and actor_id for you — you only declare the
payload the decider needs to evaluate the request: which account, and (for two of the
three) how much money. AccountCommand, the union of all three, is what makes
DeciderAggregate[AccountState, AccountCommand] in Step 3 a two-parameter subscript —
it is what your editor and mypy use to check that decide() handles every command you
defined, with no case silently missing.
With state and commands modelled, you have every input decide() and evolve() need.
Step 3 writes the two functions themselves.
Step 3: Build the Decider¶
The decider is where events, state, and commands meet. decide() owns every business
rule: given a command and the current state, it returns the events that command
produces, or raises CommandRejectedError and produces nothing. evolve() owns the
fold: given the current state and an event, it returns the next state. Neither
function touches self — they are the whole domain, and everything else (replay,
version tracking, snapshots, provenance stamping) is inherited from
DeciderAggregate.
Add this class to bank.py, below AccountCommand:
class BankAccountAggregate(DeciderAggregate[AccountState, AccountCommand]):
"""Event-sourced bank account, decider style."""
aggregate_type = "BankAccount"
@staticmethod
def initial_state() -> AccountState:
"""Return the state of an account before any event has occurred."""
return AccountState()
@staticmethod
def decide(command: AccountCommand, state: AccountState) -> list[DomainEvent]:
"""Given current state, return the events a command produces, or raise."""
match command, state:
case OpenAccount(), AccountState(is_open=True):
raise CommandRejectedError("account already opened", command=command)
case OpenAccount(initial_balance=initial_balance), _ if initial_balance < 0:
raise CommandRejectedError(
"initial balance cannot be negative", command=command
)
case OpenAccount(
account_id=account_id, owner_name=owner_name, initial_balance=initial_balance
), _:
return [
AccountOpened(
aggregate_id=account_id,
owner_name=owner_name,
initial_balance=initial_balance,
)
]
case Deposit(), AccountState(is_open=False):
raise CommandRejectedError("account is not open", command=command)
case Deposit(amount=amount), _ if amount <= 0:
raise CommandRejectedError(
"deposit amount must be positive", command=command
)
case Deposit(account_id=account_id, amount=amount), _:
return [MoneyDeposited(aggregate_id=account_id, amount=amount)]
case Withdraw(), AccountState(is_open=False):
raise CommandRejectedError("account is not open", command=command)
case Withdraw(amount=amount), _ if amount <= 0:
raise CommandRejectedError(
"withdrawal amount must be positive", command=command
)
case Withdraw(amount=amount), AccountState(balance=balance) if amount > balance:
raise CommandRejectedError(
f"insufficient balance: {balance}", command=command
)
case Withdraw(account_id=account_id, amount=amount), _:
return [MoneyWithdrawn(aggregate_id=account_id, amount=amount)]
@staticmethod
def evolve(state: AccountState, event: DomainEvent) -> AccountState:
"""Return the next state after an event. Total: unknown events pass through."""
match event:
case AccountOpened(owner_name=owner_name, initial_balance=initial_balance):
return state.model_copy(
update={
"owner_name": owner_name,
"balance": initial_balance,
"is_open": True,
}
)
case MoneyDeposited(amount=amount):
return state.model_copy(update={"balance": state.balance + amount})
case MoneyWithdrawn(amount=amount):
return state.model_copy(update={"balance": state.balance - amount})
case _:
return state
Reading decide()¶
Read it top to bottom as guard-then-grant, one command at a time. OpenAccount
against an already-open account, a negative starting balance, a closed account
receiving Deposit or Withdraw, a non-positive amount, an overdraft — each of those
is a match arm that raises CommandRejectedError with a message that says why, and
nothing else runs. The arm below it is what happens when nothing objects: construct
the event (or events — decide() returns a list, so one command producing several
facts is a first-class case, not a workaround) and return it. decide() never calls
evolve() and never touches self. It is a pure function of (command, state) — no
I/O, no side effects, easy to unit test by calling it directly with no aggregate
involved at all.
The overdraft check — amount > balance — is the rule you will watch fail in Step 4.
It fires before any event is constructed, so a rejected withdrawal never touches the
event stream. CommandRejectedError is "the domain said no": it is what you raise for
a business-rule refusal, as distinct from a programming error.
Reading evolve()¶
evolve() is a dispatch table with one case per event type — the mirror image of
decide(). AccountOpened sets the account up from nothing; the two money events fold
into whatever state already exists, producing a new instance with
model_copy(update={...}) rather than assigning to a field. evolve() must be
total: the case _: return state at the end means an event type this function
does not recognize leaves state unchanged instead of raising, which matters because
evolve() runs during every replay of every event ever recorded, including ones added
by a future version of this code that an older evolve() has never seen.
Two rules about evolve() that are easy to get wrong and painful to debug:
No validation here. evolve() must never raise on a business rule. It runs during
replay as well as on new commands, and history is not negotiable — an event that was
accepted in 2024 must still be applicable in 2026, even if the rules have since
tightened. Validation belongs in decide().
No side effects here. No emails, no HTTP calls, no writes. Every time an aggregate is loaded from the store, every event in its stream is replayed through this method. Anything with an effect would fire again on every load.
What DeciderAggregate gives you for free¶
aggregate_type = "BankAccount" is the class attribute that must match the
aggregate_type on your events and the string you pass to the repository in Step 4.
The base class requires it — a subclass that doesn't set it raises
AggregateTypeNotSetError at construction, rather than quietly ending up with events
nobody can find.
You never call execute() — the one public entry point AggregateRoot inherits from
DeciderAggregate — until Step 4, but it is worth knowing what it does: it runs
decide(command, self.state), and for every event that comes back, stamps
aggregate_version and aggregate_type (plus causation_id, correlation_id,
actor_id, and tenant_id from the command, whenever the event didn't set them
itself) and applies it. decide() completes in full before any event is applied, so a
rejection leaves the aggregate exactly as it was — no partial state, no version bump,
nothing queued.
You now have a complete decider: a state to start from, a function that decides whether a command is allowed, and a function that folds the events it produces into new state. Nothing is persisted yet — Step 4 fixes that.
Step 4: Persist It¶
Add the persistence seam to bank.py and run the whole thing. This is the finished
file — the earlier snippets, plus a main() that exercises it end to end.
async def main() -> None:
store = InMemoryEventStore()
repo: AggregateRepository[BankAccountAggregate] = AggregateRepository(
event_store=store,
aggregate_factory=BankAccountAggregate,
)
account_id = uuid4()
account = repo.create_new(account_id)
account.execute(
OpenAccount(account_id=account_id, owner_name="Ada Lovelace", initial_balance=Decimal("100"))
)
account.execute(Deposit(account_id=account_id, amount=Decimal("50")))
await repo.save(account)
print(f"version after save: {account.version}")
print(f"uncommitted after save: {account.uncommitted_events}")
reloaded = await repo.load(account_id)
print(f"reloaded balance: {reloaded.state.balance}")
print(f"reloaded version: {reloaded.version}")
try:
reloaded.execute(Withdraw(account_id=account_id, amount=Decimal("1000")))
except CommandRejectedError as e:
print(f"rejected: {e}")
print(f"balance after rejected withdrawal: {reloaded.state.balance}")
if __name__ == "__main__":
asyncio.run(main())
repo.create_new(account_id) constructs a BankAccountAggregate at version 0 with no
events applied — DeciderAggregate.__init__ calls initial_state() for you, so
account.state is already a real AccountState, never None. Two calls to
execute() run decide() and evolve() and queue two events in
account.uncommitted_events; await repo.save(account) appends them to the store and
clears that list — account.version is 2 after the print above, and
uncommitted_events is empty.
await repo.load(account_id) reads the stream back from the store, replays every
event through evolve(), and hands you a fresh BankAccountAggregate whose state
matches what you saved — reloaded.state.balance prints 150. Then the rejected
Withdraw proves the point of Step 3: CommandRejectedError propagates out of
execute(), the account's balance is unchanged, and nothing new was appended to the
stream.
Run it:
version after save: 2
uncommitted after save: []
reloaded balance: 150
reloaded version: 2
rejected: insufficient balance: 150
balance after rejected withdrawal: 150
That is the whole loop: events as the source of truth, a decider that turns commands into events (or refuses them), and a repository that moves those events between memory and storage. Everything past this point — projections, subscriptions, other backends — builds on exactly this pattern; see Projections to keep going, or aggregate styles if you want to see the same domain written the other two ways this library supports.
Testing what you just wrote¶
You do not have to hand-roll fixtures for any of this. The package ships a test
toolkit at eventsource.testing:
DeciderScenario exercises decide() and evolve() as the pure functions they are —
given some past events, when a command, then these events (or this rejection) — with
no store, no bus, and no event loop involved. InMemoryTestHarness gives you a
pre-wired in-memory store, bus, and repository when you want to test the persistence
seam too, alongside the given_events / when_command / then_event_published BDD
helpers, EventBuilder for constructing test events, EventAssertions for readable
failures, and RecordingEventBus for asserting on what was published.
The same module exports the port conformance suites — AppenderConformance,
StreamReaderConformance, CheckpointRepositoryConformance and a dozen siblings.
Subclass one and supply a store fixture and your own backend is held to exactly the
contract the built-in adapters are held to; see
Validate a Custom Backend.