Skip to content

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

  1. If-else business logic: Conditional branching based on aggregate state should be inside the aggregate.

  2. Calculations: Computing amounts, percentages, dates, or derived values belongs in the aggregate or a domain service.

  3. 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.

  4. Event raising: Events should be raised inside aggregate methods, not in the handler.

  5. Domain knowledge: If the handler "knows" business rules (refund windows, discount calculations, eligibility criteria), that knowledge belongs in the domain model.

  6. 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:

Guides: