diff --git a/docs/guides/domain-behavior/aggregate-mutation.md b/docs/guides/domain-behavior/aggregate-mutation.md index 27710500..ef237393 100644 --- a/docs/guides/domain-behavior/aggregate-mutation.md +++ b/docs/guides/domain-behavior/aggregate-mutation.md @@ -1 +1,101 @@ -# Mutating Aggregates \ No newline at end of file +# 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]: [] +``` \ No newline at end of file diff --git a/docs/guides/domain-behavior/domain-services.md b/docs/guides/domain-behavior/domain-services.md index 2e878baf..75852504 100644 --- a/docs/guides/domain-behavior/domain-services.md +++ b/docs/guides/domain-behavior/domain-services.md @@ -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. diff --git a/docs/guides/domain-behavior/invariants.md b/docs/guides/domain-behavior/invariants.md index a162d50f..ca0e51fd 100644 --- a/docs/guides/domain-behavior/invariants.md +++ b/docs/guides/domain-behavior/invariants.md @@ -1 +1,132 @@ -# Invariants \ No newline at end of file +# 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']} +``` \ No newline at end of file diff --git a/docs/guides/domain-behavior/validations.md b/docs/guides/domain-behavior/validations.md new file mode 100644 index 00000000..1741ebcc --- /dev/null +++ b/docs/guides/domain-behavior/validations.md @@ -0,0 +1,119 @@ +# Basic Validations + +There are many hygiene aspects that need to be enforced in a domain's data +and behavior, even before we get to the point of defining a domain's rules. +These basic aspects can be codified in the form of field types and its options, +using in-build validators, or even defining custom validators and attaching +them to a field. + +## Field Restrictions + +Field restrictions begin with the type of field chosen to represent an +attribute. + +```python hl_lines="12-16" +--8<-- "guides/domain-behavior/003.py:10:24" +``` + +Violating any of these constraints will throw exceptions: + +```shell hl_lines="2 3" +In [3]: account = Account( + ...: account_number="A1234", + ...: account_type="CHECKING", + ...: balance=50) +ERROR: Error during initialization... +... +ValidationError: { + 'account_number': ['"A1234" value must be an integer.'], + 'account_type': [ + "Value `'CHECKING'` is not a valid choice. Must be among ['SAVINGS', 'CURRENT']" + ] +} +``` + +These validations kick-in even on attribute change, not just during +initialization, thus keeping the aggregate valid at all times. + +Every Protean field also has options that help constrain the field value. +For example, we can specify that the field is mandatory with the `required` +option and stores a unique value with the `unique` option. + +The four options to constrain values are: + +- **`required`**: Indicates if the field is required (must have a value). If +`True`, the field is not allowed to be blank. Default is `False`. +- **`identifier`**: If True, the field is an identifier for the entity. These +fields are `unique` and `required` by default. +- **`unique`**: Indicates if the field values must be unique within the +repository. If `True`, this field's value is validated to be unique among +all entities of same category. +- **`choices`**: A set of allowed choices for the field value, supplied as an +`Enum` or `list`. + +!!!note + Note that some constraints, like uniqueness, will only be enforced when the + element is persisted. + + +Since `Account.account_number` was declared `required` earlier, skipping it +will throw an exception: + +```shell hl_lines="6" +n [5]: account = Account( + ...: account_type="SAVINGS", + ...: balance=50) +ERROR: Error during initialization: {'account_number': ['is required']} +... +ValidationError: {'account_number': ['is required']} +``` + +A full-list of field types and their options is available in the +[Fields](../domain-definition/fields/index.md) section. + +## In-built Validations + +Many field classes in Protean come pre-equipped with basic validations, like +length and value. + +For example, `Integer` fields have `min_value` and `max_value` validators, +while `String` fields have `min_length` and `max_length` validators. These +validators are typically activated by supplying them as a parameter during +field initialization. + +```python hl_lines="12-16" +--8<-- "guides/domain-behavior/004.py:7:10" +``` + +Violating these constraints results in an immediate exception: + +```shell +In [1]: Person(name="Ho", age=200) +ERROR: Error during initialization: +... +ValidationError: {'name': ['value has less than 3 characters'], 'age': ['value is greater than 120']} +``` + +A full-list of in-built validators is available in the +[Fields](../domain-definition/fields/index.md) section under each field. + + +## Custom Validators + +You can also add vaidations at the field level by defining custom validators. + +```python hl_lines="14-17" +--8<-- "guides/domain-behavior/005.py:10:26" +``` + +Now, an email address assigned to the field is validated with the custom +regex pattern: + +```shell +In [1]: Person(name="John", email="john.doe@gmail.com") +Out[1]: + +In [2]: Person(name="Jane", email="jane.doe@.gmail.com") +... +ValueError: Invalid Email Address - jane.doe@.gmail.com +``` \ No newline at end of file diff --git a/docs_src/guides/domain-behavior/001.py b/docs_src/guides/domain-behavior/001.py new file mode 100644 index 00000000..23a4e35c --- /dev/null +++ b/docs_src/guides/domain-behavior/001.py @@ -0,0 +1,76 @@ +from enum import Enum +from datetime import date + +from protean import Domain, invariant +from protean.exceptions import ValidationError +from protean.fields import Date, Float, Identifier, Integer, String, HasMany + +domain = Domain(__file__) + + +class OrderStatus(Enum): + PENDING = "PENDING" + SHIPPED = "SHIPPED" + DELIVERED = "DELIVERED" + + +@domain.aggregate +class Order: + customer_id = Identifier() + order_date = Date() + total_amount = Float() + status = String(max_length=50, choices=OrderStatus) + items = HasMany("OrderItem") + + @invariant + def total_amount_of_order_must_equal_sum_of_subtotal_of_all_items(self): + if self.total_amount != sum(item.subtotal for item in self.items): + raise ValidationError({"_entity": ["Total should be sum of item prices"]}) + + @invariant + def order_date_must_be_within_the_last_30_days_if_status_is_pending(self): + if self.status == OrderStatus.PENDING.value and self.order_date < date( + 2020, 1, 1 + ): + raise ValidationError( + { + "_entity": [ + "Order date must be within the last 30 days if status is PENDING" + ] + } + ) + + @invariant + def customer_id_must_be_non_null_and_the_order_must_contain_at_least_one_item(self): + if not self.customer_id or not self.items: + raise ValidationError( + { + "_entity": [ + "Customer ID must be non-null and the order must contain at least one item" + ] + } + ) + + +@domain.entity +class OrderItem: + product_id = Identifier() + quantity = Integer() + price = Float() + subtotal = Float() + + class Meta: + part_of = Order + + @invariant + def the_quantity_must_be_a_positive_integer_and_the_subtotal_must_be_correctly_calculated( + self, + ): + if self.quantity <= 0 or self.subtotal != self.quantity * self.price: + raise ValidationError( + { + "_entity": [ + "Quantity must be a positive integer and the subtotal must be correctly calculated" + ] + } + ) diff --git a/docs_src/guides/domain-behavior/002.py b/docs_src/guides/domain-behavior/002.py new file mode 100644 index 00000000..b453dde1 --- /dev/null +++ b/docs_src/guides/domain-behavior/002.py @@ -0,0 +1,31 @@ +from protean import Domain, invariant +from protean.fields import Float, Identifier + +banking = Domain(__file__) + + +class InsufficientFundsException(Exception): + pass + + +@banking.event(part_of="Account") +class AccountWithdrawn: + account_number = Identifier(required=True) + amount = Float(required=True) + + +@banking.aggregate +class Account: + account_number = Identifier(required=True, unique=True) + balance = Float() + overdraft_limit = Float(default=0.0) + + @invariant + def balance_must_be_greater_than_or_equal_to_overdraft_limit(self): + if self.balance < -self.overdraft_limit: + raise InsufficientFundsException("Balance cannot be below overdraft limit") + + def withdraw(self, amount: float): + self.balance -= amount # Update account state (mutation) + + self.raise_(AccountWithdrawn(account_number=self.account_number, amount=amount)) diff --git a/docs_src/guides/domain-behavior/003.py b/docs_src/guides/domain-behavior/003.py new file mode 100644 index 00000000..6751d81e --- /dev/null +++ b/docs_src/guides/domain-behavior/003.py @@ -0,0 +1,24 @@ +from datetime import datetime, timezone +from enum import Enum + +from protean import Domain +from protean.fields import DateTime, Float, Integer, String + +domain = Domain(__file__) + + +def utc_now(): + return datetime.now(timezone.utc) + + +class AccountType(Enum): + SAVINGS = "SAVINGS" + CURRENT = "CURRENT" + + +@domain.aggregate +class Account: + account_number = Integer(required=True, unique=True) + account_type = String(required=True, max_length=7, choices=AccountType) + balance = Float(default=0.0) + opened_at = DateTime(default=utc_now) diff --git a/docs_src/guides/domain-behavior/004.py b/docs_src/guides/domain-behavior/004.py new file mode 100644 index 00000000..78e5a398 --- /dev/null +++ b/docs_src/guides/domain-behavior/004.py @@ -0,0 +1,10 @@ +from protean import Domain +from protean.fields import Integer, String + +domain = Domain(__file__) + + +@domain.aggregate +class Person: + name = String(required=True, min_length=3, max_length=50) + age = Integer(required=True, min_value=0, max_value=120) diff --git a/docs_src/guides/domain-behavior/005.py b/docs_src/guides/domain-behavior/005.py new file mode 100644 index 00000000..647fd483 --- /dev/null +++ b/docs_src/guides/domain-behavior/005.py @@ -0,0 +1,26 @@ +import re + +from protean import Domain +from protean.fields import String + + +domain = Domain(__file__) + + +class EmailValidator: + def __init__(self): + self.error = "Invalid Email Address" + + def __call__(self, email): + # Define the regular expression pattern for valid email addresses + pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9]+\.[a-zA-Z]{2,}$" + + # Match the email with the pattern + if not bool(re.match(pattern, email)): + raise ValueError(f"{self.error} - {email}") + + +@domain.aggregate +class Person: + name = String(required=True, max_length=50) + email = String(required=True, max_length=254, validators=[EmailValidator()]) diff --git a/docs_src/guides/domain-behavior/006.py b/docs_src/guides/domain-behavior/006.py new file mode 100644 index 00000000..bd807cbe --- /dev/null +++ b/docs_src/guides/domain-behavior/006.py @@ -0,0 +1,100 @@ +from datetime import datetime, timezone +from enum import Enum + +from protean import Domain +from protean.fields import ( + DateTime, + Float, + Identifier, + Integer, + HasMany, + String, +) + +domain = Domain(__file__) + + +class OrderStatus(Enum): + PENDING = "PENDING" + CONFIRMED = "CONFIRMED" + SHIPPED = "SHIPPED" + DELIVERED = "DELIVERED" + + +@domain.event +class OrderConfirmed: + order_id = Identifier(required=True) + confirmed_at = DateTime(required=True) + + class Meta: + part_of = "Order" + + +@domain.aggregate +class Order: + customer_id = Identifier(required=True) + items = HasMany("OrderItem") + status = String(choices=OrderStatus, default=OrderStatus.PENDING.value) + payment_id = Identifier() + + def confirm(self): + self.status = OrderStatus.CONFIRMED.value + self.raise_( + OrderConfirmed(order_id=self.id, confirmed_at=datetime.now(timezone.utc)) + ) + + +@domain.entity +class OrderItem: + product_id = Identifier(required=True) + quantity = Integer() + price = Float() + + class Meta: + part_of = Order + + +@domain.event +class StockReserved: + product_id = Identifier(required=True) + quantity = Integer(required=True) + reserved_at = DateTime(required=True) + + class Meta: + part_of = "Inventory" + + +@domain.aggregate +class Inventory: + product_id = Identifier(required=True) + quantity = Integer() + + def reserve_stock(self, quantity: int): + self.quantity -= quantity + self.raise_( + StockReserved( + product_id=self.product_id, + quantity=quantity, + reserved_at=datetime.now(timezone.utc), + ) + ) + + +@domain.domain_service(part_of=[Order, Inventory]) +class OrderPlacementService: + @classmethod + def place_order( + cls, order: Order, inventories: list[Inventory] + ) -> tuple[Order, list[Inventory]]: + for item in order.items: + inventory = next( + (i for i in inventories if i.product_id == item.product_id), None + ) + if inventory is None or inventory.quantity < item.quantity: + raise Exception("Product is out of stock") + + inventory.reserve_stock(item.quantity) + + order.confirm() + + return order, inventories diff --git a/mkdocs.yml b/mkdocs.yml index de8d6fc6..70f078dd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -26,6 +26,7 @@ theme: - navigation.path - navigation.prune - navigation.indexes + - navigation.footer - toc.follow - navigation.top - search.suggest @@ -39,7 +40,14 @@ markdown_extensions: - admonition - mdx_include - pymdownx.details - - pymdownx.superfences + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.snippets: + check_paths: true + base_path: docs_src nav: - Protean: - index.md @@ -109,6 +117,9 @@ nav: - guides/domain-definition/repositories.md - Adding Behavior: - guides/domain-behavior/index.md + - guides/domain-behavior/validations.md + - guides/domain-behavior/invariants.md + - guides/domain-behavior/aggregate-mutation.md - guides/domain-behavior/domain-services.md # - Application Layer: # - guides/app-layer/index.md diff --git a/src/protean/core/domain_service.py b/src/protean/core/domain_service.py index 25cbe6f7..39c1232e 100644 --- a/src/protean/core/domain_service.py +++ b/src/protean/core/domain_service.py @@ -1,6 +1,7 @@ import logging from protean.container import Element, OptionsMixin +from protean.exceptions import IncorrectUsageError from protean.utils import DomainObjects, derive_element_class logger = logging.getLogger(__name__) @@ -25,8 +26,21 @@ def __new__(cls, *args, **kwargs): @classmethod def _default_options(cls): - return [] + return [ + ("part_of", None), + ] def domain_service_factory(element_cls, **kwargs): - return derive_element_class(element_cls, BaseDomainService, **kwargs) + element_cls = derive_element_class(element_cls, BaseDomainService, **kwargs) + + if not element_cls.meta_.part_of or len(element_cls.meta_.part_of) < 2: + raise IncorrectUsageError( + { + "_entity": [ + f"Domain Service `{element_cls.__name__}` needs to be associated with two or more Aggregates" + ] + } + ) + + return element_cls diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index ea8bed5b..6a928227 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -857,6 +857,15 @@ def handlers_for(self, event: BaseEvent) -> List[BaseEventHandler]: # FIXME Optimize calls to this method with cache, but also with support for Multitenancy def repository_for(self, part_of): + if isinstance(part_of, str): + raise IncorrectUsageError( + { + "element": [ + f"Element {part_of} is not registered in domain {self.name}" + ] + } + ) + if part_of.element_type == DomainObjects.EVENT_SOURCED_AGGREGATE: return self.event_store.repository_for(part_of) else: diff --git a/tests/aggregate/test_aggregate_initialization.py b/tests/aggregate/test_aggregate_initialization.py index 2ec6370b..39a9e79c 100644 --- a/tests/aggregate/test_aggregate_initialization.py +++ b/tests/aggregate/test_aggregate_initialization.py @@ -3,7 +3,7 @@ import pytest -from protean import BaseEntity +from protean import BaseEntity, BaseAggregate from protean.exceptions import ValidationError from protean.reflection import attributes, declared_fields from protean.utils import fully_qualified_name @@ -21,6 +21,12 @@ class TestAggregateStructure: + def test_that_base_aggregate_cannot_be_instantiated(self): + with pytest.raises(TypeError) as exc: + BaseAggregate() + + assert str(exc.value) == "BaseAggregate cannot be instantiated" + def test_aggregate_inheritance(self): assert issubclass(Role, BaseEntity) diff --git a/tests/domain_service/elements.py b/tests/domain_service/elements.py deleted file mode 100644 index a5ab961d..00000000 --- a/tests/domain_service/elements.py +++ /dev/null @@ -1,6 +0,0 @@ -from protean import BaseDomainService - - -class DummyDomainService(BaseDomainService): - def do_complex_process(self): - print("Performing complex process...") diff --git a/tests/domain_service/test_domain_service_functionality.py b/tests/domain_service/test_domain_service_functionality.py new file mode 100644 index 00000000..2841fb7e --- /dev/null +++ b/tests/domain_service/test_domain_service_functionality.py @@ -0,0 +1,148 @@ +import pytest + +from datetime import datetime, timezone +from enum import Enum +from uuid import uuid4 + +from protean import ( + BaseAggregate, + BaseDomainService, + BaseEvent, + BaseValueObject, + BaseEntity, +) +from protean.fields import ( + DateTime, + Float, + Identifier, + Integer, + HasMany, + String, + ValueObject, +) + + +class OrderStatus(Enum): + PENDING = "PENDING" + CONFIRMED = "CONFIRMED" + SHIPPED = "SHIPPED" + DELIVERED = "DELIVERED" + + +class OrderConfirmed(BaseEvent): + order_id = Identifier(required=True) + confirmed_at = DateTime(required=True) + + class Meta: + part_of = "Order" + + +class Order(BaseAggregate): + customer_id = Identifier(required=True) + items = HasMany("OrderItem") + status = String(choices=OrderStatus, default=OrderStatus.PENDING.value) + payment_id = Identifier() + + def confirm(self): + self.status = OrderStatus.CONFIRMED.value + self.raise_( + OrderConfirmed(order_id=self.id, confirmed_at=datetime.now(timezone.utc)) + ) + + +class OrderItem(BaseEntity): + product_id = Identifier(required=True) + quantity = Integer() + price = Float() + + class Meta: + part_of = Order + + +class Warehouse(BaseValueObject): + location = String() + contact = String() + + class Meta: + part_of = "Inventory" + + +class StockReserved(BaseEvent): + product_id = Identifier(required=True) + quantity = Integer(required=True) + reserved_at = DateTime(required=True) + + class Meta: + part_of = "Inventory" + + +class Inventory(BaseAggregate): + product_id = Identifier(required=True) + quantity = Integer() + warehouse = ValueObject(Warehouse) + + def reserve_stock(self, quantity: int): + self.quantity -= quantity + self.raise_( + StockReserved( + product_id=self.product_id, + quantity=quantity, + reserved_at=datetime.now(timezone.utc), + ) + ) + + +class OrderPlacementService(BaseDomainService): + class Meta: + part_of = [Order, Inventory] + + @classmethod + def place_order(cls, order: Order, inventories: list[Inventory]): + for item in order.items: + inventory = next( + (i for i in inventories if i.product_id == item.product_id), None + ) + if inventory is None or inventory.quantity < item.quantity: + raise Exception("Product is out of stock") + + inventory.reserve_stock(item.quantity) + + order.confirm() + + return order, inventories + + +@pytest.fixture(autouse=True) +def register_elements(test_domain): + test_domain.register(Order) + test_domain.register(OrderItem) + test_domain.register(Warehouse) + test_domain.register(Inventory) + test_domain.register(OrderConfirmed) + test_domain.register(StockReserved) + test_domain.register(OrderPlacementService) + test_domain.init(traverse=False) + + +class TestOrderPlacement: + def test_order_placement(self): + order = Order(customer_id=str(uuid4()), payment_id=str(uuid4())) + order.add_items(OrderItem(product_id=str(uuid4()), quantity=10, price=100)) + + inventory = Inventory( + product_id=order.items[0].product_id, + quantity=100, + warehouse=Warehouse(location="NYC", contact="John Doe"), + ) + + order, inventories = OrderPlacementService.place_order(order, [inventory]) + + assert order.status == OrderStatus.CONFIRMED.value + assert inventories[0].quantity == 90 + assert len(order._events) == 1 + assert isinstance(order._events[0], OrderConfirmed) + assert len(inventory._events) == 1 + assert isinstance(inventory._events[0], StockReserved) + assert inventory._events[0].quantity == 10 + assert inventory._events[0].product_id == order.items[0].product_id + assert inventory._events[0].reserved_at is not None diff --git a/tests/domain_service/tests.py b/tests/domain_service/tests.py index 384ae561..114b5382 100644 --- a/tests/domain_service/tests.py +++ b/tests/domain_service/tests.py @@ -3,7 +3,21 @@ from protean import BaseDomainService from protean.utils import fully_qualified_name -from .elements import DummyDomainService + +def Aggregate1(BaseAggregate): + pass + + +def Aggregate2(BaseAggregate): + pass + + +class DummyDomainService(BaseDomainService): + class Meta: + part_of = [Aggregate1, Aggregate2] + + def do_complex_process(self): + print("Performing complex process...") class TestDomainServiceInitialization: @@ -26,7 +40,7 @@ def test_that_domain_service_can_be_registered_with_domain(self, test_domain): ) def test_that_domain_service_can_be_registered_via_annotations(self, test_domain): - @test_domain.domain_service + @test_domain.domain_service(part_of=[Aggregate1, Aggregate2]) class AnnotatedDomainService: def special_method(self): pass @@ -35,3 +49,34 @@ def special_method(self): fully_qualified_name(AnnotatedDomainService) in test_domain.registry.domain_services ) + + def test_that_domain_service_is_associated_with_aggregates(self, test_domain): + @test_domain.aggregate + class Aggregate3: + pass + + @test_domain.aggregate + class Aggregate4: + pass + + @test_domain.domain_service(part_of=[Aggregate3, Aggregate4]) + class AnnotatedDomainService: + def special_method(self): + pass + + assert ( + fully_qualified_name(AnnotatedDomainService) + in test_domain.registry.domain_services + ) + assert ( + Aggregate3 + in test_domain.registry.domain_services[ + fully_qualified_name(AnnotatedDomainService) + ].cls.meta_.part_of + ) + assert ( + Aggregate4 + in test_domain.registry.domain_services[ + fully_qualified_name(AnnotatedDomainService) + ].cls.meta_.part_of + ) diff --git a/tests/reflection/__init__.py b/tests/reflection/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/reflection/test_declared_fields.py b/tests/reflection/test_declared_fields.py new file mode 100644 index 00000000..3e89a278 --- /dev/null +++ b/tests/reflection/test_declared_fields.py @@ -0,0 +1,31 @@ +import pytest + +from protean import BaseAggregate +from protean.exceptions import IncorrectUsageError +from protean.fields import Integer, String +from protean.reflection import declared_fields + + +class Person(BaseAggregate): + name = String(max_length=50, required=True) + age = Integer() + + +def test_declared_fields(): + assert len(declared_fields(Person)) == 3 + assert all(key in declared_fields(Person) for key in ["name", "age", "id"]) + + +def test_declared_fields_on_non_element(): + class Dummy: + pass + + with pytest.raises(IncorrectUsageError) as exception: + declared_fields(Dummy) + + assert exception.value.messages == { + "field": [ + ".Dummy'> " + "does not have fields" + ] + } diff --git a/tests/reflection/test_fields.py b/tests/reflection/test_fields.py new file mode 100644 index 00000000..549ac39d --- /dev/null +++ b/tests/reflection/test_fields.py @@ -0,0 +1,31 @@ +import pytest + +from protean import BaseAggregate +from protean.exceptions import IncorrectUsageError +from protean.fields import Integer, String +from protean.reflection import fields + + +class Person(BaseAggregate): + name = String(max_length=50, required=True) + age = Integer() + + +def test_fields(): + assert len(fields(Person)) == 4 + assert all(key in fields(Person) for key in ["name", "age", "id", "_version"]) + + +def test_fields_on_non_element(): + class Dummy: + pass + + with pytest.raises(IncorrectUsageError) as exception: + fields(Dummy) + + assert exception.value.messages == { + "field": [ + ".Dummy'> " + "does not have fields" + ] + } diff --git a/tests/reflection/test_id_field.py b/tests/reflection/test_id_field.py index ee021c51..49c71918 100644 --- a/tests/reflection/test_id_field.py +++ b/tests/reflection/test_id_field.py @@ -18,9 +18,5 @@ def test_value_objects_do_not_have_id_fields(): id_field(Balance) assert str(exception.value) == str( - { - "identity": [ - " does not have identity fields" - ] - } + {"identity": [" does not have identity fields"]} ) diff --git a/tests/repository/tests.py b/tests/repository/tests.py index dfbc91b5..ef8646b2 100644 --- a/tests/repository/tests.py +++ b/tests/repository/tests.py @@ -1,5 +1,7 @@ import pytest +from protean.exceptions import IncorrectUsageError + from .elements import Person @@ -27,3 +29,14 @@ def test_that_all_aggregates_can_be_retrieved_with_repository(test_domain): test_domain.repository_for(Person).add(person) assert test_domain.repository_for(Person).all() == [person] + + +def test_that_incorrectusageerror_is_raised_when_retrieving_nonexistent_aggregate( + test_domain, +): + with pytest.raises(IncorrectUsageError) as exc: + test_domain.repository_for("Invalid") + + assert exc.value.messages == { + "element": ["Element Invalid is not registered in domain Test"] + }