Invariants
Invariants are business rules or constraints that always need to be true within a specific domain concept. They define the fundamental and consistent state of the concept, ensuring it remains unchanged even as other aspects evolve play a crucial role in ensuring business validations within a domain.
Protean treats invariants as first-class citizens, to make them explicit and visible, making it easier to maintain the integrity of the domain model. You can define invariants on Aggregates, Entities, and Value Objects.
Key Facts
- Always Valid: Invariants are conditions that must hold true at all times.
- Declared on Concepts: Invariants are registered along with domain concepts, typically in aggregates as they encapsulate the concept.
- Immediate: Invariants are validated immediately after a domain concept is initialized as well as on changes to any attribute in the aggregate cluster.
- Domain-Driven: Invariants stem from the business rules and policies specific to a domain.
- Enforced by the Domain Model: Protean takes on the responsibility of enforcing invariants.
@invariant
decorator
Invariants are defined using the @invariant
decorator in Aggregates,
Entities, and Value Objects (plus in Domain Services, as we will soon see):
@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.
pre
and post
Invariants
The @invariant
decorator has two flavors - pre
and post
.
pre
invariants are triggered before elements are updated, while post
invariants are triggered after the update. pre
invariants are used to prevent
invalid state from being introduced, while post
invariants ensure that the
aggregate remains in a valid state after the update.
pre
invariants are useful in certain situations where you want to check state
before the elements are mutated. For instance, you might want to check if a
user has enough balance before deducting it. Also, some invariant checks may
be easier to add before changing an element.
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.
Validation
Invariant validations are triggered throughout the lifecycle of domain objects, to ensure all invariants remain satisfied. Aggregates are the root of the triggering mechanism, though. The validations are conducted recursively, starting with the aggregate and trickling down into entities.
Post-Initialization
Immediately after an object (aggregate or entity) is initialized, all invariant checks are triggered to ensure the aggregate remains 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 enclosing 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']}
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.
Within the atomic_change
context manager, validations are temporarily
disabled. Invariant validations are triggered upon exiting the context manager.
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']}
Note
Atomic Changes context manager can only be applied when updating or changing an already initialized element.