Skip to content

Commit

Permalink
Resolve references when initializing Domain (#419)
Browse files Browse the repository at this point in the history
This commit moves resolving references, as well as validating domain constructs
to domain initialization, not activation. This ensures they happen one time, at
the beginning of domain setup. If they are in activation, the resolution would
be triggered every time a domain context is activated, which would be every time
the domain processes a request!

This commit also has the following fixes:
* Ensure Value Object field's class resolution works
* All Events and Commands need to be associated with an Aggregate or a stream
* Validation to ensure events are not associated with multiple event sourced aggregates
* Allow Abstract events and commands to be defined without an Aggregate or a stream
* Scope `setup_db` fixtures to Module level (Having them at `session` level
was resulting in the wrong context being popped)
* Move `fetch_element_cls_from_registry` under Domain object to avoid using `current_domain`
* Pass `domain` during class resolution to avoid using `current_domain`
  • Loading branch information
subhashb authored May 16, 2024
1 parent 2377a55 commit de0cb3f
Show file tree
Hide file tree
Showing 63 changed files with 629 additions and 330 deletions.
1 change: 1 addition & 0 deletions docs/guides/domain-behavior/aggregate-mutation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Mutating Aggregates
1 change: 1 addition & 0 deletions docs/guides/domain-behavior/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Adding Rules and Behavior
1 change: 1 addition & 0 deletions docs/guides/domain-behavior/invariants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Invariants
1 change: 1 addition & 0 deletions docs/guides/domain-behavior/raising-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Raising Events
73 changes: 73 additions & 0 deletions docs/guides/domain-definition/events.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,75 @@
# Events

Most applications have a definite state - they reflect past user input and
interactions in their current state. It is advantageous to model these past
changes as a series of discrete events. Domain events happen to be those
activities that domain experts care about and represent what happened as-is.

In Protean, an `Event` is an immutable object that represents a significant
occurrence or change in the domain. Events are raised by aggregates to signal
that something noteworthy has happened, allowing other parts of the system to
react - and sync - to these changes in a decoupled manner.

Events have a few primary functions:

1. **Events allows different components to communicate with each other.**

Within a domain or across, events can be used as a mechanism to implement
eventual consistency, in the same bounded context or across. This promotes
loose coupling by decoupling the producer (e.g., an aggregate that raises
an event) from the consumers (e.g., various components that handle the
event).

Such a design eliminates the need for two-phase commits (global
transactions) across bounded contexts, optimizing performance at the level
of each transaction.

2. **Events act as API contracts.**

Events define a clear and consistent structure for data that is shared
between different components of the system. This promotes system-wide
interoperability and integration between components.

3. **Events help preserve context boundaries.**

Events propagate information across bounded contexts, thus helping to
sync changes throughout the application domain. This allows each domain
to be modeled in the architecture pattern that is most appropriate for its
use case.

## Defining Events

Event names should be descriptive and convey the specific change or occurrence
in the domain clearly, ensuring that the purpose of the event is immediately
understandable. Events are named as past-tense verbs to clearly indicate
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"
{! docs_src/guides/domain-definition/events/001.py !}
```

Events are always connected to an Aggregate class, specified with the
`aggregate_cls` param in the decorator. An exception to this rule is when the
Event class has been marked _Abstract_.

## Event Facts

- Events should be named in past tense, because we observe domain events _after
the fact_. `StockDepleted` is a better choice than the imperative
`DepleteStock` as an event name.
- An event is associated with an aggregate or a stream, specified with
`aggregate_cls` or `stream` parameters to the decorator, as above. We will
dive deeper into these parameters in the Processing Events section.
<!-- FIXME Add link to events processing section -->
- Events are essentially Data Transfer Objects (DTO)- they can only hold
simple fields.
- Events should only contain information directly relevant to the event. A
receiver that needs more information should be listening to other pertinent
events and add read-only structures to its own state to take decisions later.
A receiver should not query the current state from the sender because the
sender's state could have already mutated.

## Immutability

4 changes: 4 additions & 0 deletions docs/guides/domain-definition/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ and translating them as closely as possible - terminology, structure, and
behavior - in code. Protean supports the tactical patterns outlined by DDD
to mirror the domain model in [code model](../../glossary.md#code-model).

In this section, we will talk about the foundational structures that make up
the domain model. In the next, we will explore how to define behavior and
set up invariants (business rules) that bring the Domain model to life.

## Domain Layer

One of the most important building block of a domain model is the Aggregate.
Expand Down
13 changes: 13 additions & 0 deletions docs/guides/domain-definition/value-objects.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ Value Objects can be embedded into Aggregates and Entities with the
{! docs_src/guides/domain-definition/009.py !}
```

!!!note
You can also specify a Value Object's class name as input to the
`ValueObject` field, which will be resolved when the domain is initialized.
This can help avoid the problem of circular references.

```python
@domain.aggregate
class User:
email = ValueObject("Email")
name = String(max_length=30)
timezone = String(max_length=30)
```

An email address can be supplied during user object creation, and the
value object takes care of its own validations.

Expand Down
3 changes: 3 additions & 0 deletions docs/guides/getting-started/start-here.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Start Here

Explain the overall structure of guides and the path to follow.
37 changes: 37 additions & 0 deletions docs_src/guides/domain-definition/events/001.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from enum import Enum

from protean import Domain
from protean.fields import String, Identifier

domain = Domain(__file__)


class UserStatus(Enum):
ACTIVE = "ACTIVE"
ARCHIVED = "ARCHIVED"


@domain.event(aggregate_cls="User")
class UserActivated:
user_id = Identifier(required=True)


@domain.event(aggregate_cls="User")
class UserRenamed:
user_id = Identifier(required=True)
name = String(required=True, max_length=50)


@domain.aggregate
class User:
name = String(max_length=50, required=True)
email = String(required=True)
status = String(choices=UserStatus)

def activate(self) -> None:
self.status = UserStatus.ACTIVE.value
self.raise_(UserActivated(user_id=self.id))

def change_name(self, name: str) -> None:
self.name = name
self.raise_(UserRenamed(user_id=self.id, name=name))
11 changes: 6 additions & 5 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,8 @@ nav:
# - core-concepts/event-sourcing/projections.md
- Guides:
- guides/index.md
- Getting Started:
# - guides/getting-started/index.md
- guides/getting-started/installation.md
- guides/getting-started/start-here.md
- guides/getting-started/installation.md
# - guides/getting-started/quickstart.md
- Compose a Domain:
- guides/compose-a-domain/index.md
Expand All @@ -95,7 +94,7 @@ nav:
- guides/compose-a-domain/element-decorators.md
- guides/compose-a-domain/object-model.md
# - guides/compose-a-domain/configuration.md
- Defining Domain Concepts:
- Defining Concepts:
- guides/domain-definition/index.md
- Fields:
- guides/domain-definition/fields/index.md
Expand All @@ -106,9 +105,11 @@ nav:
- guides/domain-definition/aggregates.md
- guides/domain-definition/entities.md
- guides/domain-definition/value-objects.md
- guides/domain-definition/domain-services.md
- guides/domain-definition/events.md
- guides/domain-definition/repositories.md
- Adding Behavior:
- guides/domain-behavior/index.md
- guides/domain-behavior/domain-services.md
# - Application Layer:
# - guides/app-layer/index.md
# - guides/app-layer/application-services.md
Expand Down
3 changes: 1 addition & 2 deletions src/protean/adapters/broker/celery.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from protean.port.broker import BaseBroker
from protean.utils import (
DomainObjects,
fetch_element_cls_from_registry,
fully_qualified_name,
)
from protean.utils.inflection import underscore
Expand Down Expand Up @@ -95,7 +94,7 @@ def register(self, initiator_cls, consumer_cls):
)

def publish(self, message: Message) -> None:
event_cls = fetch_element_cls_from_registry(
event_cls = self.domain.fetch_element_cls_from_registry(
message.type, (DomainObjects.EVENT,)
)
for subscriber in self._subscribers[fully_qualified_name(event_cls)]:
Expand Down
2 changes: 1 addition & 1 deletion src/protean/adapters/repository/elasticsearch.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,7 @@ def _drop_database_artifacts(self):
}
for _, element_record in elements.items():
model_cls = self.domain.repository_for(element_record.cls)._model
provider = current_domain.providers[element_record.cls.meta_.provider]
provider = self.domain.providers[element_record.cls.meta_.provider]
if provider.conn_info[
"DATABASE"
] == Database.ELASTICSEARCH.value and model_cls._index.exists(using=conn):
Expand Down
8 changes: 2 additions & 6 deletions src/protean/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,8 @@ def server(
# FIXME Accept MAX_WORKERS as command-line input as well
try:
domain = derive_domain(domain)
except NoDomainException:
logger.error(
"Could not locate a Protean domain. You should provide a domain in"
'"PROTEAN_DOMAIN" environment variable or pass a domain file in options '
'and a "domain.py" module was not found in the current directory.'
)
except NoDomainException as exc:
logger.error(f"Error loading Protean domain: {exc.messages}")
raise typer.Abort()

from protean.server import Engine
Expand Down
7 changes: 2 additions & 5 deletions src/protean/cli/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,8 @@ def docker_compose(
"""Generate a `docker-compose.yml` from Domain config"""
try:
domain_instance = derive_domain(domain)
except NoDomainException:
logger.error(
"Could not locate a Protean domain. You should provide a domain in"
'"PROTEAN_DOMAIN" environment variable or pass a domain file in options'
)
except NoDomainException as exc:
logger.error(f"Error loading Protean domain: {exc.messages}")
raise typer.Abort()

print(f"Generating docker-compose.yml for domain at {domain}")
Expand Down
10 changes: 3 additions & 7 deletions src/protean/cli/shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,15 @@ def shell(
):
try:
domain_instance = derive_domain(domain)
except NoDomainException:
logger.error(
"Could not locate a Protean domain. You should provide a domain in"
'"PROTEAN_DOMAIN" environment variable or pass a domain file in options'
)
except NoDomainException as exc:
logger.error(f"Error loading Protean domain: {exc.messages}")
raise typer.Abort()

if traverse:
print("Traversing directory to load all modules...")
domain_instance.init(traverse=traverse)

with domain_instance.domain_context():
domain_instance.init(traverse=traverse)

ctx: dict[str, typing.Any] = {}
ctx.update(domain_instance.make_shell_context())

Expand Down
18 changes: 15 additions & 3 deletions src/protean/core/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,15 @@ def __setattr__(self, name, value):
else:
raise IncorrectUsageError(
{
"_value_object": [
"Value Objects are immutable and cannot be modified once created"
"_command": [
"Command Objects are immutable and cannot be modified once created"
]
}
)

@classmethod
def _default_options(cls):
return [("aggregate_cls", None), ("stream_name", None)]
return [("abstract", False), ("aggregate_cls", None), ("stream_name", None)]

@classmethod
def __track_id_field(subclass):
Expand All @@ -76,4 +76,16 @@ def __track_id_field(subclass):
def command_factory(element_cls, **kwargs):
element_cls = derive_element_class(element_cls, BaseCommand, **kwargs)

if (
not (element_cls.meta_.aggregate_cls or element_cls.meta_.stream_name)
and not element_cls.meta_.abstract
):
raise IncorrectUsageError(
{
"_command": [
f"Command `{element_cls.__name__}` needs to be associated with an aggregate or a stream"
]
}
)

return element_cls
22 changes: 18 additions & 4 deletions src/protean/core/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,15 @@ def __setattr__(self, name, value):
else:
raise IncorrectUsageError(
{
"_value_object": [
"Value Objects are immutable and cannot be modified once created"
"_event": [
"Event Objects are immutable and cannot be modified once created"
]
}
)

@classmethod
def _default_options(cls):
return [("aggregate_cls", None), ("stream_name", None)]
return [("abstract", False), ("aggregate_cls", None), ("stream_name", None)]

@classmethod
def __track_id_field(subclass):
Expand All @@ -75,4 +75,18 @@ def __track_id_field(subclass):


def domain_event_factory(element_cls, **kwargs):
return derive_element_class(element_cls, BaseEvent, **kwargs)
element_cls = derive_element_class(element_cls, BaseEvent, **kwargs)

if (
not (element_cls.meta_.aggregate_cls or element_cls.meta_.stream_name)
and not element_cls.meta_.abstract
):
raise IncorrectUsageError(
{
"_event": [
f"Event `{element_cls.__name__}` needs to be associated with an aggregate or a stream"
]
}
)

return element_cls
24 changes: 8 additions & 16 deletions src/protean/core/event_sourced_aggregate.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,25 +181,17 @@ def event_sourced_aggregate_factory(element_cls, **opts):
)

# Associate Event with the aggregate class
#
# This can potentially cause a problem because an Event can only be associated
# with one aggregate class, but multiple event handlers can consume it.
# By resetting the event's aggregate class, its previous association is lost.
# We catch this problem during domain validation.
#
# The domain validation should check for the same event class being present
# in `_events_cls_map` of multiple aggregate classes.
if inspect.isclass(method._event_cls) and issubclass(
method._event_cls, BaseEvent
):
# An Event can only be associated with one aggregate class, but multiple event handlers
# can consume it.
if (
method._event_cls.meta_.aggregate_cls
and method._event_cls.meta_.aggregate_cls != element_cls
):
raise IncorrectUsageError(
{
"_entity": [
f"{method._event_cls.__name__} Event cannot be associated with"
f" {element_cls.__name__} because it is already associated with"
f" {method._event_cls.meta_.aggregate_cls.__name__}"
]
}
)

method._event_cls.meta_.aggregate_cls = element_cls

return element_cls
Loading

0 comments on commit de0cb3f

Please sign in to comment.