Thin Handlers, Rich Domain
The Problem
A developer writes a command handler for processing refunds:
# fragment
@domain.command_handler(part_of=Order)
class OrderCommandHandler(BaseCommandHandler):
@handle(RequestRefund)
def request_refund(self, command: RequestRefund):
repo = current_domain.repository_for(Order)
order = repo.get(command.order_id)
# Check if the order is eligible for refund
if order.status not in ("shipped", "delivered"):
raise ValidationError(
{"status": ["Only shipped or delivered orders can be refunded"]}
)
# Check if the refund window has passed
if order.delivered_at:
days_since_delivery = (datetime.now(timezone.utc) - order.delivered_at).days
if days_since_delivery > 30:
raise ValidationError(
{"timing": ["Refund window has expired (30 days)"]}
)
# Calculate the refund amount
refund_amount = order.total
if order.partial_refund_amount:
refund_amount = order.total - order.partial_refund_amount
if command.amount and command.amount < refund_amount:
refund_amount = command.amount
# Apply the refund
order.status = "refund_requested"
order.refund_amount = refund_amount
order.refund_requested_at = datetime.now(timezone.utc)
order.refund_reason = command.reason
# Raise events
order.raise_(RefundRequested(
order_id=order.order_id,
customer_id=order.customer_id,
amount=refund_amount,
reason=command.reason,
))
# Check if we need to notify the warehouse
if order.status == "shipped" and not order.delivered_at:
order.raise_(ShipmentInterceptRequested(
order_id=order.order_id,
tracking_number=order.tracking_number,
))
repo.add(order)
This handler is 40 lines of business logic. It validates, calculates, mutates,
decides, and raises events. The Order aggregate is a passive data container
that the handler manipulates.
This is the anemic domain model. In this anti-pattern, domain objects carry data but no behavior. Service layers (handlers, application services, utility functions) contain the business logic.
The consequences:
-
Logic duplication: Another handler that processes admin overrides reimplements the refund calculation. A batch job that auto-refunds expired subscriptions has its own version. Each diverges slightly over time.
-
Untestable business logic: To test refund eligibility, you need to construct a command, set up a repository, create a handler instance, and run it within a UoW. The business rule is buried inside infrastructure.
-
Hidden dependencies: The handler knows about refund windows, partial refunds, shipment interception, and warehouse notifications. These are domain concerns scattered across infrastructure code.
-
Aggregate bypass: Since the aggregate has no methods, nothing prevents other code from setting
order.status = "refund_requested"without checking eligibility or raising events.
The root cause: behavior is in the wrong place. The handler should orchestrate; the domain model should decide.
The Pattern
Keep handlers thin. They load, delegate, and save. Move all business logic into aggregates and domain services.
Thin handler:
1. Load aggregate from repository
2. Call one aggregate method
3. Save aggregate to repository
Rich domain:
- Aggregate methods validate, mutate, and raise events
- Domain services coordinate cross-aggregate logic
- Value objects encapsulate concept rules
The handler pattern is almost always three lines:
# fragment
@handle(SomeCommand)
def handle_command(self, command: SomeCommand):
repo = current_domain.repository_for(Aggregate)
aggregate = repo.get(command.aggregate_id)
aggregate.do_the_thing(command.relevant_data)
repo.add(aggregate)
Load. Call. Save. Everything else belongs in the domain model.
Applying the Pattern
Before: Thick Handler
# fragment
@handle(RequestRefund)
def request_refund(self, command: RequestRefund):
repo = current_domain.repository_for(Order)
order = repo.get(command.order_id)
# 40 lines of business logic in the handler
if order.status not in ("shipped", "delivered"):
raise ValidationError(...)
days_since = (datetime.now(timezone.utc) - order.delivered_at).days
if days_since > 30:
raise ValidationError(...)
refund_amount = order.total - (order.partial_refund_amount or 0)
if command.amount and command.amount < refund_amount:
refund_amount = command.amount
order.status = "refund_requested"
order.refund_amount = refund_amount
order.refund_requested_at = datetime.now(timezone.utc)
order.refund_reason = command.reason
order.raise_(RefundRequested(...))
if order.status == "shipped" and not order.delivered_at:
order.raise_(ShipmentInterceptRequested(...))
repo.add(order)
After: Thin Handler, Rich Aggregate
from datetime import UTC, datetime
from protean import Domain, current_domain, handle
from protean.core.command_handler import BaseCommandHandler
from protean.exceptions import ValidationError
from protean.fields import Auto, DateTime, Float, HasMany, Identifier, Integer, String
domain = Domain(name="ThinHandlersRefunds")
@domain.entity(part_of="Order")
class OrderItem:
product_id: Identifier(required=True)
quantity: Integer(min_value=1)
unit_price: Float()
@domain.command(part_of="Order")
class RequestRefund:
order_id: Identifier(required=True)
amount: Float()
reason: String()
@domain.event(part_of="Order")
class RefundRequested:
order_id: Identifier(required=True)
customer_id: Identifier(required=True)
amount: Float()
reason: String()
@domain.event(part_of="Order")
class ShipmentInterceptRequested:
order_id: Identifier(required=True)
tracking_number: String()
@domain.aggregate
class Order:
order_id: Auto(identifier=True)
customer_id: Identifier(required=True)
items = HasMany(OrderItem)
status: String(default="draft")
total: Float(default=0.0)
partial_refund_amount: Float(default=0.0)
refund_amount: Float()
refund_reason: String()
refund_requested_at: DateTime()
delivered_at: DateTime()
tracking_number: String()
REFUND_WINDOW_DAYS = 30
def request_refund(self, amount: float | None = None, reason: str = "") -> None:
"""Request a refund for this order."""
self._validate_refund_eligibility()
refund_amount = self._calculate_refund_amount(amount)
needs_intercept = self._needs_shipment_intercept()
self.status = "refund_requested"
self.refund_amount = refund_amount
self.refund_requested_at = datetime.now(UTC)
self.refund_reason = reason
self.raise_(
RefundRequested(
order_id=self.order_id,
customer_id=self.customer_id,
amount=refund_amount,
reason=reason,
)
)
if needs_intercept:
self.raise_(
ShipmentInterceptRequested(
order_id=self.order_id,
tracking_number=self.tracking_number,
)
)
def _validate_refund_eligibility(self) -> None:
"""Check if this order can be refunded."""
if self.status not in ("shipped", "delivered"):
raise ValidationError(
{"status": ["Only shipped or delivered orders can be refunded"]}
)
if self.delivered_at:
delivered_at = self.delivered_at
# A database without time zones, such as SQLite, returns a naive value
if delivered_at.tzinfo is None:
delivered_at = delivered_at.replace(tzinfo=UTC)
days_since = (datetime.now(UTC) - delivered_at).days
if days_since > self.REFUND_WINDOW_DAYS:
raise ValidationError(
{
"timing": [
f"Refund window has expired ({self.REFUND_WINDOW_DAYS} days)"
]
}
)
def _calculate_refund_amount(self, requested_amount: float | None = None) -> float:
"""Calculate the actual refund amount."""
max_refundable = self.total - self.partial_refund_amount
if max_refundable <= 0:
raise ValidationError({"amount": ["Nothing is left to refund"]})
if requested_amount is None:
return max_refundable
if requested_amount <= 0:
raise ValidationError({"amount": ["Refund amount must be positive"]})
return min(requested_amount, max_refundable)
def _needs_shipment_intercept(self) -> bool:
"""Check if we need to intercept the shipment."""
return self.status == "shipped" and not self.delivered_at
@domain.command_handler(part_of=Order)
class OrderCommandHandler(BaseCommandHandler):
@handle(RequestRefund)
def request_refund(self, command: RequestRefund):
repo = current_domain.repository_for(Order)
order = repo.get(command.order_id)
order.request_refund(
amount=command.amount,
reason=command.reason,
)
repo.add(order)
The handler is 5 lines. The aggregate has the business logic organized into clear, named methods with specific responsibilities.
Where Logic Lives
Aggregate Methods: Single-Aggregate Business Rules
Logic that depends only on the aggregate's own state belongs in aggregate methods:
from datetime import date
from protean import Domain, current_domain, handle
from protean.core.command_handler import BaseCommandHandler
from protean.exceptions import ValidationError
from protean.fields import Auto, Date, Float, Identifier, String
domain = Domain(name="ThinHandlersAccounts")
@domain.event(part_of="Account")
class MoneyWithdrawn:
account_id: Identifier(required=True)
amount: Float(required=True)
new_balance: Float(required=True)
@domain.command(part_of="Account")
class WithdrawMoney:
account_id: Identifier(required=True)
amount: Float(required=True)
@domain.aggregate
class Account:
account_id: Auto(identifier=True)
balance: Float(default=0.0)
overdraft_limit: Float(default=0.0)
status: String(default="active")
daily_withdrawal_total: Float(default=0.0)
last_withdrawal_date: Date()
DAILY_WITHDRAWAL_LIMIT = 5000.0
def withdraw(self, amount: float) -> None:
"""Withdraw money from this account."""
if self.status != "active":
raise ValidationError(
{"status": ["Cannot withdraw from a non-active account"]}
)
if amount <= 0:
raise ValidationError({"amount": ["Withdrawal amount must be positive"]})
self._check_daily_limit(amount)
if self.balance - amount < -self.overdraft_limit:
raise ValidationError({"balance": ["Insufficient funds"]})
self.balance -= amount
self._update_daily_total(amount)
self.raise_(
MoneyWithdrawn(
account_id=self.account_id,
amount=amount,
new_balance=self.balance,
)
)
def _check_daily_limit(self, amount: float) -> None:
today = date.today()
if self.last_withdrawal_date != today:
self.daily_withdrawal_total = 0.0
if self.daily_withdrawal_total + amount > self.DAILY_WITHDRAWAL_LIMIT:
raise ValidationError(
{
"amount": [
f"Daily withdrawal limit of {self.DAILY_WITHDRAWAL_LIMIT} exceeded"
]
}
)
def _update_daily_total(self, amount: float) -> None:
today = date.today()
if self.last_withdrawal_date != today:
self.daily_withdrawal_total = amount
else:
self.daily_withdrawal_total += amount
self.last_withdrawal_date = today
All withdrawal logic (status check, daily limits, balance check, total tracking) is in the aggregate. The handler is:
@domain.command_handler(part_of=Account)
class AccountCommandHandler(BaseCommandHandler):
@handle(WithdrawMoney)
def withdraw(self, command: WithdrawMoney):
repo = current_domain.repository_for(Account)
account = repo.get(command.account_id)
account.withdraw(command.amount)
repo.add(account)
Domain Services: Cross-Aggregate Coordination
Logic that reads from multiple aggregates but modifies only one belongs in a domain service:
from protean import Domain, current_domain, handle
from protean.core.command_handler import BaseCommandHandler
from protean.exceptions import ValidationError
from protean.fields import Auto, Float, Identifier
domain = Domain(name="ThinHandlersTransfers")
@domain.event(part_of="Account")
class MoneyDebited:
account_id: Identifier(required=True)
amount: Float(required=True)
transfer_id: Identifier(required=True)
target_account_id: Identifier(required=True)
@domain.aggregate
class Account:
account_id: Auto(identifier=True)
balance: Float(default=0.0)
risk_score: Float(default=0.0)
credit_policy_id: Identifier(required=True)
def debit(self, amount: float, transfer_id: str, target_account_id: str) -> None:
if amount <= 0:
raise ValidationError({"amount": ["Transfer amount must be positive"]})
if amount > self.balance:
raise ValidationError({"balance": ["Insufficient funds"]})
self.balance -= amount
self.raise_(
MoneyDebited(
account_id=self.account_id,
amount=amount,
transfer_id=transfer_id,
target_account_id=target_account_id,
)
)
@domain.aggregate
class CreditPolicy:
policy_id: Auto(identifier=True)
max_transfer_amount: Float(required=True)
max_risk_score: Float(required=True)
@domain.command(part_of=Account)
class TransferMoney:
transfer_id: Identifier(required=True)
from_account_id: Identifier(required=True)
to_account_id: Identifier(required=True)
amount: Float(required=True)
@domain.domain_service(part_of=[Account, CreditPolicy])
class TransferService:
"""Validates transfer eligibility across Account and CreditPolicy."""
def __init__(self, source: Account, policy: CreditPolicy):
super().__init__(source, policy)
self.source = source
self.policy = policy
def validate_and_debit(
self, amount: float, transfer_id: str, target_account_id: str
) -> None:
"""Validate the transfer and debit the source account."""
if amount > self.policy.max_transfer_amount:
raise ValidationError(
{
"amount": [
f"Exceeds policy limit of {self.policy.max_transfer_amount}"
]
}
)
if self.source.risk_score > self.policy.max_risk_score:
raise ValidationError(
{"risk": ["Account risk score exceeds policy threshold"]}
)
# Delegate the actual debit to the aggregate
self.source.debit(amount, transfer_id, target_account_id)
@domain.command_handler(part_of=Account)
class AccountCommandHandler(BaseCommandHandler):
@handle(TransferMoney)
def transfer(self, command: TransferMoney):
account_repo = current_domain.repository_for(Account)
policy_repo = current_domain.repository_for(CreditPolicy)
source = account_repo.get(command.from_account_id)
# A second aggregate, loaded for the domain service
policy = policy_repo.get(source.credit_policy_id)
TransferService(source, policy).validate_and_debit(
amount=command.amount,
transfer_id=command.transfer_id,
target_account_id=command.to_account_id,
)
account_repo.add(source)
The handler loads both aggregates and calls the domain service. The domain
service coordinates the validation logic. The aggregate's debit() method
handles the actual state change. Each component has a clear responsibility.
Value Objects: Concept-Level Logic
Logic intrinsic to a domain concept belongs in the value object:
from protean import Domain
from protean.exceptions import ValidationError
from protean.fields import Float, String
domain = Domain(name="ThinHandlersMoney")
@domain.value_object
class Money:
amount: Float(required=True)
currency: String(max_length=3, required=True)
def add(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValidationError({"currency": ["Cannot add different currencies"]})
return Money(amount=self.amount + other.amount, currency=self.currency)
Money checks that two amounts share a currency, so the handler that adds two
monetary amounts needs no currency check.
The Three-Line Handler Test
If your handler has more than the three canonical lines (load, call, save), ask whether the extra logic belongs in the domain model:
| Handler Line | Belongs In Handler? | Move To |
|---|---|---|
repo.get(id) |
Yes | — |
aggregate.method(data) |
Yes | — |
repo.add(aggregate) |
Yes | — |
if aggregate.status != "active" |
No | Aggregate method (precondition) |
amount = command.total * 0.9 |
No | Aggregate method (calculation) |
aggregate.raise_(Event(...)) |
No | Aggregate method (event raising) |
if user.role != "admin" |
Maybe | Handler (Layer 4 contextual check) |
service.validate(a, b) |
Yes | Domain service (cross-aggregate) |
The handler is the coordinator, and the domain model is the decision-maker.
Signs Your Handler Is Too Thick
-
If-else business logic: Conditional branching based on aggregate state should be inside the aggregate.
-
Calculations: Computing amounts, percentages, dates, or derived values belongs in the aggregate or a domain service.
-
Multiple aggregate method calls: If the handler calls several methods in sequence on the same aggregate, those methods should be composed into a single higher-level method on the aggregate.
-
Event raising: Events should be raised inside aggregate methods, not in the handler.
-
Domain knowledge: If the handler "knows" business rules (refund windows, discount calculations, eligibility criteria), that knowledge belongs in the domain model.
-
More than 10 lines of non-infrastructure code: A handler that's longer than load-call-save is a code smell.
Testing Benefits
The most compelling reason for thin handlers: domain logic becomes directly testable.
Testing with Thick Handlers (Hard)
# fragment
class TestRefund:
def test_refund_eligibility(self, test_domain):
# Must set up: command, handler, repository, UoW
test_domain.register(Order)
test_domain.register(OrderCommandHandler)
test_domain.init(traverse=False)
with test_domain.domain_context():
# Create and persist an order
repo = test_domain.repository_for(Order)
order = Order(status="delivered", total=100.0, delivered_at=datetime.now())
repo.add(order)
# Build command
command = RequestRefund(order_id=order.order_id, reason="Changed mind")
# Process through handler
test_domain.process(command)
# Verify
updated = repo.get(order.order_id)
assert updated.status == "refund_requested"
Testing with Thin Handlers (Easy)
The test_domain fixture comes from a conftest.py next to the tests. It
initializes the domain and opens a domain context, so the aggregate can raise
events:
# fragment
import pytest
from refunds.domain import domain
@pytest.fixture
def test_domain():
domain.init(traverse=False)
with domain.domain_context():
yield domain
Each test builds an order and calls its method:
# fragment
from datetime import UTC, datetime, timedelta
import pytest
from refunds.domain import Order, RefundRequested
from protean.exceptions import ValidationError
class TestRefund:
def test_can_refund_delivered_order(self, test_domain):
order = Order(
customer_id="cust-1",
status="delivered",
total=100.0,
delivered_at=datetime.now(UTC),
)
order.request_refund(reason="Changed mind")
assert order.status == "refund_requested"
assert order.refund_amount == 100.0
assert len(order._events) == 1
assert isinstance(order._events[0], RefundRequested)
def test_cannot_refund_draft_order(self, test_domain):
order = Order(customer_id="cust-1", status="draft", total=100.0)
with pytest.raises(ValidationError) as exc:
order.request_refund(reason="Changed mind")
assert "Only shipped or delivered orders" in str(exc.value)
def test_refund_window_expired(self, test_domain):
order = Order(
customer_id="cust-1",
status="delivered",
total=100.0,
delivered_at=datetime.now(UTC) - timedelta(days=31),
)
with pytest.raises(ValidationError) as exc:
order.request_refund(reason="Changed mind")
assert "Refund window has expired" in str(exc.value)
def test_partial_refund_calculation(self, test_domain):
order = Order(
customer_id="cust-1",
status="delivered",
total=100.0,
partial_refund_amount=30.0,
delivered_at=datetime.now(UTC),
)
order.request_refund(amount=50.0, reason="Partial")
assert order.refund_amount == 50.0
def test_partial_refund_capped_at_remaining(self, test_domain):
order = Order(
customer_id="cust-1",
status="delivered",
total=100.0,
partial_refund_amount=30.0,
delivered_at=datetime.now(UTC),
)
order.request_refund(amount=200.0, reason="Full")
assert order.refund_amount == 70.0 # Capped at remaining
Five focused tests, each testing a specific business rule, without any infrastructure setup. Fast, readable, and comprehensive.
Event Handlers Follow the Same Pattern
Event handlers should be equally thin:
from collections import Counter
from protean import Domain, current_domain, handle
from protean.core.event_handler import BaseEventHandler
from protean.exceptions import ValidationError
from protean.fields import Auto, Dict, Identifier, Integer, List, String
domain = Domain(name="warehouse")
@domain.aggregate
class Order:
order_id: Auto(identifier=True)
customer_id: Identifier(required=True)
status: String(default="draft")
@domain.event(part_of=Order)
class OrderPlaced:
order_id: Identifier(required=True)
items: List(content_type=Dict)
@domain.aggregate
class Inventory:
product_id: Identifier(identifier=True)
available: Integer(default=0)
reserved: Integer(default=0)
reserved_for: List(content_type=String)
def reserve(self, order_id: str, quantity: int) -> None:
"""Reserve stock for an order, once per order."""
if order_id in self.reserved_for:
return
if quantity > self.available:
raise ValidationError(
{"quantity": [f"Only {self.available} units of {self.product_id} left"]}
)
self.available -= quantity
self.reserved += quantity
self.reserved_for = [*self.reserved_for, order_id]
# Thin event handler
@domain.event_handler(part_of=Inventory, stream_category="warehouse::order")
class InventoryEventHandler(BaseEventHandler):
@handle(OrderPlaced)
def on_order_placed(self, event: OrderPlaced):
# reserve() runs once per order, so repeated lines are combined first
quantities = Counter()
for item in event.items:
quantities[item["product_id"]] += item["quantity"]
repo = current_domain.repository_for(Inventory)
for product_id, quantity in quantities.items():
inventory = repo.get(product_id)
inventory.reserve(order_id=event.order_id, quantity=quantity)
repo.add(inventory)
The reservation logic (checking availability, preventing double-reservation,
updating quantities) is in Inventory.reserve(). OrderPlaced belongs to
Order, so stream_category points the handler at the order stream. Without
it the handler would listen to the inventory stream and never see the event.
Application Services Follow the Same Pattern
Application services orchestrate use cases and should also be thin:
from protean import Domain, current_domain, use_case
from protean.core.application_service import BaseApplicationService
from protean.exceptions import ValidationError
from protean.fields import (
Float,
HasMany,
Identifier,
Integer,
String,
ValueObject,
)
domain = Domain(name="ThinHandlersOrdering")
@domain.value_object
class Money:
amount: Float(required=True)
currency: String(max_length=3, required=True)
@domain.entity(part_of="Order")
class OrderItem:
product_id: Identifier(required=True)
quantity: Integer(min_value=1, required=True)
unit_price: Float(required=True)
@domain.event(part_of="Order")
class OrderPlaced:
order_id: Identifier(required=True)
customer_id: Identifier(required=True)
@domain.aggregate
class Order:
order_id: Identifier(identifier=True)
customer_id: Identifier(required=True)
items = HasMany(OrderItem)
total = ValueObject(Money)
status: String(default="draft")
def add_item(self, product_id: str, quantity: int, unit_price: float) -> None:
self.add_items(
OrderItem(product_id=product_id, quantity=quantity, unit_price=unit_price)
)
def place(self) -> None:
if not self.items:
raise ValidationError({"items": ["Cannot place an order with no items"]})
self.status = "placed"
self.raise_(OrderPlaced(order_id=self.order_id, customer_id=self.customer_id))
@domain.application_service(part_of=Order)
class OrderApplicationService(BaseApplicationService):
@use_case
def place_order(self, order_id, customer_id, items, total, currency):
repo = current_domain.repository_for(Order)
order = Order(
order_id=order_id,
customer_id=customer_id,
total=Money(amount=total, currency=currency),
)
for item in items:
order.add_item(**item)
order.place()
repo.add(order)
Load or create, call domain methods, save. The application service coordinates the sequence; the domain model contains the logic.
When the Handler Has Extra Lines
Some legitimate additions to the three-line pattern:
Loading Additional Data for Domain Services
@domain.command_handler(part_of=Account)
class AccountCommandHandler(BaseCommandHandler):
@handle(TransferMoney)
def transfer(self, command: TransferMoney):
account_repo = current_domain.repository_for(Account)
policy_repo = current_domain.repository_for(CreditPolicy)
source = account_repo.get(command.from_account_id)
# A second aggregate, loaded for the domain service
policy = policy_repo.get(source.credit_policy_id)
TransferService(source, policy).validate_and_debit(
amount=command.amount,
transfer_id=command.transfer_id,
target_account_id=command.to_account_id,
)
account_repo.add(source)
Loading a second aggregate for a domain service is fine. The handler is still just orchestrating.
Creating New Aggregates
# fragment
@handle(PlaceOrder)
def place_order(self, command: PlaceOrder):
order = Order(
order_id=command.order_id,
customer_id=command.customer_id,
)
for item in command.items:
order.add_item(**item) # Aggregate method
order.place() # Aggregate method
repo.add(order)
Building the order with multiple add_item calls is still orchestration.
Layer 4 Guards
# fragment
@handle(CancelOrder)
def cancel_order(self, command: CancelOrder):
order = repo.get(command.order_id)
# Contextual guard (Layer 4)
if command.requested_by_role not in ("admin", "customer"):
raise AuthorizationError("Unauthorized")
order.cancel(command.reason)
repo.add(order)
Authorization checks are the handler's responsibility (see Validation Layering).
Summary
| Aspect | Thick Handlers | Thin Handlers + Rich Domain |
|---|---|---|
| Business logic | Scattered in handlers | Centralized in aggregates |
| Handler size | 20-50+ lines | 3-5 lines |
| Duplication | High (multiple handlers, same rules) | None (single aggregate method) |
| Testability | Requires infrastructure | Direct method calls |
| Aggregate role | Data container | Behavior + data |
| Domain service role | Unused | Cross-aggregate coordination |
| Code organization | Logic by handler | Logic by domain concept |
| Readability | Must read handler to understand rules | Method names express intent |
Handlers orchestrate. Aggregates think. Load, call, save. If your handler knows business rules, the rules are in the wrong place.
Related reading
Concepts:
- Command Handlers: Command handler responsibilities.
- Application Services: Orchestrating use cases.
- Domain Services: Cross-aggregate business logic.
Guides:
- Command Handlers: Defining and implementing command handlers.
- Application Services: The @use_case decorator and service workflow.
- Aggregate Mutation: Pushing state changes into aggregates.