-
Notifications
You must be signed in to change notification settings - Fork 10
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add documentation related to defining Domain Behaviors (#426)
Includes the following changes/fixes: * Show error message when an element is unknown/unregistered * Add Invariants documentation * Add missing test cases for the reflection module * Add documentation for domain behavior
- Loading branch information
Showing
22 changed files
with
1,017 additions
and
19 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,101 @@ | ||
# Mutating Aggregates | ||
# Mutating Aggregates | ||
|
||
The primary mechanism to modify the current state of a domain - to reflect | ||
some action or event that has happened - is by mutating its state. Since | ||
aggregates encapsulate all data and behavior of concepts in domain, | ||
state changes are initiated by invoking state-changing methods on the aggregate. | ||
|
||
## Typical Workflow | ||
|
||
A typical workflow of a state change is depicted below: | ||
|
||
```mermaid | ||
sequenceDiagram | ||
autonumber | ||
ApplicationService->>Repository: Fetch Aggregate | ||
Repository-->>ApplicationService: aggregate | ||
ApplicationService->>aggregate: Call state change | ||
aggregate->>aggregate: Mutate | ||
aggregate-->>ApplicationService: | ||
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. We will dive | ||
deeper into the Application layer in a later section, but below is the | ||
aggregate method that mutates state: | ||
|
||
```python hl_lines="13-16 18-24" | ||
--8<-- "guides/domain-behavior/002.py:10:33" | ||
``` | ||
|
||
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. | ||
|
||
```python hl_lines="13" | ||
--8<-- "guides/domain-behavior/002.py:16:33" | ||
``` | ||
|
||
If the state change is successful, meaning it satisfies all | ||
invariants defined on the model, the aggregate immediately reflects the | ||
changes. | ||
|
||
```shell hl_lines="8" | ||
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. | ||
|
||
```shell hl_lines="3 7" | ||
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 | ||
``` | ||
|
||
## Raising Events | ||
|
||
The aggregate also (preferably) raises one or more events to recort the state | ||
change and to propagate the change within and beyond the bounded context. | ||
|
||
```python hl_lines="15" | ||
--8<-- "guides/domain-behavior/002.py:16:33" | ||
``` | ||
|
||
The events are visible as part of the mutated aggregate. When we review the | ||
Application Layer, we will also talk about how these events are dispatched | ||
automatically. | ||
|
||
```shell hl_lines="8 12-13" | ||
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': '37fc8d10-1209-41d2-a6fa-4f7312912212'} | ||
|
||
In [4]: account._events | ||
Out[4]: [<AccountWithdrawn: AccountWithdrawn object ({'account_number': '1234', 'amount': 500.0})>] | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,85 @@ | ||
# Domain Services | ||
|
||
Domain services act as orchestrators, centralizing complex domain logic that | ||
doesn't neatly fit within an entity or aggregate. They encapsulate business | ||
rules and domain decisions that need multiple aggregates as input. | ||
|
||
Domain services free us from having to shoehorn business logic into aggregate | ||
clusters. This keeps domain objects focused on their core state and behavior, | ||
while domain services handle the broader workflows and complex interactions | ||
within the domain. | ||
|
||
Even though Domain services can access multiple aggregates, they are not meant | ||
to propagate state changes in more than one aggregate. A combination of | ||
Application Services, Events, and eventual consistency sync aggregates when a | ||
transaction spans beyond an aggregate's boundary. We will discuss these aspects | ||
more thoroughly in the Application Layer section. | ||
|
||
## Key Facts | ||
|
||
- **Stateless:** Domain services are stateless - they don’t hold any internal | ||
state between method calls. They operate on the state provided to them through | ||
their method parameters. | ||
- **Encapsulate Business Logic:** Domain Services encapsulate complex business | ||
logic or operations that involve multiple aggregate, specifically, logic that | ||
doesn't naturally fit within any single aggregate. | ||
- **Pure Domain Concepts:** Domain services focus purely on domain logic and | ||
cannot handle technical aspects like persistence or messaging, though they | ||
mutate aggregates to a state that they is ready to be persisted. Technical | ||
concerns are typically handled by invoking services like application services | ||
or command/event handlers. | ||
|
||
## Defining a Domain Service | ||
|
||
A Domain Service is defined with the `Domain.domain_service` decorator: | ||
|
||
```python hl_lines="1 5-6" | ||
--8<-- "guides/domain-behavior/006.py:83:100" | ||
``` | ||
|
||
The domain service has to be associated with at least two aggregates with the | ||
`part_of` option, as seen in the example above. | ||
|
||
Each method in the Domain Service element is a class method, that receives two | ||
or more aggregates or list of aggregates. | ||
|
||
## Typical Workflow | ||
|
||
Let us consider an example `OrderPlacementService` that places an order and | ||
updates inventory stocks simultaneously. The typical workflow of a Domain | ||
Service is below: | ||
|
||
```mermaid | ||
sequenceDiagram | ||
autonumber | ||
Application Service->>Repository: Fetch order and product invehtories | ||
Repository-->>Application Service: order and inventories | ||
Application Service->>Domain Service: Invoke operation | ||
Domain Service->>Domain Service: Mutate aggregates | ||
Domain Service-->>Application Service: | ||
Application Service->>Repository: Persist aggregates | ||
``` | ||
|
||
The application service loads the necessary aggregates through repositories, | ||
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. | ||
|
||
|
||
## A full-blown example | ||
|
||
```python hl_lines="67-82" | ||
--8<-- "guides/domain-behavior/006.py:16:98" | ||
``` | ||
|
||
When an order is placed, `Order` status has to be `CONFIRMED` _and_ the stock | ||
record of each product in `Inventory` has to be reduced. | ||
|
||
This change could be performed with events, with `Order` generating an event | ||
and `Inventory` aggregate consuming the event and updating its records. But | ||
there is a possibility of encountering stock depleted issues if multiple | ||
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,132 @@ | ||
# Invariants | ||
# 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. | ||
|
||
## 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 and | ||
Entities: | ||
|
||
```python hl_lines="9-10 14-15" | ||
--8<-- "guides/domain-behavior/001.py:17:41" | ||
``` | ||
|
||
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. | ||
|
||
## 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. | ||
|
||
```shell hl_lines="11 13" | ||
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. | ||
|
||
```shell hl_lines="13 15" | ||
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. | ||
|
||
```shell hl_lines="14" | ||
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: | ||
|
||
```shell hl_lines="3" | ||
In [4]: order.total_amount = 120.0 | ||
... | ||
ValidationError: {'_entity': ['Total should be sum of item prices']} | ||
``` |
Oops, something went wrong.