Skip to content

Mutating Aggregates

DDD CQRS ES

In DDD, aggregates are not passive data containers — they are the guardians of business rules. If external code could freely set fields on an aggregate (the "anemic domain model" anti-pattern), invariants would be bypassed and the aggregate could silently drift into an invalid state. 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:

sequenceDiagram
  autonumber
  ApplicationService->>Repository: Fetch Aggregate
  Repository-->>ApplicationService: Aggregate
  ApplicationService->>Aggregate: Call state change
  Aggregate->>Aggregate: Mutate
  Aggregate-->>ApplicationService: Done
  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. 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

How It Works

Every field assignment on an aggregate or entity (self.x = value) is intercepted by __setattr__, which runs a full validation cycle:

  1. Pre-invariants fire@invariant.pre methods check whether the current state allows the proposed change.
  2. Protean validates the assignment — the field's type, constraints (required, max_length, choices, etc.) are enforced. If validation fails, a ValidationError is raised and the assignment never takes effect.
  3. Post-invariants fire@invariant.post methods verify the aggregate remains in a valid state after the change.
  4. The entity is marked as changed — Protean's internal _EntityState tracks the mutation so the Unit of Work knows to persist it.

This means that 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:

from protean import atomic_change

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. This ensures cross-entity business rules are always enforced, even when mutations happen deep 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.aggregate(is_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"

This ensures the same code path runs whether the aggregate is processing a live command or being reconstructed from stored events.

The raise_() method wraps the @apply call inside atomic_change(), so invariants are checked before and after the state change — the "always valid" guarantee is preserved.

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: