Skip to content

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