Invariants
DDD CQRS ES
Field-level validations catch type errors, missing values, and range violations — but they cannot express rules that span multiple fields or depend on the aggregate's overall state. For example, "an order's total must equal the sum of its items" or "a shipment can only be dispatched if the order is confirmed" are business rules that no single field constraint can enforce.
Invariants fill this gap. They are business rules or constraints that must
always be true within a domain concept. Protean treats invariants as
first-class citizens, making them explicit and visible with the @invariant
decorator. You can define invariants on Aggregates, Entities, Value Objects,
and Domain Services.
For background on why invariants are fundamental to DDD and how they keep your domain always valid, see Invariants concept.
@invariant decorator
Invariants are defined using the @invariant decorator with either a .pre
or .post qualifier. You must always use @invariant.pre or
@invariant.post — plain @invariant without a qualifier is not valid.
@domain.aggregate
class Order:
customer_id: Identifier()
order_date: Date()
total_amount: Float()
status: String(max_length=50, choices=OrderStatus)
items = HasMany("OrderItem")
@invariant.post
def total_amount_of_order_must_equal_sum_of_subtotal_of_all_items(self):
if self.total_amount != sum(item.subtotal for item in self.items):
raise ValidationError({"_entity": ["Total should be sum of item prices"]})
@invariant.post
def order_date_must_be_within_the_last_30_days_if_status_is_pending(self):
if self.status == OrderStatus.PENDING.value and self.order_date < date(
2020, 1, 1
):
raise ValidationError(
{
"_entity": [
"Order date must be within the last 30 days if status is PENDING"
]
}
)
In the above example, Order aggregate has two invariants (business
conditions), one that the total amount of the order must always equal the sum
of individual item subtotals, and the other that the order date must be within
30 days if status is PENDING.
All methods marked @invariant are associated with the domain element when
the element is registered with the domain.
Invariant methods must raise ValidationError (from
protean.exceptions) when the rule is violated. This is the exception type
the framework catches and aggregates. If an invariant raises a different
exception type, it will propagate directly to the caller instead of being
collected with other invariant violations.
pre and post Invariants
The @invariant decorator has two flavors — pre and post.
post invariants are triggered after elements are constructed or updated.
They ensure that the aggregate is in a valid state after the change.
pre invariants are triggered before elements are updated. They are used
to check whether a proposed change is allowed given the current state.
Pre-invariant example
Pre-invariants are useful when you want to guard against invalid transitions. For example, checking that an account has sufficient balance before a withdrawal:
from protean import Domain, invariant
from protean.exceptions import ValidationError
from protean.fields import Float, Identifier, String
domain = Domain()
@domain.aggregate
class Account:
account_number: Identifier(required=True, unique=True)
balance: Float(default=0.0)
status: String(choices=["ACTIVE", "FROZEN"], default="ACTIVE")
@invariant.pre
def account_must_be_active_to_transact(self):
if self.status == "FROZEN":
raise ValidationError(
{"_entity": ["Cannot modify a frozen account"]}
)
@invariant.post
def balance_must_not_be_negative(self):
if self.balance < 0:
raise ValidationError(
{"_entity": ["Insufficient funds"]}
)
def withdraw(self, amount: float):
self.balance -= amount
When withdraw() is called, the flow is:
- Pre-invariants fire —
account_must_be_active_to_transactchecks the current state. If the account is frozen,ValidationErroris raised and the assignmentself.balance -= amountnever happens. - The attribute assignment
self.balance -= amountexecutes. - Post-invariants fire —
balance_must_not_be_negativechecks the resulting state. If the balance went negative,ValidationErroris raised and the assignment is rolled back.
Note
pre invariants are not applicable when aggregates and entities are being
initialized. Their validations only kick in when an element is being
changed or updated from an existing state.
Note
pre invariant checks are not applicable to ValueObject elements because
they are immutable — they cannot be changed once initialized.
When Invariants Run
Invariant validations are triggered throughout the lifecycle of domain objects. The aggregate is the root of the triggering mechanism — validations are conducted recursively, starting with the aggregate and trickling down into enclosed entities.
Post-Initialization
Immediately after an object (aggregate or entity) is initialized, all post-invariant checks are triggered to ensure the aggregate starts in a valid state.
In [1]: Order(
...: customer_id="1",
...: order_date="2020-01-01",
...: total_amount=100.0,
...: status="PENDING",
...: items=[
...: OrderItem(product_id="1", quantity=2, price=10.0, subtotal=20.0),
...: OrderItem(product_id="2", quantity=3, price=20.0, subtotal=60.0),
...: ],
...:)
ERROR: Error during initialization: {'_entity': ['Total should be sum of item prices']}
...
ValidationError: {'_entity': ['Total should be sum of item prices']}
Attribute Changes
Every attribute change in an aggregate or its enclosed entities triggers invariant validation throughout the aggregate cluster. This ensures that any modification maintains the consistency of the domain model.
In [1]: order = Order(
...: customer_id="1",
...: order_date="2020-01-01",
...: total_amount=100.0,
...: status="PENDING",
...: items=[
...: OrderItem(product_id="1", quantity=4, price=10.0, subtotal=40.0),
...: OrderItem(product_id="2", quantity=3, price=20.0, subtotal=60.0),
...: ],
...: )
...:
In [2]: order.total_amount = 140.0
...
ValidationError: {'_entity': ['Total should be sum of item prices']}
This happens because __setattr__ intercepts every field assignment and runs
the full pre-check → validate → post-check cycle. See
Aggregate Mutation for the complete
mechanism.
Entity-to-Root Delegation
When a child entity's attribute is changed, invariants fire on the root aggregate, not just the entity itself. This ensures that cross-entity business rules (defined on the aggregate) are always enforced, even when the mutation happens deep in the aggregate cluster.
Adding and Removing Entities
add_* and remove_* methods on HasMany associations also trigger
invariants. Both pre-invariants and post-invariants fire, just as they do
for direct attribute assignments.
In [3]: order.add_items(OrderItem(product_id="3", quantity=2, price=10.0, subtotal=20.0))
...
ValidationError: {'_entity': ['Total should be sum of item prices']}
Adding an item changes the sum of item subtotals, which no longer matches
total_amount, so the post-invariant fires immediately.
When adding or removing entities requires coordinated changes (like updating
a total), use atomic_change to batch the mutations.
Atomic Changes
There may be times when multiple attributes need to be changed together, and
validations should not trigger until the entire operation is complete.
The atomic_change context manager can be used to achieve this.
from protean import atomic_change
Within the atomic_change context manager, the cycle works as follows:
- Pre-invariants fire on entry — the current state is validated.
- Invariant checks are suspended during the block — individual assignments do not trigger pre/post checks.
- Post-invariants fire on exit — the final state is validated.
In [1]: from protean import atomic_change
In [2]: order = Order(
...: customer_id="1",
...: order_date="2020-01-01",
...: total_amount=100.0,
...: status="PENDING",
...: items=[
...: OrderItem(product_id="1", quantity=4, price=10.0, subtotal=40.0),
...: OrderItem(product_id="2", quantity=3, price=20.0, subtotal=60.0),
...: ],
...: )
In [3]: with atomic_change(order):
...: order.total_amount = 120.0
...: order.add_items(
...: OrderItem(product_id="3", quantity=2, price=10.0, subtotal=20.0)
...: )
...:
Trying to perform the attribute updates one after another would have resulted
in a ValidationError exception:
In [4]: order.total_amount = 120.0
...
ValidationError: {'_entity': ['Total should be sum of item prices']}
atomic_change is also used internally by raise_() in event-sourced
aggregates to wrap @apply handlers — see
Raising Events for details.
Note
atomic_change can only be applied when updating or changing an already
initialized element.
Invariant Inheritance
Invariants defined on a parent class are inherited by subclasses through
standard Python MRO resolution. A subclass inherits all @invariant.pre and
@invariant.post methods from its parents, and can add its own.
Error Structure
When invariants raise ValidationError, the framework aggregates all
violations into a single ValidationError with a dictionary structure:
ValidationError({
'_entity': ['Total should be sum of item prices'],
'balance': ['Insufficient funds'],
})
The _entity key is a convention for errors that apply to the entity as a
whole rather than a specific field. Multiple invariant violations are collected
and reported together.
See also
Deep dive: The Always-Valid Domain — The complete story of how Protean's four validation layers work together to guarantee your domain objects are never invalid.
Concept overview: Invariants — Why invariants are fundamental to DDD and how they keep your domain always valid.
Related guides:
- Validations — Field-level constraints (Layer 1).
- Status Transitions — Enforcing state machine rules with the
Statusfield. - Aggregate Mutation — The
__setattr__mechanism that triggers invariants. - Domain Services — Cross-aggregate invariants in domain services.
Patterns: Validation Layering — Choosing the right layer for each kind of validation rule.