Chapter 13: Checking Before You Ship with Domain Services
A customer just complained: they ordered five copies of a book, the order was confirmed, but only three were in stock. The current system confirms orders blindly. It never checks inventory. We need business logic that spans two aggregates (Order and Inventory), and that logic does not belong in either aggregate. It belongs in a domain service.
What Is a Domain Service?
A domain service is a stateless object that encapsulates business logic spanning two or more aggregates. Unlike aggregates, domain services:
- Have no identity and no lifecycle.
- Are invoked from command handlers or application services.
- Can run pre-invariants and post-invariants for validation.
- Are always associated with the aggregates they coordinate.
When to Use a Domain Service (vs. an Event Handler)
You might wonder: couldn't we use an event handler to check inventory when an order is confirmed? The key difference is transactional consistency:
| Approach | Guarantees |
|---|---|
| Domain service | Synchronous. Inventory is checked before the order is confirmed. If the stock it reads is insufficient, the command fails and the order stays PENDING. |
| Event handler | Eventually consistent; the order is confirmed first, then the handler runs. If stock is insufficient, you need compensating actions. |
Use a domain service when the business rule says "this must not happen", like confirming an order without sufficient stock. Use event handlers when the reaction can happen after the fact.
This chapter uses both. The domain service checks stock and confirms the order. Reserving the stock changes a second aggregate, Inventory, so it happens in its own transaction, through an event and a command.
The Fulfillment Service
@domain.domain_service(part_of=[Order, Inventory])
class OrderFulfillmentService:
"""Validates inventory availability before confirming an order."""
def __init__(self, order, inventories):
super().__init__(order, *inventories)
self.order = order
self.inventories = inventories
@invariant.pre
def all_items_in_stock(self):
"""Check that every order item has sufficient inventory."""
inventory_by_title = {inv.title: inv for inv in self.inventories}
for title, quantity in self.order.quantities_by_title().items():
inv = inventory_by_title.get(title)
if inv is None:
raise ValidationError(
{"_entity": [f"No inventory record for '{title}'"]}
)
if inv.quantity < quantity:
raise ValidationError(
{
"_entity": [
(
f"Insufficient stock for '{title}': "
f"{inv.quantity} available, {quantity} requested"
)
]
}
)
def confirm_order(self):
"""Confirm the order. The pre-invariant has already checked stock."""
self.order.confirm()
return self.order
Let's break down how this works:
-
part_of=[Order, Inventory]: The service is associated with both aggregates. Protean requires domain services to declare which aggregates they coordinate. -
__init__: The constructor receives the aggregates and callssuper().__init__()with all of them, which keeps them on the service. Protean wires the invariants when it registers the class, so they run even if a service leaves this call out. -
@invariant.pre: Theall_items_in_stockinvariant runs beforeconfirm_order()executes. If any item is out of stock, aValidationErroris raised and the order is never confirmed. The check adds up the order's lines for each title, so two lines for the same book are checked against the stock together. Pre-invariants are the domain service's main value. They check a rule that spans aggregates before anything changes. -
confirm_order(): The domain method that performs the mutation. It confirms the order, andOrder.confirm()raisesOrderConfirmedwith the customer's name and the quantity ordered for each title. The service does not touch Inventory.
Updating the Command Handler
The ConfirmOrder handler now loads inventory records and delegates to
the domain service:
@domain.command_handler(part_of=Order)
class OrderCommandHandler:
@handle(ConfirmOrder)
def confirm_order(self, command: ConfirmOrder) -> None:
repo = current_domain.repository_for(Order)
order = repo.get(command.order_id)
if order.status == "CONFIRMED":
# A repeated ConfirmOrder would raise OrderConfirmed a second time.
return
# Load inventory records for all items in the order
inv_repo = current_domain.repository_for(Inventory)
inventories = []
for title in order.quantities_by_title():
inv_results = inv_repo.query.filter(title=title).all()
if inv_results.items:
inventories.append(inv_results.items[0])
# Delegate to the domain service
service = OrderFulfillmentService(order, inventories)
service.confirm_order()
# Persist only the order. Inventory reserves through OrderConfirmed.
repo.add(order)
The handler's job is orchestration, not business logic:
- Load the order from the repository. Stop if it is already confirmed, so a
repeated
ConfirmOrderchanges nothing. - Load the relevant inventory records.
- Pass everything to the domain service.
- Persist the order. Only the order.
If the inventory check fails, the ValidationError propagates to the
API layer and returns a 400 Bad Request automatically (thanks to the
exception handlers we registered in Chapter 10). Nothing was saved, so the
order stays PENDING.
A handler changes one aggregate per transaction. The inventory records are loaded so the service can check them, but they are not saved here.
The handlers find a book's stock by its title, so the tutorial assumes each
title has one inventory record. If two records share a title, the reservation
below fails with TooManyObjectsError after the order is saved.
Reserving the Stock
OrderConfirmed lists each title once, with its total quantity. An event
handler in Order's cluster reacts to it and sends one ReserveInventory
command per title. Inventory's command handler does the reservation:
@domain.event_handler(part_of=Order)
class OrderEventHandler:
@handle(OrderConfirmed)
def on_order_confirmed(self, event: OrderConfirmed) -> None:
print(
f" [Notification] Order {event.order_id} confirmed for {event.customer_name}"
)
@handle(OrderConfirmed)
def reserve_inventory(self, event: OrderConfirmed) -> None:
for item in event.items:
current_domain.process(
ReserveInventory(
order_id=event.order_id,
book_title=item["book_title"],
quantity=item["quantity"],
)
)
@domain.command_handler(part_of=Inventory)
class InventoryCommandHandler:
@handle(ReserveInventory)
def reserve(self, command: ReserveInventory) -> None:
repo = current_domain.repository_for(Inventory)
inventory = repo.find_by(title=command.book_title)
# OrderConfirmed lists each title once, so an order reserves a given
# book once. A redelivered event finds its order here and stops.
if command.order_id in inventory.reserved_order_ids:
return
inventory.reserve(command.order_id, command.quantity)
repo.add(inventory)
The event handler sits in Order's cluster because Order owns
OrderConfirmed. It does not change Inventory itself. It hands the change to
Inventory as a command. reserve_inventory belongs in the OrderEventHandler
from Chapter 6, next to its on_order_confirmed notification. Add the method
to that class; do not define a second OrderEventHandler. One handler can have
several methods for the same event, and each one runs.
Events can be delivered more than once, so the command handler checks
reserved_order_ids first and skips an order it has already reserved for.
Without that check, a redelivered OrderConfirmed would reserve the stock
twice. The check works because an order reserves each book once: two lines for
the same book arrive as one command with the summed quantity. The list grows by
one id for every order that reserves the book, and it loads with the inventory
record each time.
The stock check and the reservation happen in two transactions. The service
checks the stock it read. The reservation runs later, in its own transaction,
and can still fail: another order can take the last copies in between.
Inventory.reserve() checks the stock again and raises ValidationError, so
the stock never goes below zero. The order, though, is already saved as
CONFIRMED, and no stock is reserved for it. With the "sync" settings below,
domain.process(ConfirmOrder(...)) then raises TransactionError. With
asynchronous processing, the reservation fails in the server instead.
A real store has to handle that failure. A common way is a compensating step:
Inventory's command handler catches the error and raises an event such as
ReservationFailed, and a handler reacts by cancelling the order. Chapter 20
builds this kind of compensation with a process manager for a failed shipment.
It leaves a failed reservation out, and describes how to add it.
This chapter's domain sets command_processing and event_processing to
"sync", so the whole chain runs before domain.process() returns. With
asynchronous processing, the reservation runs shortly after, in the server.
Testing the Domain Service
Test both the happy path and the out-of-stock scenario:
# tests/test_domain_services.py (example tests)
def test_confirm_order_with_stock():
"""Order is confirmed when inventory is sufficient."""
with domain.domain_context():
inv = Inventory(book_id="book-1", title="Dune", quantity=10)
order = Order(
customer_name="Alice",
items=[
OrderItem(book_title="Dune", quantity=2, unit_price=Money(amount=15.99))
],
)
service = OrderFulfillmentService(order, [inv])
service.confirm_order()
assert order.status == "CONFIRMED"
assert inv.quantity == 10 # the service checks stock; reserving comes later
def test_confirm_order_out_of_stock():
"""Order fails when inventory is insufficient."""
with domain.domain_context():
inv = Inventory(book_id="book-1", title="Dune", quantity=1)
order = Order(
customer_name="Alice",
items=[
OrderItem(book_title="Dune", quantity=5, unit_price=Money(amount=15.99))
],
)
try:
service = OrderFulfillmentService(order, [inv])
service.confirm_order()
raise AssertionError("Should have raised ValidationError")
except ValidationError as e:
assert "Insufficient stock" in str(e.messages)
Notice that we test the domain service directly, with no command processing
and no repositories. The tests open a domain context because Order.confirm()
raises an event. The service takes aggregates as input and changes them in
memory, so it is easy to unit test. The happy-path test also shows that the service leaves the inventory
alone: the stock is still 10 after the order is confirmed.
What We Built
- An
OrderFulfillmentServicedomain service that validates inventory across aggregates before confirming an order. - A
@invariant.prethat enforces "all items must be in stock" as a cross-aggregate business rule. - An updated
ConfirmOrderhandler that delegates to the service and persists only the order. - An
OrderConfirmedevent, an event handler method that turns it into oneReserveInventorycommand per title, and an Inventory command handler that reserves the stock once per order. - Tests verifying both success and failure paths.
In the next chapter, we will integrate with an external book supplier using a subscriber.
Full Source
from protean import Domain, handle, invariant
from protean.exceptions import ValidationError
from protean.fields import (
Dict,
Float,
HasMany,
Identifier,
Integer,
List,
String,
ValueObject,
)
from protean.utils.globals import current_domain
domain = Domain("bookshelf")
domain.config["command_processing"] = "sync"
domain.config["event_processing"] = "sync"
@domain.value_object
class Money:
currency: String(max_length=3, default="USD")
amount: Float(required=True)
@domain.aggregate
class Inventory:
book_id: Identifier(required=True)
title: String(max_length=200, required=True)
quantity: Integer(default=0)
reserved_order_ids = List(content_type=String)
def reserve(self, order_id: str, amount: int):
if self.quantity < amount:
raise ValidationError(
{
"quantity": [
f"Insufficient stock: {self.quantity} available, {amount} requested"
]
}
)
self.quantity -= amount
self.reserved_order_ids = [*self.reserved_order_ids, order_id]
@domain.aggregate
class Order:
customer_name: String(max_length=150, required=True)
status: String(max_length=20, default="PENDING")
items = HasMany("OrderItem")
def quantities_by_title(self) -> dict[str, int]:
"""Total quantity per book, adding up lines for the same title."""
totals: dict[str, int] = {}
for item in self.items:
totals[item.book_title] = totals.get(item.book_title, 0) + item.quantity
return totals
def confirm(self):
self.status = "CONFIRMED"
self.raise_(
OrderConfirmed(
order_id=self.id,
customer_name=self.customer_name,
items=[
{"book_title": title, "quantity": quantity}
for title, quantity in self.quantities_by_title().items()
],
)
)
@domain.entity(part_of=Order)
class OrderItem:
book_title: String(max_length=200, required=True)
quantity: Integer(required=True)
unit_price = ValueObject(Money)
@domain.event(part_of=Order)
class OrderConfirmed:
order_id: Identifier(required=True)
customer_name: String(max_length=150, required=True)
items = List(content_type=Dict)
@domain.domain_service(part_of=[Order, Inventory])
class OrderFulfillmentService:
"""Validates inventory availability before confirming an order."""
def __init__(self, order, inventories):
super().__init__(order, *inventories)
self.order = order
self.inventories = inventories
@invariant.pre
def all_items_in_stock(self):
"""Check that every order item has sufficient inventory."""
inventory_by_title = {inv.title: inv for inv in self.inventories}
for title, quantity in self.order.quantities_by_title().items():
inv = inventory_by_title.get(title)
if inv is None:
raise ValidationError(
{"_entity": [f"No inventory record for '{title}'"]}
)
if inv.quantity < quantity:
raise ValidationError(
{
"_entity": [
(
f"Insufficient stock for '{title}': "
f"{inv.quantity} available, {quantity} requested"
)
]
}
)
def confirm_order(self):
"""Confirm the order. The pre-invariant has already checked stock."""
self.order.confirm()
return self.order
@domain.command(part_of=Order)
class ConfirmOrder:
order_id: Identifier(required=True)
@domain.command(part_of=Inventory)
class ReserveInventory:
order_id: Identifier(required=True)
book_title: String(max_length=200, required=True)
quantity: Integer(required=True)
@domain.command_handler(part_of=Order)
class OrderCommandHandler:
@handle(ConfirmOrder)
def confirm_order(self, command: ConfirmOrder) -> None:
repo = current_domain.repository_for(Order)
order = repo.get(command.order_id)
if order.status == "CONFIRMED":
# A repeated ConfirmOrder would raise OrderConfirmed a second time.
return
# Load inventory records for all items in the order
inv_repo = current_domain.repository_for(Inventory)
inventories = []
for title in order.quantities_by_title():
inv_results = inv_repo.query.filter(title=title).all()
if inv_results.items:
inventories.append(inv_results.items[0])
# Delegate to the domain service
service = OrderFulfillmentService(order, inventories)
service.confirm_order()
# Persist only the order. Inventory reserves through OrderConfirmed.
repo.add(order)
@domain.event_handler(part_of=Order)
class OrderEventHandler:
@handle(OrderConfirmed)
def on_order_confirmed(self, event: OrderConfirmed) -> None:
print(
f" [Notification] Order {event.order_id} confirmed for {event.customer_name}"
)
@handle(OrderConfirmed)
def reserve_inventory(self, event: OrderConfirmed) -> None:
for item in event.items:
current_domain.process(
ReserveInventory(
order_id=event.order_id,
book_title=item["book_title"],
quantity=item["quantity"],
)
)
@domain.command_handler(part_of=Inventory)
class InventoryCommandHandler:
@handle(ReserveInventory)
def reserve(self, command: ReserveInventory) -> None:
repo = current_domain.repository_for(Inventory)
inventory = repo.find_by(title=command.book_title)
# OrderConfirmed lists each title once, so an order reserves a given
# book once. A redelivered event finds its order here and stops.
if command.order_id in inventory.reserved_order_ids:
return
inventory.reserve(command.order_id, command.quantity)
repo.add(inventory)
domain.init(traverse=False)
# tests/test_domain_services.py (example tests)
def test_confirm_order_with_stock():
"""Order is confirmed when inventory is sufficient."""
with domain.domain_context():
inv = Inventory(book_id="book-1", title="Dune", quantity=10)
order = Order(
customer_name="Alice",
items=[
OrderItem(book_title="Dune", quantity=2, unit_price=Money(amount=15.99))
],
)
service = OrderFulfillmentService(order, [inv])
service.confirm_order()
assert order.status == "CONFIRMED"
assert inv.quantity == 10 # the service checks stock; reserving comes later
def test_confirm_order_out_of_stock():
"""Order fails when inventory is insufficient."""
with domain.domain_context():
inv = Inventory(book_id="book-1", title="Dune", quantity=1)
order = Order(
customer_name="Alice",
items=[
OrderItem(book_title="Dune", quantity=5, unit_price=Money(amount=15.99))
],
)
try:
service = OrderFulfillmentService(order, [inv])
service.confirm_order()
raise AssertionError("Should have raised ValidationError")
except ValidationError as e:
assert "Insufficient stock" in str(e.messages)
Next
Chapter 14: Connecting to the Outside World with Subscribers →