Mutating Aggregates
DDD CQRS ES
In DDD, aggregates are not passive data containers. Aggregates enforce business rules. If external code could freely set fields on an aggregate (the "anemic domain model" anti-pattern), that code would bypass invariants and could leave the aggregate in an invalid state without any error. Instead, state changes happen through methods on the aggregate that encapsulate the business logic, enforce invariants, and raise events.
Typical Workflow
A typical workflow of a state change is depicted below:
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. Below is the aggregate method that mutates state:
from protean import Domain, invariant
from protean.exceptions import ValidationError
from protean.fields import Float, Identifier
banking = Domain()
@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 ValidationError(
{"_entity": ["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
You change state within an aggregate by updating its attributes.
@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 ValidationError(
{"_entity": ["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 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',
'_version': -1}
If the change does not satisfy an invariant, Protean raises a ValidationError
that carries the invariant's message under the field it names.
In [1]: account = Account(account_number="1234", balance=1000.0, overdraft_limit=50.0)
In [2]: account.withdraw(1100.0)
---------------------------------------------------------------------------
ValidationError Traceback (most recent call last)
...
ValidationError: {'_entity': ['Balance cannot be below overdraft limit']}
How It Works
Every field assignment on an aggregate or entity (self.x = value) is
intercepted by __setattr__, which runs a full validation cycle:
- Pre-invariants fire:
@invariant.premethods check whether the current state allows the proposed change. - Protean validates the assignment: The field's type, constraints
(
required,max_length,choices, etc.) are enforced. If validation fails, aValidationErroris raised and the assignment never takes effect. - Post-invariants fire:
@invariant.postmethods verify the aggregate remains in a valid state after the change. If one fails, aValidationErroris raised and the field goes back to its previous value. - The entity is marked as changed: Protean's internal
_EntityStatetracks the mutation so the Unit of Work knows to persist it.
Because __setattr__ intercepts each field assignment, every individual
assignment triggers the full invariant cycle. If you need to change multiple
fields together (where intermediate states would be invalid), use
atomic_change. This example uses the Order
aggregate from the Invariants guide:
from protean import atomic_change
def add_third_item(order):
with atomic_change(order):
order.total_amount = 120.0
order.add_items(
OrderItem(product_id="3", quantity=2, price=10.0, subtotal=20.0)
)
Within atomic_change, pre-invariants fire on entry, individual assignment
checks are suspended, and post-invariants fire on exit. See
Invariants, Atomic Changes for details.
Identifier Immutability
Identifier fields (marked with identifier=True or using the Auto/
Identifier field type) cannot be changed once set. Attempting to reassign
an identifier raises InvalidOperationError:
In [1]: account.id = "new-id"
...
InvalidOperationError: Identifiers cannot be changed once set
Child Entity Mutations
When a child entity's attribute is changed, the root aggregate's invariants fire, not just the entity's own. As a result, Protean always enforces cross-entity business rules, including for mutations to nested entities in the aggregate cluster.
Event-Sourced Aggregates
For event-sourced aggregates, state is never mutated directly in
business methods. Instead, business methods raise events via raise_(),
and the framework automatically invokes the corresponding @apply
handler to perform the state change:
@domain.event(part_of="Order")
class OrderConfirmed:
order_id: Identifier(required=True)
@domain.aggregate(event_sourced=True)
class Order:
status: String(max_length=20, default="PENDING")
def confirm(self):
# Don't set self.status here. Raise an event instead.
self.raise_(OrderConfirmed(order_id=self.id))
@apply
def when_confirmed(self, event: OrderConfirmed):
# State mutation happens here, triggered by raise_()
self.status = "CONFIRMED"
With @apply handlers, the aggregate runs the same code path for a live
command and for reconstruction from stored events.
The raise_() method wraps the @apply call inside atomic_change(). As a
result, Protean checks invariants before and after the state change and
preserves the "always valid" guarantee.
See Raising Events for full
details on the raise_() + @apply integration.
See also
Concept overview: Aggregates: Aggregate consistency, invariants, and state management.
Related guides:
- Invariants: Business rules that enforce aggregate consistency.
- Raising Events: Recording and propagating state changes as domain events.
- Validations: Field-level constraints enforced during mutation.
Patterns:
- Encapsulate State Changes: Protecting aggregate internals with controlled mutation methods.
- Aggregate State Machines: Modeling aggregate lifecycle transitions.
- Thin Handlers, Rich Domain: Keeping business logic in the aggregate, not the handler.