Skip to content

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:

Sequence diagram

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:

  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. If one fails, a ValidationError is raised and the field goes back to its previous value.
  4. The entity is marked as changed: Protean's internal _EntityState tracks 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: