Chapter 4: Business Rules That Never Break
A tester discovers a problem: withdraw $200 from an account with $100
and the withdrawal succeeds — because the validation in withdraw()
checks the balance before the @apply handler runs. What if the
validation and the state mutation disagree?
In this chapter we will add invariants — rules that are checked after every state mutation, guaranteeing the aggregate is always valid regardless of how it got there.
The Problem with Method-Level Validation
In Chapter 2, our withdraw() method checked if amount > self.balance.
This works for the simple case, but it has a subtle issue: the check
happens before raise_() calls the @apply handler. If any future
code path changes the balance between the check and the event, the
validation could be bypassed.
Invariants solve this by validating the aggregate's state after
the @apply handler has already mutated it.
Post-Invariants
Add two invariants to the Account aggregate:
@invariant.post
def balance_must_not_be_negative(self):
if self.balance is not None and self.balance < 0:
raise ValidationError(
{"balance": ["Insufficient funds: balance cannot be negative"]}
)
@invariant.post
def closed_account_must_have_zero_balance(self):
if self.status == "CLOSED" and self.balance != 0:
raise ValidationError(
{"status": ["Cannot close account with non-zero balance"]}
)
Here is how they work:
-
When
raise_()is called on an event-sourced aggregate, Protean:- Records the event
- Calls the matching
@applyhandler (state is mutated) - Runs all
@invariant.postmethods - If any invariant raises
ValidationError, the event is rejected and state is rolled back
-
balance_must_not_be_negativeensures no operation can leave the account with a negative balance. We no longer need the manual check inwithdraw()— the invariant handles it. -
closed_account_must_have_zero_balanceensures an account cannot be closed while funds remain. This rule would be difficult to enforce with method-level checks alone because it spans two fields (statusandbalance).
Closing an Account
We also add an AccountClosed event and a close() method:
@domain.event(part_of="Account")
class AccountClosed:
account_id: Identifier(required=True)
reason: String()
def close(self, reason: str = None) -> None:
self.raise_(
AccountClosed(
account_id=str(self.id),
reason=reason,
)
)
And the corresponding @apply handler:
@apply
def on_account_closed(self, event: AccountClosed):
self.status = "CLOSED"
Invariants in Action
Let's exercise both invariants:
if __name__ == "__main__":
with domain.domain_context():
# Open an account with $100
account_id = domain.process(
OpenAccount(
account_number="ACC-001",
holder_name="Alice Johnson",
opening_deposit=100.00,
)
)
print(f"Account opened: {account_id}")
# Try to withdraw $200 — the invariant will catch this
try:
domain.process(MakeWithdrawal(account_id=account_id, amount=200.00))
except ValidationError as e:
print(f"Overdraft rejected: {e.messages}")
# Try to close with a non-zero balance — invariant catches this too
try:
domain.process(
CloseAccount(account_id=account_id, reason="Customer request")
)
except ValidationError as e:
print(f"Close rejected: {e.messages}")
# Withdraw all funds first, then close
domain.process(MakeWithdrawal(account_id=account_id, amount=100.00))
domain.process(CloseAccount(account_id=account_id, reason="Customer request"))
repo = domain.repository_for(Account)
account = repo.get(account_id)
print(f"\nAccount status: {account.status}")
print(f"Balance: ${account.balance:.2f}")
assert account.status == "CLOSED"
assert account.balance == 0.0
print("\nAll checks passed!")
Run it:
$ python fidelis.py
Account opened: acc-001-uuid...
Overdraft rejected: {'balance': ['Insufficient funds: balance cannot be negative']}
Close rejected: {'status': ['Cannot close account with non-zero balance']}
Account status: CLOSED
Balance: $0.00
All checks passed!
The overdraft attempt was caught by balance_must_not_be_negative —
the @apply handler reduced the balance to -$100, the invariant
detected the violation, and Protean rolled back the event.
The close attempt was caught by closed_account_must_have_zero_balance
— the @apply handler set status = "CLOSED", but the invariant
saw the balance was still $100 and rejected the transition.
Why Invariants Are Better
Invariants have important advantages over method-level checks:
| Method-Level Checks | Invariants |
|---|---|
| Run before the event | Run after the @apply handler |
| Check preconditions | Validate postconditions |
| Can be bypassed by new code paths | Always enforced, every time |
| Do not run during replay | Also enforced during replay |
| Must be duplicated across methods | Defined once, applied everywhere |
Always Valid
Post-invariants guarantee that the aggregate is in a valid state after every single event. This is the "always valid" principle — there is no window where the aggregate exists in an invalid state.
What We Built
- Post-invariants with
@invariant.postthat validate state after every@applyhandler. balance_must_not_be_negative— prevents overdrafts at the aggregate level.closed_account_must_have_zero_balance— enforces a multi-field business rule.- An AccountClosed event and
close()method. - Confidence that business rules are always enforced, regardless of how the aggregate is modified.
Next, we will write proper automated tests using Protean's fluent testing DSL.
Full Source
from protean import Domain, apply, handle, invariant
from protean.exceptions import ValidationError
from protean.fields import Float, Identifier, String
from protean.utils.globals import current_domain
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.event(part_of="Account")
class AccountClosed:
account_id: Identifier(required=True)
reason: String()
@domain.aggregate(is_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")
@invariant.post
def balance_must_not_be_negative(self):
if self.balance is not None and self.balance < 0:
raise ValidationError(
{"balance": ["Insufficient funds: balance cannot be negative"]}
)
@invariant.post
def closed_account_must_have_zero_balance(self):
if self.status == "CLOSED" and self.balance != 0:
raise ValidationError(
{"status": ["Cannot close account with non-zero balance"]}
)
@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:
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:
if amount <= 0:
raise ValidationError({"amount": ["Withdrawal amount must be positive"]})
self.raise_(
WithdrawalMade(
account_id=str(self.id),
amount=amount,
reference=reference,
)
)
def close(self, reason: str = None) -> None:
self.raise_(
AccountClosed(
account_id=str(self.id),
reason=reason,
)
)
@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
@apply
def on_account_closed(self, event: AccountClosed):
self.status = "CLOSED"
@domain.command(part_of=Account)
class OpenAccount:
account_number: String(required=True)
holder_name: String(required=True)
opening_deposit: Float(required=True)
@domain.command(part_of=Account)
class MakeDeposit:
account_id: Identifier(required=True)
amount: Float(required=True)
reference: String()
@domain.command(part_of=Account)
class MakeWithdrawal:
account_id: Identifier(required=True)
amount: Float(required=True)
reference: String()
@domain.command(part_of=Account)
class CloseAccount:
account_id: Identifier(required=True)
reason: String()
@domain.command_handler(part_of=Account)
class AccountCommandHandler:
@handle(OpenAccount)
def handle_open_account(self, command: OpenAccount):
account = Account.open(
account_number=command.account_number,
holder_name=command.holder_name,
opening_deposit=command.opening_deposit,
)
current_domain.repository_for(Account).add(account)
return str(account.id)
@handle(MakeDeposit)
def handle_make_deposit(self, command: MakeDeposit):
repo = current_domain.repository_for(Account)
account = repo.get(command.account_id)
account.deposit(command.amount, reference=command.reference)
repo.add(account)
@handle(MakeWithdrawal)
def handle_make_withdrawal(self, command: MakeWithdrawal):
repo = current_domain.repository_for(Account)
account = repo.get(command.account_id)
account.withdraw(command.amount, reference=command.reference)
repo.add(account)
@handle(CloseAccount)
def handle_close_account(self, command: CloseAccount):
repo = current_domain.repository_for(Account)
account = repo.get(command.account_id)
account.close(reason=command.reason)
repo.add(account)
domain.init(traverse=False)
domain.config["event_processing"] = "sync"
domain.config["command_processing"] = "sync"
if __name__ == "__main__":
with domain.domain_context():
# Open an account with $100
account_id = domain.process(
OpenAccount(
account_number="ACC-001",
holder_name="Alice Johnson",
opening_deposit=100.00,
)
)
print(f"Account opened: {account_id}")
# Try to withdraw $200 — the invariant will catch this
try:
domain.process(MakeWithdrawal(account_id=account_id, amount=200.00))
except ValidationError as e:
print(f"Overdraft rejected: {e.messages}")
# Try to close with a non-zero balance — invariant catches this too
try:
domain.process(
CloseAccount(account_id=account_id, reason="Customer request")
)
except ValidationError as e:
print(f"Close rejected: {e.messages}")
# Withdraw all funds first, then close
domain.process(MakeWithdrawal(account_id=account_id, amount=100.00))
domain.process(CloseAccount(account_id=account_id, reason="Customer request"))
repo = domain.repository_for(Account)
account = repo.get(account_id)
print(f"\nAccount status: {account.status}")
print(f"Balance: ${account.balance:.2f}")
assert account.status == "CLOSED"
assert account.balance == 0.0
print("\nAll checks passed!")