-
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 on Commands and Command Handlers
- Loading branch information
Showing
11 changed files
with
295 additions
and
4 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
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
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 |
---|---|---|
|
@@ -46,15 +46,15 @@ that an event has already occurred, such as `OrderPlaced` or `PaymentProcessed`. | |
|
||
You can define an event with the `Domain.event` decorator: | ||
|
||
```python hl_lines="22 26 29-31 34-37" | ||
```python hl_lines="14-16 19-22 31-33 35-38" | ||
{! docs_src/guides/domain-definition/events/001.py !} | ||
``` | ||
|
||
Events are always connected to an Aggregate class, specified with the | ||
`part_of` param in the decorator. An exception to this rule is when the | ||
Event class has been marked _Abstract_. | ||
|
||
## Event Facts | ||
## Key Facts | ||
|
||
- Events should be named in past tense, because we observe domain events _after | ||
the fact_. `StockDepleted` is a better choice than the imperative | ||
|
@@ -73,3 +73,20 @@ sender's state could have already mutated. | |
|
||
## Immutability | ||
|
||
Event objects are immutable - they cannot be changed once created. This is | ||
important because events are meant to be used as a snapshot of the domain | ||
state at a specific point in time. | ||
|
||
```shell hl_lines="5 7-11" | ||
In [1]: user = User(name='John Doe', email='[email protected]', status='ACTIVE') | ||
|
||
In [2]: renamed = UserRenamed(user_id=user.id, name="John Doe Jr.") | ||
|
||
In [3]: renamed.name = "John Doe Sr." | ||
... | ||
IncorrectUsageError: { | ||
'_event': [ | ||
'Event Objects are immutable and cannot be modified once created' | ||
] | ||
} | ||
``` |
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 |
---|---|---|
@@ -0,0 +1,99 @@ | ||
# Command Handlers | ||
|
||
Command handlers are responsible for executing commands and persisting system | ||
state. They typically interact with aggregate roots to perform the required | ||
operations, ensuring that all business rules and invariants are upheld. | ||
|
||
## Key Facts | ||
|
||
- Command Handlers extract relevant data from a command and invoke the | ||
appropriate aggregate method with the necessary parameters. | ||
- Command Handlers are responsible for hydrating (loading) and persisting | ||
aggregates. | ||
- A Command Handler method can hydrate more than one aggregate at a time, | ||
depending on the business process requirements, but it should always persist | ||
one aggregate root. Other aggregates should be synced eventually through | ||
domain events. | ||
|
||
## Defining a Command Handler | ||
|
||
Command Handlers are defined with the `Domain.command_handler` decorator: | ||
|
||
```python hl_lines="19-22 46-52" | ||
{! docs_src/guides/exposing-domain/002.py !} | ||
``` | ||
|
||
## Workflow | ||
|
||
```mermaid | ||
sequenceDiagram | ||
autonumber | ||
Domain->>Command Handler: Command object | ||
Command Handler->>Command Handler: Load aggregate | ||
Command Handler->>Aggregate: Extract data and invoke method | ||
Aggregate->>Aggregate: Mutate | ||
Aggregate-->>Command Handler: | ||
Command Handler->>Command Handler: Persist aggregate | ||
``` | ||
|
||
1. **Domain Sends Command Object to Command Handler**: The domain layer | ||
initiates the process by sending a command object to the command handler. | ||
This command object encapsulates the intent to perform a specific action or | ||
operation within the domain. | ||
|
||
1. **Command Handler Loads Aggregate**: Upon receiving the command object, the | ||
command handler begins by loading the necessary aggregate from the repository | ||
or data store. The aggregate is the key entity that will be acted upon based | ||
on the command. | ||
|
||
1. **Command Handler Extracts Data and Invokes Aggregate Method**: The command | ||
handler extracts the relevant data from the command object and invokes the | ||
appropriate method on the aggregate. This method call triggers the aggregate | ||
to perform the specified operation. | ||
|
||
1. **Aggregate Mutates**: Within the aggregate, the invoked method processes | ||
the data and performs the necessary business logic, resulting in a change | ||
(mutation) of the aggregate's state. This ensures that the operation adheres | ||
to the business rules and maintains consistency. | ||
|
||
1. **Aggregate Responds to Command Handler**: After mutating its state, the | ||
aggregate completes its operation and returns control to the command handler. | ||
The response may include confirmation of the successful operation or any | ||
relevant data resulting from the mutation. | ||
|
||
1. **Command Handler Persists Aggregate**: Finally, the command handler | ||
persists the modified aggregate back to the repository or data store. This | ||
ensures that the changes made to the aggregate's state are saved and reflected | ||
in the system's state. | ||
|
||
## Unit of Work | ||
|
||
Command handler methods always execute within a `UnitOfWork` context by | ||
default. The UnitOfWork pattern ensures that the series of changes to an | ||
aggregate cluster are treated as a single, atomic transaction. If an error | ||
occurs, the UnitOfWork rolls back all changes, ensuring no partial updates | ||
are applied. | ||
|
||
Each command handler method is wrapped in a `UnitOfWork` context, without | ||
having to explicitly specify it. Both handler methods in | ||
`AccountCommandHandler` below are equivalent: | ||
|
||
```python hl_lines="5" | ||
@domain.command_handler(part_of=Account) | ||
class AccountCommandHandler: | ||
@handle(RegisterCommand) | ||
def register(self, command: RegisterCommand): | ||
with UnitOfWork(): | ||
... # code to register account | ||
|
||
@handle(ActivateCommand) | ||
def activate(self, command: ActivateCommand): | ||
... # code to activate account | ||
``` | ||
|
||
!!!note | ||
A `UnitOfWork` context applies to objects in the aggregate cluster, | ||
and not multiple aggregates. A Command Handler method can load multiple | ||
aggregates to perform the business process, but should never persist more | ||
than one at a time. Other aggregates should be synced eventually through | ||
domain events. |
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 |
---|---|---|
@@ -0,0 +1,73 @@ | ||
# Commands | ||
|
||
Commands represent actions or operations that change the state of the system. | ||
They encapsulate the intent to perform a specific task, often containing data necessary for the action, and are (typically) processed by command handlers to | ||
ensure business rules and invariants are upheld. | ||
|
||
In Protean, command objects are essentially DTOs (Data Transfer Objects) that | ||
carry intent and information necessary to perform a specific action. | ||
|
||
## Key Facts | ||
|
||
- Commands are typically named using imperative verbs that clearly describe the intended action or change. E.g. CreateOrder, UpdateCustomerAddress, | ||
ShipProduct, and CancelReservation. | ||
- Commands are typically related to an aggregate, because aggregates are the | ||
entry point for all modifications, ensuring consistency and enforcing business | ||
rules. | ||
- When commands represent a domain concept that spans across aggregates, one | ||
aggregate takes the responsibility of processing the command and raising events | ||
to eventually make the rest of the system consistent. | ||
|
||
## Defining Commands | ||
|
||
A command is defined with the `Domain.command` decorator: | ||
|
||
```python hl_lines="12-15" | ||
{! docs_src/guides/exposing-domain/001.py !} | ||
``` | ||
|
||
A command is always associated with an aggregate class with the `part_of` | ||
option, as seen in the example above. | ||
|
||
## Workflow | ||
|
||
Command objects are often instantiated by the API controller, which acts as the | ||
entry point for external requests into the system. When a client makes a | ||
request to the API, the controller receives this request and translates the | ||
incoming data into the appropriate command object. | ||
|
||
In Protean, the API controller submits the command to the `domain` object, | ||
which then dispatches the command to the appropriate command handler. We will | ||
explore how the domain identifies the command handler in the | ||
[Command Handlers](./command-handlers.md) section. | ||
|
||
```mermaid | ||
sequenceDiagram | ||
autonumber | ||
API Controller->>Domain: command object | ||
Domain-->>API Controller: acknowledge reciept | ||
Domain->>Command Handler: command object | ||
Command Handler->>Command Handler: Process command | ||
``` | ||
|
||
## Immutability | ||
|
||
Like Events, Commands in Protean are immutable. This means that once a | ||
command is created, it cannot be changed. | ||
|
||
```shell hl_lines="8-14" | ||
In [1]: from datetime import datetime, timedelta | ||
|
||
In [2]: publish_article_command = PublishArticle(article_id="1") | ||
|
||
In [3]: publish_article_command | ||
Out[3]: <PublishArticle: PublishArticle object ({'article_id': '1', 'published_at': '2024-05-28 17:47:35.570857+00:00'})> | ||
|
||
In [4]: publish_article_command.published_at = datetime.now() - timedelta(hours=24) | ||
... | ||
IncorrectUsageError: { | ||
'_command': [ | ||
'Command Objects are immutable and cannot be modified once created' | ||
] | ||
} | ||
``` |
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 |
---|---|---|
@@ -0,0 +1 @@ | ||
# Accessing the Domain Model |
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
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 |
---|---|---|
@@ -0,0 +1,22 @@ | ||
from datetime import datetime, timezone | ||
|
||
from protean import Domain | ||
from protean.fields import DateTime, Identifier | ||
|
||
publishing = Domain(__file__, "Publishing", load_toml=False) | ||
|
||
|
||
def utc_now(): | ||
return datetime.now(timezone.utc) | ||
|
||
|
||
@publishing.command(part_of="Article") | ||
class PublishArticle: | ||
article_id = Identifier(required=True) | ||
published_at = DateTime(default=utc_now) | ||
|
||
|
||
@publishing.aggregate | ||
class Article: | ||
article_id = Identifier(required=True) | ||
published_at = DateTime(default=utc_now) |
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 |
---|---|---|
@@ -0,0 +1,53 @@ | ||
from datetime import datetime, timezone | ||
from enum import Enum | ||
|
||
from protean import Domain, handle | ||
from protean.globals import current_domain | ||
from protean.fields import DateTime, Identifier, String | ||
|
||
publishing = Domain(__file__, "Publishing") | ||
|
||
|
||
def utc_now(): | ||
return datetime.now(timezone.utc) | ||
|
||
|
||
class ArticleStatus(Enum): | ||
DRAFT = "DRAFT" | ||
PUBLISHED = "PUBLISHED" | ||
|
||
|
||
@publishing.command(part_of="Article") | ||
class PublishArticle: | ||
article_id = Identifier(required=True) | ||
published_at = DateTime(default=utc_now) | ||
|
||
|
||
@publishing.event(part_of="Article") | ||
class ArticlePublished: | ||
article_id = Identifier(required=True) | ||
published_at = DateTime() | ||
|
||
|
||
@publishing.aggregate | ||
class Article: | ||
article_id = Identifier(required=True) | ||
status = String(choices=ArticleStatus, default=ArticleStatus.DRAFT.value) | ||
published_at = DateTime(default=utc_now) | ||
|
||
def publish(self, published_at: DateTime) -> None: | ||
self.status = ArticleStatus.PUBLISHED.value | ||
self.published_at = published_at | ||
|
||
self.raise_( | ||
ArticlePublished(article_id=self.article_id, published_at=published_at) | ||
) | ||
|
||
|
||
@publishing.command_handler(part_of=Article) | ||
class ArticleCommandHandler: | ||
@handle(PublishArticle) | ||
def publish_article(self, command): | ||
article = current_domain.repository_for(Article).get(command.article_id) | ||
article.publish() | ||
current_domain.repository_for(Article).add(article) |
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
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