Skip to content

Commit

Permalink
Add documentation on Commands and Command Handlers
Browse files Browse the repository at this point in the history
  • Loading branch information
subhashb committed May 29, 2024
1 parent 5c73456 commit f4d9a76
Show file tree
Hide file tree
Showing 11 changed files with 295 additions and 4 deletions.
18 changes: 18 additions & 0 deletions docs/guides/compose-a-domain/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@

### `environment`

The `environment` attribute specifies the current running environment of the
application. By default, the environment is `development`. The current
environment can be gathered from an environment variable called `PROTEAN_ENV`.

The framework
recognizes `development` and `production` environments, but additional
environments such as `pre-prod`, `staging`, and `testing` can be specified as
needed.

- **Default Value**: `development`
- **Environment Variable**: `PROTEAN_ENV`
- **Examples**:
- `development`
- `production`
- `pre-prod`
- `staging`
- `testing`

### `debug`

### `testing`
Expand Down
4 changes: 4 additions & 0 deletions docs/guides/domain-definition/aggregates.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ They are conceptual wholes - they enclose all behaviors and data of a distinct
domain concept. Aggregates are often composed of one or more Aggregate
Elements, that work together to codify the concept.

Traditional DDD refers to such entities as **Aggregate Roots** because they
compose and manage a cluster of objects. In Protean, the term ***Aggregate***
and ***Aggregate Root*** are synonymous.

Aggregates are defined with the `Domain.aggregate` decorator:

```python hl_lines="8"
Expand Down
21 changes: 19 additions & 2 deletions docs/guides/domain-definition/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
]
}
```
99 changes: 99 additions & 0 deletions docs/guides/exposing-domain/command-handlers.md
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.
73 changes: 73 additions & 0 deletions docs/guides/exposing-domain/commands.md
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'
]
}
```
1 change: 1 addition & 0 deletions docs/guides/exposing-domain/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Accessing the Domain Model
2 changes: 1 addition & 1 deletion docs_src/guides/domain-definition/events/001.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from protean import Domain
from protean.fields import String, Identifier

domain = Domain(__file__)
domain = Domain(__file__, load_toml=False)


class UserStatus(Enum):
Expand Down
22 changes: 22 additions & 0 deletions docs_src/guides/exposing-domain/001.py
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)
53 changes: 53 additions & 0 deletions docs_src/guides/exposing-domain/002.py
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)
4 changes: 4 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ nav:
- guides/domain-behavior/invariants.md
- guides/domain-behavior/aggregate-mutation.md
- guides/domain-behavior/domain-services.md
- Exposing the domain:
- guides/exposing-domain/index.md
- guides/exposing-domain/commands.md
- guides/exposing-domain/command-handlers.md
# - Application Layer:
# - guides/app-layer/index.md
# - guides/app-layer/application-services.md
Expand Down
2 changes: 1 addition & 1 deletion src/protean/domain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -808,7 +808,7 @@ def view(self, _cls=None, **kwargs):
def publish(self, event: BaseEvent) -> None:
"""Publish Events to all configured brokers.
Args:
event_or_command (BaseEvent): The Event object containing data to be pushed
event (BaseEvent): The Event object containing data to be pushed
"""
# Persist event in Message Store
self.event_store.store.append(event)
Expand Down

0 comments on commit f4d9a76

Please sign in to comment.