Skip to content

Commit

Permalink
Add documentation related to defining Domain Behaviors (#426)
Browse files Browse the repository at this point in the history
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
subhashb authored May 23, 2024
1 parent 1de09f0 commit 28cae2f
Show file tree
Hide file tree
Showing 22 changed files with 1,017 additions and 19 deletions.
102 changes: 101 additions & 1 deletion docs/guides/domain-behavior/aggregate-mutation.md
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})>]
```
83 changes: 83 additions & 0 deletions docs/guides/domain-behavior/domain-services.md
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.
133 changes: 132 additions & 1 deletion docs/guides/domain-behavior/invariants.md
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']}
```
Loading

0 comments on commit 28cae2f

Please sign in to comment.