Chapter 2: Deposits and Withdrawals
An account that can only be opened is not much use. In this chapter we will add deposits and withdrawals, multiple event types flowing through a single aggregate. Along the way, we will see a core principle of Event Sourcing: all state changes flow exclusively through events.
New Events
A deposit and a withdrawal are two distinct facts:
@domain.event(part_of="Account")
class DepositMade:
account_id: Identifier(required=True)
amount: Float(required=True)
reference: String()
@domain.event(part_of="Account")
class WithdrawalMade:
account_id: Identifier(required=True)
amount: Float(required=True)
reference: String()
Each event captures the data needed to describe what happened. The reference
field is optional. It records why the transaction occurred (a paycheck, a
refund, a grocery purchase).
Domain Methods and Apply Handlers
Now we add domain methods to the Account aggregate and the
corresponding @apply handlers:
@domain.aggregate(event_sourced=True)
class Account:
account_number: String(max_length=20, required=True)
holder_name: String(max_length=100, required=True)
balance: Float(default=0.0)
status: String(max_length=20, default="ACTIVE")
@classmethod
def open(cls, account_number: str, holder_name: str, opening_deposit: float):
account = cls._create_new()
account.raise_(
AccountOpened(
account_id=str(account.id),
account_number=account_number,
holder_name=holder_name,
opening_deposit=opening_deposit,
)
)
return account
def deposit(self, amount: float, reference: str | None = None) -> None:
if amount <= 0:
raise ValidationError({"amount": ["Deposit amount must be positive"]})
self.raise_(
DepositMade(
account_id=str(self.id),
amount=amount,
reference=reference,
)
)
def withdraw(self, amount: float, reference: str | None = None) -> None:
if amount <= 0:
raise ValidationError({"amount": ["Withdrawal amount must be positive"]})
if amount > self.balance:
raise ValidationError({"amount": ["Insufficient funds"]})
self.raise_(
WithdrawalMade(
account_id=str(self.id),
amount=amount,
reference=reference,
)
)
@apply
def on_account_opened(self, event: AccountOpened):
self.id = event.account_id
self.account_number = event.account_number
self.holder_name = event.holder_name
self.balance = event.opening_deposit
self.status = "ACTIVE"
@apply
def on_deposit_made(self, event: DepositMade):
self.balance += event.amount
@apply
def on_withdrawal_made(self, event: WithdrawalMade):
self.balance -= event.amount
Notice the separation of concerns:
- Domain methods (
deposit(),withdraw()) contain validation logic. They check that the amount is positive and that funds are sufficient. If validation passes, they callraise_(). @applyhandlers contain pure state mutations. They update the balance. No validation, no side effects, just state changes.
This separation is critical. During replay (when loading from the event store),
only the @apply handlers run. The validation in deposit() and withdraw()
does not re-execute. Those events already happened and were validated at the
time. Replay trusts the event history.
Multiple Events, One Aggregate
Let's exercise the full lifecycle:
if __name__ == "__main__":
with domain.domain_context():
# Open an account
account = Account.open(
account_number="ACC-001",
holder_name="Alice Johnson",
opening_deposit=1000.00,
)
# Make several transactions
account.deposit(500.00, reference="paycheck")
account.deposit(200.00, reference="refund")
account.withdraw(150.00, reference="groceries")
# Persist — all four events are written to the event store
repo = domain.repository_for(Account)
repo.add(account)
# Retrieve — all four events are replayed
loaded = repo.get(account.id)
print(f"Account: {loaded.holder_name} ({loaded.account_number})")
print(f"Balance: ${loaded.balance:.2f}") # 1000 + 500 + 200 - 150 = 1550
print(f"Version: {loaded._version}")
# Each event incremented the version
assert loaded.balance == 1550.00
assert loaded._version == 3 # 0-indexed: events 0, 1, 2, 3
Run it:
$ python fidelis.py
Account: Alice Johnson (ACC-001)
Balance: $1550.00
Version: 3
The balance is $1,550.00: the opening deposit of $1,000 plus $500 and $200 in
deposits, minus the $150 withdrawal. But this number was never stored. It was
computed by replaying four events through their @apply handlers.
Version Tracking
Each event increments the aggregate's _version. After four events
(opened, two deposits, one withdrawal), the version is 3 (0-indexed).
The version serves as an optimistic concurrency check, if two processes try to
modify the same account simultaneously, the second one will detect a version
conflict.
Validation at the Domain Boundary
Try withdrawing more than the balance:
# Try an invalid withdrawal
try:
loaded.withdraw(10000.00)
except ValidationError as e:
print(f"\nRejected: {e.messages}")
Output:
Rejected: {'amount': ['Insufficient funds']}
The validation in withdraw() catches the problem before any event
is raised. No event means no state change. The aggregate remains
consistent.
Note
This validation lives in the domain method, not in the @apply
handler. In the next chapters we will move business rules into
invariants, which validate state
after every @apply handler runs.
What We Built
- DepositMade and WithdrawalMade events describing financial transactions.
- Domain methods that validate inputs and raise events.
@applyhandlers that mutate state from events.- An aggregate that derives its balance from multiple events.
- Version tracking that increments with each event.
The aggregate is starting to feel like a real ledger. Next, we will add commands and the processing pipeline so external systems can interact with our domain through typed contracts.
Full Source
from protean import Domain, apply
from protean.exceptions import ValidationError
from protean.fields import Float, Identifier, String
domain = Domain("fidelis")
@domain.event(part_of="Account")
class AccountOpened:
account_id: Identifier(required=True)
account_number: String(required=True)
holder_name: String(required=True)
opening_deposit: Float(required=True)
@domain.event(part_of="Account")
class DepositMade:
account_id: Identifier(required=True)
amount: Float(required=True)
reference: String()
@domain.event(part_of="Account")
class WithdrawalMade:
account_id: Identifier(required=True)
amount: Float(required=True)
reference: String()
@domain.aggregate(event_sourced=True)
class Account:
account_number: String(max_length=20, required=True)
holder_name: String(max_length=100, required=True)
balance: Float(default=0.0)
status: String(max_length=20, default="ACTIVE")
@classmethod
def open(cls, account_number: str, holder_name: str, opening_deposit: float):
account = cls._create_new()
account.raise_(
AccountOpened(
account_id=str(account.id),
account_number=account_number,
holder_name=holder_name,
opening_deposit=opening_deposit,
)
)
return account
def deposit(self, amount: float, reference: str | None = None) -> None:
if amount <= 0:
raise ValidationError({"amount": ["Deposit amount must be positive"]})
self.raise_(
DepositMade(
account_id=str(self.id),
amount=amount,
reference=reference,
)
)
def withdraw(self, amount: float, reference: str | None = None) -> None:
if amount <= 0:
raise ValidationError({"amount": ["Withdrawal amount must be positive"]})
if amount > self.balance:
raise ValidationError({"amount": ["Insufficient funds"]})
self.raise_(
WithdrawalMade(
account_id=str(self.id),
amount=amount,
reference=reference,
)
)
@apply
def on_account_opened(self, event: AccountOpened):
self.id = event.account_id
self.account_number = event.account_number
self.holder_name = event.holder_name
self.balance = event.opening_deposit
self.status = "ACTIVE"
@apply
def on_deposit_made(self, event: DepositMade):
self.balance += event.amount
@apply
def on_withdrawal_made(self, event: WithdrawalMade):
self.balance -= event.amount
domain.init(traverse=False)
if __name__ == "__main__":
with domain.domain_context():
# Open an account
account = Account.open(
account_number="ACC-001",
holder_name="Alice Johnson",
opening_deposit=1000.00,
)
# Make several transactions
account.deposit(500.00, reference="paycheck")
account.deposit(200.00, reference="refund")
account.withdraw(150.00, reference="groceries")
# Persist — all four events are written to the event store
repo = domain.repository_for(Account)
repo.add(account)
# Retrieve — all four events are replayed
loaded = repo.get(account.id)
print(f"Account: {loaded.holder_name} ({loaded.account_number})")
print(f"Balance: ${loaded.balance:.2f}") # 1000 + 500 + 200 - 150 = 1550
print(f"Version: {loaded._version}")
# Each event incremented the version
assert loaded.balance == 1550.00
assert loaded._version == 3 # 0-indexed: events 0, 1, 2, 3
# Try an invalid withdrawal
try:
loaded.withdraw(10000.00)
except ValidationError as e:
print(f"\nRejected: {e.messages}")
print("\nAll checks passed!")