Mutating Aggregates
The primary mechanism to modify the current state of a domain - to reflect some action or event that has happened - is by mutating its state. Since aggregates encapsulate all data and behavior of concepts in domain, state changes are initiated by invoking state-changing methods on the aggregate.
Typical Workflow
A typical workflow of a state change is depicted below:
sequenceDiagram
autonumber
ApplicationService->>Repository: Fetch Aggregate
Repository-->>ApplicationService: aggregate
ApplicationService->>aggregate: Call state change
aggregate->>aggregate: Mutate
aggregate-->>ApplicationService:
ApplicationService->>Repository: Persist aggregate
An Application Service (or another element from the Application Layer, like Command Handler or Event Handler) loads the aggregate from the repository. It then invokes a method on the aggregate that mutates state. We will dive deeper into the Application layer in a later section, but below is the aggregate method that mutates state:
@banking.event(part_of="Account")
class AccountWithdrawn:
account_number = Identifier(required=True)
amount = Float(required=True)
@banking.aggregate
class Account:
account_number = Identifier(required=True, unique=True)
balance = Float()
overdraft_limit = Float(default=0.0)
@invariant.post
def balance_must_be_greater_than_or_equal_to_overdraft_limit(self):
if self.balance < -self.overdraft_limit:
raise InsufficientFundsException("Balance cannot be below overdraft limit")
def withdraw(self, amount: float):
self.balance -= amount # Update account state (mutation)
self.raise_(AccountWithdrawn(account_number=self.account_number, amount=amount))
Also visible is the invariant (business rule) that the balance should never be below the overdraft limit.
Mutating State
Changing state within an aggregate is straightforward, in the form of attribute updates.
@banking.aggregate
class Account:
account_number = Identifier(required=True, unique=True)
balance = Float()
overdraft_limit = Float(default=0.0)
@invariant.post
def balance_must_be_greater_than_or_equal_to_overdraft_limit(self):
if self.balance < -self.overdraft_limit:
raise InsufficientFundsException("Balance cannot be below overdraft limit")
def withdraw(self, amount: float):
self.balance -= amount # Update account state (mutation)
self.raise_(AccountWithdrawn(account_number=self.account_number, amount=amount))
If the state change is successful, meaning it satisfies all invariants defined on the model, the aggregate immediately reflects the changes.
In [1]: account = Account(account_number="1234", balance=1000.0, overdraft_limit=50.0)
In [2]: account.withdraw(500.0)
In [3]: account.to_dict()
Out[3]:
{'account_number': '1234',
'balance': 500.0,
'overdraft_limit': 50.0,
'id': '73e6826c-cae0-4fbf-b42b-7edefc030968'}
If the change does not satisfy an invariant, exceptions are raised.
In [1]: account = Account(account_number="1234", balance=1000.0, overdraft_limit=50.0)
In [2]: account.withdraw(1100.0)
---------------------------------------------------------------------------
InsufficientFundsException Traceback (most recent call last)
...
InsufficientFundsException: Balance cannot be below overdraft limit