Skip to content

Commit

Permalink
Support 3 flavors of Domain Service method specification
Browse files Browse the repository at this point in the history
  • Loading branch information
subhashb committed Jun 1, 2024
1 parent ed41efb commit 29830a4
Show file tree
Hide file tree
Showing 9 changed files with 720 additions and 67 deletions.
58 changes: 51 additions & 7 deletions docs/guides/domain-behavior/domain-services.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,61 @@ or command/event handlers.

## Defining a Domain Service

A Domain Service is defined with the `Domain.domain_service` decorator:
A Domain Service is defined with the `Domain.domain_service` decorator, and
associated with at least two aggregates with the `part_of` option.

```python hl_lines="1 5-6"
--8<-- "guides/domain-behavior/006.py:83:100"
The service methods in a Domain Service can be structured in three flavors:

### Class methods

If you don't have any invariants to be managed by the Domain Service, each
method in the Domain Service can simply be a class method, that receives all
the input necessary for performing the business function.

```python hl_lines="1-2"
--8<-- "guides/domain-behavior/008.py:88:98"
```

Invoking it is straight forward:

```shell
OrderPlacementService.place_order(order, inventories)
```

### Instance methods

In this flavor, the Domain Service is instantiated with the aggregates and each
method performs a distinct business function.

```python hl_lines="1-2 9"
--8<-- "guides/domain-behavior/007.py:88:112"
```

The domain service has to be associated with at least two aggregates with the
`part_of` option, as seen in the example above.
You would then instantiate the Domain Service, passing the relevant aggregates
and invoke the methods on the instance.

```shell
service = OrderPlacementService(order, inventories)
service.place_order()
```

### Callable class

If you have a single business function, you can simply model it as a callable
class:

```python hl_lines="1-2 9"
--8<-- "guides/domain-behavior/006.py:88:112"
```

```shell
service = OrderPlacementService(order, inventories)
service()
```

Each method in the Domain Service element is a class method, that receives two
or more aggregates or list of aggregates.
!!!note
You can include private methods in a Domain Service class by prefixing the
method name with an underscore.

## Typical Workflow

Expand Down
18 changes: 9 additions & 9 deletions docs_src/guides/domain-behavior/006.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ def __init__(self, order, inventories):
self.order = order
self.inventories = inventories

def __call__(self):
for item in self.order.items:
inventory = next(
(i for i in self.inventories if i.product_id == item.product_id), None
)
inventory.reserve_stock(item.quantity)

self.order.confirm()

@invariant.pre
def inventory_should_have_sufficient_stock(self):
for item in self.order.items:
Expand Down Expand Up @@ -138,12 +147,3 @@ def total_quantity_reserved_should_match_order_quantity(self):
raise ValidationError(
{"_service": ["Total reserved quantity does not match order quantity"]}
)

def __call__(self):
for item in self.order.items:
inventory = next(
(i for i in self.inventories if i.product_id == item.product_id), None
)
inventory.reserve_stock(item.quantity)

self.order.confirm()
149 changes: 149 additions & 0 deletions docs_src/guides/domain-behavior/007.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
from datetime import datetime, timezone
from enum import Enum

from protean import Domain, invariant
from protean.exceptions import ValidationError
from protean.fields import (
DateTime,
Float,
Identifier,
Integer,
HasMany,
String,
ValueObject,
)

domain = Domain(__file__)


class OrderStatus(Enum):
PENDING = "PENDING"
CONFIRMED = "CONFIRMED"
SHIPPED = "SHIPPED"
DELIVERED = "DELIVERED"


@domain.event(part_of="Order")
class OrderConfirmed:
order_id = Identifier(required=True)
confirmed_at = DateTime(required=True)


@domain.aggregate
class Order:
customer_id = Identifier(required=True)
items = HasMany("OrderItem")
status = String(choices=OrderStatus, default=OrderStatus.PENDING.value)
payment_id = Identifier()

@invariant.post
def order_should_contain_items(self):
if not self.items or len(self.items) == 0:
raise ValidationError({"_entity": ["Order must contain at least one item"]})

def confirm(self):
self.status = OrderStatus.CONFIRMED.value
self.raise_(
OrderConfirmed(order_id=self.id, confirmed_at=datetime.now(timezone.utc))
)


@domain.entity(part_of=Order)
class OrderItem:
product_id = Identifier(required=True)
quantity = Integer()
price = Float()


@domain.value_object(part_of="Inventory")
class Warehouse:
location = String()
contact = String()


@domain.event(part_of="Inventory")
class StockReserved:
product_id = Identifier(required=True)
quantity = Integer(required=True)
reserved_at = DateTime(required=True)


@domain.aggregate
class Inventory:
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),
)
)


@domain.domain_service(part_of=[Order, Inventory])
class OrderPlacementService:
def __init__(self, order, inventories):
super().__init__(*(order, inventories))

self.order = order
self.inventories = inventories

def place_order(self):
for item in self.order.items:
inventory = next(
(i for i in self.inventories if i.product_id == item.product_id), None
)
inventory.reserve_stock(item.quantity)

self.order.confirm()

@invariant.pre
def inventory_should_have_sufficient_stock(self):
for item in self.order.items:
inventory = next(
(i for i in self.inventories if i.product_id == item.product_id), None
)
if inventory is None or inventory.quantity < item.quantity:
raise ValidationError({"_service": ["Product is out of stock"]})

@invariant.pre
def order_payment_method_should_be_valid(self):
if not self.order.payment_id:
raise ValidationError(
{"_service": ["Order must have a valid payment method"]}
)

@invariant.post
def total_reserved_value_should_match_order_value(self):
order_total = sum(item.quantity * item.price for item in self.order.items)
reserved_total = 0
for item in self.order.items:
inventory = next(
(i for i in self.inventories if i.product_id == item.product_id), None
)
if inventory:
reserved_total += inventory._events[0].quantity * item.price

if order_total != reserved_total:
raise ValidationError(
{"_service": ["Total reserved value does not match order value"]}
)

@invariant.post
def total_quantity_reserved_should_match_order_quantity(self):
order_quantity = sum(item.quantity for item in self.order.items)
reserved_quantity = sum(
inventory._events[0].quantity
for inventory in self.inventories
if inventory._events
)

if order_quantity != reserved_quantity:
raise ValidationError(
{"_service": ["Total reserved quantity does not match order quantity"]}
)
98 changes: 98 additions & 0 deletions docs_src/guides/domain-behavior/008.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
from datetime import datetime, timezone
from enum import Enum

from protean import Domain, invariant
from protean.exceptions import ValidationError
from protean.fields import (
DateTime,
Float,
Identifier,
Integer,
HasMany,
String,
ValueObject,
)

domain = Domain(__file__)


class OrderStatus(Enum):
PENDING = "PENDING"
CONFIRMED = "CONFIRMED"
SHIPPED = "SHIPPED"
DELIVERED = "DELIVERED"


@domain.event(part_of="Order")
class OrderConfirmed:
order_id = Identifier(required=True)
confirmed_at = DateTime(required=True)


@domain.aggregate
class Order:
customer_id = Identifier(required=True)
items = HasMany("OrderItem")
status = String(choices=OrderStatus, default=OrderStatus.PENDING.value)
payment_id = Identifier()

@invariant.post
def order_should_contain_items(self):
if not self.items or len(self.items) == 0:
raise ValidationError({"_entity": ["Order must contain at least one item"]})

def confirm(self):
self.status = OrderStatus.CONFIRMED.value
self.raise_(
OrderConfirmed(order_id=self.id, confirmed_at=datetime.now(timezone.utc))
)


@domain.entity(part_of=Order)
class OrderItem:
product_id = Identifier(required=True)
quantity = Integer()
price = Float()


@domain.value_object(part_of="Inventory")
class Warehouse:
location = String()
contact = String()


@domain.event(part_of="Inventory")
class StockReserved:
product_id = Identifier(required=True)
quantity = Integer(required=True)
reserved_at = DateTime(required=True)


@domain.aggregate
class Inventory:
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),
)
)


@domain.domain_service(part_of=[Order, Inventory])
class OrderPlacementService:
@classmethod
def place_order(self, order, inventories):
for item in self.order.items:
inventory = next(
(i for i in self.inventories if i.product_id == item.product_id), None
)
inventory.reserve_stock(item.quantity)

self.order.confirm()
Loading

0 comments on commit 29830a4

Please sign in to comment.