Skip to content

Commit

Permalink
428 Domain Service Enhancements (#430)
Browse files Browse the repository at this point in the history
Changes:
1. `pre` and `post` invariant structures support for Aggregates, Entities,
Value Objects and Domain services.
2. Callable classes for Domain Services.
3. Automatic invariant validation around Domain Service method invocation.
4. Rename `clean()` to `_postcheck`

Fixes #428
  • Loading branch information
subhashb authored May 31, 2024
1 parent 90cd0f3 commit ed41efb
Show file tree
Hide file tree
Showing 38 changed files with 1,123 additions and 281 deletions.
18 changes: 16 additions & 2 deletions docs/guides/domain-behavior/domain-services.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,20 @@ and invokes the service method to place order. The service method executes
the business logic, mutates the aggregates, and returns them to the application
service, which then persists them again with the help of repositories.

## Invariants

Just like Aggregates and Entities, Domain Services can also have invariants.
These invariants are used to validate the state of the aggregates passed to
the service method. Unlike in Aggregates though, invariants in Domain Services
typically deal with validations that span across multiple aggregates.

`pre` invariants check the state of the aggregates before they are mutated,
while `post` invariants check the state after the mutation.

## A full-blown example

```python hl_lines="67-82"
--8<-- "guides/domain-behavior/006.py:16:98"
```python hl_lines="142-149"
{! docs_src/guides/domain-behavior/006.py !}
```

When an order is placed, `Order` status has to be `CONFIRMED` _and_ the stock
Expand All @@ -83,3 +92,8 @@ orders are placed at the same time.
So a Domain Service works best here because it updates the states of both
the `Order` aggregate as well as the `Inventory` aggregate in a single
transaction.

**IMPORTANT**: Even though the inventory aggregate is mutated here to ensure
all invariants are satisified, the Command Handler method invoking the Domain
Service should only persist the `Order` aggregate. The `Inventory` aggregate
will eventually be updated through the domain event `OrderConfirmed`.
43 changes: 38 additions & 5 deletions docs/guides/domain-behavior/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ 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.
visible, making it easier to maintain the integrity of the domain model. You
can define invariants on Aggregates, Entities, and Value Objects.

## Key Facts

Expand All @@ -19,12 +20,12 @@ 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.
enforcing invariants.

## `@invariant` decorator

Invariants are defined using the `@invariant` decorator in Aggregates and
Entities:
Invariants are defined using the `@invariant` decorator in Aggregates,
Entities, and Value Objects (plus in Domain Services, as we will soon see):

```python hl_lines="9-10 14-15"
--8<-- "guides/domain-behavior/001.py:17:41"
Expand All @@ -38,6 +39,34 @@ of individual item subtotals, and the other that the order date must be within
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.

In Protean, we will mostly be using `post` invariants because the domain model
is expected to remain valid after any operation. You would typically start
with the domain in a good state, mutate the elements, and check if all
invariants are satisfied.

`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,
Expand Down Expand Up @@ -129,4 +158,8 @@ 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.
2 changes: 1 addition & 1 deletion docs/guides/domain-definition/fields/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,5 @@ manage related objects efficiently, preserving data integrity across the domain.


<!-- Be careful not to choose field names that conflict with the
[Data Container API](../../api/data-containers) like `clean`, `clone`, or
[Data Container API](../../api/data-containers) like `clone`, or
`to_dict`. -->
19 changes: 19 additions & 0 deletions docs/guides/domain-definition/value-objects.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,25 @@ satisfied at all times.
It is recommended that you always deal with Value Objects by their class.
Attributes are generally used by Protean during persistence and retrieval.

## Invariants

When a validation spans across multiple fields, you can specify it in an
`invariant` method. These methods are executed every time the value object is
initialized.

```python hl_lines="13-16"
{! docs_src/guides/domain-definition/012.py !}
```

```shell hl_lines="3"
In [1]: Balance(currency="USD", amount=-100)
...
ValidationError: {'balance': ['Balance cannot be negative for USD']}
```

Refer to [`invariants`](../domain-behavior/invariants.md) section for a
deeper explanation of invariants.

## Equality

Two value objects are considered to be equal if their values are equal.
Expand Down
8 changes: 4 additions & 4 deletions docs_src/guides/domain-behavior/001.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ class Order:
status = String(max_length=50, choices=OrderStatus)
items = HasMany("OrderItem")

@invariant
@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
@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
Expand All @@ -40,7 +40,7 @@ def order_date_must_be_within_the_last_30_days_if_status_is_pending(self):
}
)

@invariant
@invariant.post
def customer_id_must_be_non_null_and_the_order_must_contain_at_least_one_item(self):
if not self.customer_id or not self.items:
raise ValidationError(
Expand All @@ -62,7 +62,7 @@ class OrderItem:
class Meta:
part_of = Order

@invariant
@invariant.post
def the_quantity_must_be_a_positive_integer_and_the_subtotal_must_be_correctly_calculated(
self,
):
Expand Down
2 changes: 1 addition & 1 deletion docs_src/guides/domain-behavior/002.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class Account:
balance = Float()
overdraft_limit = Float(default=0.0)

@invariant
@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")
Expand Down
95 changes: 72 additions & 23 deletions docs_src/guides/domain-behavior/006.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
from datetime import datetime, timezone
from enum import Enum

from protean import Domain
from protean import Domain, invariant
from protean.exceptions import ValidationError
from protean.fields import (
DateTime,
Float,
Identifier,
Integer,
HasMany,
String,
ValueObject,
)

domain = Domain(__file__)
Expand All @@ -21,14 +23,11 @@ class OrderStatus(Enum):
DELIVERED = "DELIVERED"


@domain.event
@domain.event(part_of="Order")
class OrderConfirmed:
order_id = Identifier(required=True)
confirmed_at = DateTime(required=True)

class Meta:
part_of = "Order"


@domain.aggregate
class Order:
Expand All @@ -37,37 +36,43 @@ class Order:
status = String(choices=OrderStatus, default=OrderStatus.PENDING.value)
payment_id = Identifier()

@invariant.post
def order_should_contain_items(self):
if not self.items or len(self.items) == 0:
raise ValidationError({"_entity": ["Order must contain at least one item"]})

def confirm(self):
self.status = OrderStatus.CONFIRMED.value
self.raise_(
OrderConfirmed(order_id=self.id, confirmed_at=datetime.now(timezone.utc))
)


@domain.entity
@domain.entity(part_of=Order)
class OrderItem:
product_id = Identifier(required=True)
quantity = Integer()
price = Float()

class Meta:
part_of = Order

@domain.value_object(part_of="Inventory")
class Warehouse:
location = String()
contact = String()


@domain.event
@domain.event(part_of="Inventory")
class StockReserved:
product_id = Identifier(required=True)
quantity = Integer(required=True)
reserved_at = DateTime(required=True)

class Meta:
part_of = "Inventory"


@domain.aggregate
class Inventory:
product_id = Identifier(required=True)
quantity = Integer()
warehouse = ValueObject(Warehouse)

def reserve_stock(self, quantity: int):
self.quantity -= quantity
Expand All @@ -81,20 +86,64 @@ def reserve_stock(self, quantity: int):


@domain.domain_service(part_of=[Order, Inventory])
class OrderPlacementService:
@classmethod
def place_order(
cls, order: Order, inventories: list[Inventory]
) -> tuple[Order, list[Inventory]]:
for item in order.items:
class place_order:
def __init__(self, order, inventories):
super().__init__(*(order, inventories))

self.order = order
self.inventories = inventories

@invariant.pre
def inventory_should_have_sufficient_stock(self):
for item in self.order.items:
inventory = next(
(i for i in inventories if i.product_id == item.product_id), None
(i for i in self.inventories if i.product_id == item.product_id), None
)
if inventory is None or inventory.quantity < item.quantity:
raise Exception("Product is out of stock")
raise ValidationError({"_service": ["Product is out of stock"]})

inventory.reserve_stock(item.quantity)
@invariant.pre
def order_payment_method_should_be_valid(self):
if not self.order.payment_id:
raise ValidationError(
{"_service": ["Order must have a valid payment method"]}
)

@invariant.post
def total_reserved_value_should_match_order_value(self):
order_total = sum(item.quantity * item.price for item in self.order.items)
reserved_total = 0
for item in self.order.items:
inventory = next(
(i for i in self.inventories if i.product_id == item.product_id), None
)
if inventory:
reserved_total += inventory._events[0].quantity * item.price

if order_total != reserved_total:
raise ValidationError(
{"_service": ["Total reserved value does not match order value"]}
)

@invariant.post
def total_quantity_reserved_should_match_order_quantity(self):
order_quantity = sum(item.quantity for item in self.order.items)
reserved_quantity = sum(
inventory._events[0].quantity
for inventory in self.inventories
if inventory._events
)

order.confirm()
if order_quantity != reserved_quantity:
raise ValidationError(
{"_service": ["Total reserved quantity does not match order quantity"]}
)

def __call__(self):
for item in self.order.items:
inventory = next(
(i for i in self.inventories if i.product_id == item.product_id), None
)
inventory.reserve_stock(item.quantity)

return order, inventories
self.order.confirm()
16 changes: 16 additions & 0 deletions docs_src/guides/domain-definition/012.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from protean import Domain, invariant
from protean.exceptions import ValidationError
from protean.fields import Float, String

domain = Domain(__name__, load_toml=False)


@domain.value_object
class Balance:
currency = String(max_length=3, required=True)
amount = Float(required=True)

@invariant.post
def check_balance_is_positive_if_currency_is_USD(self):
if self.amount < 0 and self.currency == "USD":
raise ValidationError({"balance": ["Balance cannot be negative for USD"]})
11 changes: 0 additions & 11 deletions src/protean/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,11 +268,6 @@ def __init__(self, *template, **kwargs): # noqa: C901

self._initialized = True

# `clean()` will return a `defaultdict(list)` if errors are to be raised
custom_errors = self.clean() or {}
for field in custom_errors:
self.errors[field].extend(custom_errors[field])

# Raise any errors found during load
if self.errors:
logger.error(self.errors)
Expand All @@ -283,12 +278,6 @@ def defaults(self):
To be overridden in concrete Containers, when an attribute's default depends on other attribute values.
"""

def clean(self):
"""Placeholder method for validations.
To be overridden in concrete Containers, when complex validations spanning multiple fields are required.
"""
return defaultdict(list)

def __eq__(self, other):
"""Equivalence check for containers is based only on data.
Expand Down
Loading

0 comments on commit ed41efb

Please sign in to comment.