DEV Community

Cover image for Designing a Domain Model That Actually Models the Domain
Pablo Ifrán
Pablo Ifrán

Posted on • Originally published at elpic.Medium

Designing a Domain Model That Actually Models the Domain

Ports and adapters only work if you have a domain worth protecting.

If your domain objects are just bags of getters and setters no behavior, no rules, no real logic the boundaries don't buy you much. You've built a clean fence around an empty lot.

This post is about filling the lot.


The Anemic Domain Model

Here's the Order and Customer you see in most codebases even the "clean" ones:

from dataclasses import dataclass
from typing import List, Optional

@dataclass
class OrderItem:
    product_id: str
    price: float
    quantity: int

@dataclass
class Customer:
    id: str
    loyalty_points: int

@dataclass
class Order:
    customer_id: str
    items: List[OrderItem]
    loyalty_points: int
    total: float = 0.0
    status: str = "draft"
    id: Optional[str] = None
Enter fullscreen mode Exit fullscreen mode

These are data containers. They hold values. They don't do anything.

The business rules that involve these objects how totals are calculated, what makes an order valid, whether a customer qualifies for a discount live somewhere else. Usually scattered across service methods, utility functions, and the occasional if statement buried in a route handler.

This is the anemic domain model. Martin Fowler named it in 2003. It's still the default pattern in most Python codebases.

The problem isn't that these classes are wrong. It's that they're incomplete. The data is here but the behavior isn't.


What Belongs in the Domain

Before adding behavior, it helps to know what belongs there.

The rule: if a piece of logic is about the rules of your business, it belongs in the domain. If it's about how you store or transmit data, it belongs in infrastructure.

Concretely, for an order management system:

Domain logic:

  • How is an order's total calculated?
  • When is an order considered valid?
  • What makes a customer eligible for a discount?
  • Can a placed order be cancelled?
  • What's the maximum quantity per item?

Not domain logic:

  • How do you persist an order to PostgreSQL?
  • How do you serialize an order to JSON?
  • How do you send an email confirmation?
  • What HTTP status code do you return when an order isn't found?

The first list lives on your domain objects. The second list lives in adapters, serializers, and route handlers. When logic from the second list ends up on the domain objects, you have leakage. When logic from the first list ends up in service methods or route handlers, you have an anemic domain.


Evolving the Model

Let's move the business rules back where they belong.

from __future__ import annotations
from dataclasses import dataclass, field
from typing import List, Optional


@dataclass(frozen=True)
class Money:
    amount: float
    currency: str = "USD"

    def __post_init__(self) -> None:
        if self.amount < 0:
            raise ValueError(f"Money amount cannot be negative: {self.amount}")

    def __add__(self, other: Money) -> Money:
        if self.currency != other.currency:
            raise ValueError(f"Cannot add {self.currency} and {other.currency}")
        return Money(self.amount + other.amount, self.currency)

    def __mul__(self, factor: float) -> Money:
        return Money(round(self.amount * factor, 2), self.currency)


@dataclass(frozen=True)
class Discount:
    rate: float  # 0.0 to 1.0

    def __post_init__(self) -> None:
        if not (0.0 <= self.rate <= 1.0):
            raise ValueError(f"Discount rate must be between 0.0 and 1.0, got: {self.rate}")

    def apply(self, amount: Money) -> Money:
        return amount * (1.0 - self.rate)

    @classmethod
    def loyalty(cls, loyalty_points: int) -> Discount:
        """Return the discount a customer earns based on loyalty points."""
        if loyalty_points > 500:
            return cls(0.10)
        return cls(0.0)
Enter fullscreen mode Exit fullscreen mode

Two value objects: Money and Discount. Both are frozen dataclasses immutable by construction. Both validate themselves. You cannot create a Money with a negative amount. You cannot create a Discount with a rate outside [0.0, 1.0]. The rules are encoded in the objects, not enforced by whatever code happens to use them.

Now the OrderItem and Order:

@dataclass
class OrderItem:
    product_id: str
    price: Money
    quantity: int

    def __post_init__(self) -> None:
        if self.quantity <= 0:
            raise ValueError(f"Quantity must be positive, got: {self.quantity}")
        if self.quantity > 100:
            raise ValueError(f"Quantity cannot exceed 100 per item, got: {self.quantity}")

    def subtotal(self) -> Money:
        return self.price * self.quantity


@dataclass
class Customer:
    id: str
    loyalty_points: int

    def discount(self) -> Discount:
        return Discount.loyalty(self.loyalty_points)


@dataclass
class Order:
    customer_id: str
    items: List[OrderItem]
    discount: Discount
    status: str = "draft"
    id: Optional[str] = None

    def __post_init__(self) -> None:
        if not self.items:
            raise ValueError("An order must contain at least one item")

    def total(self) -> Money:
        raw = Money(0.0)
        for item in self.items:
            raw = raw + item.subtotal()
        return self.discount.apply(raw)

    def place(self) -> Order:
        if self.status != "draft":
            raise ValueError(f"Cannot place an order with status '{self.status}'")
        self.status = "pending"
        return self

    def cancel(self) -> Order:
        if self.status not in ("draft", "pending"):
            raise ValueError(f"Cannot cancel an order with status '{self.status}'")
        self.status = "cancelled"
        return self
Enter fullscreen mode Exit fullscreen mode

Look at what changed:

  • total() is a method on Order, not a calculation in OrderService. The order knows how to compute its own total.
  • place() and cancel() encode the state machine. An order can't be placed if it's not a draft. That rule lives on the object not in a service method that might forget to check.
  • Customer.discount() knows how to determine what discount a customer earns. The OrderService doesn't have to know the threshold is 500.
  • OrderItem.subtotal() knows how to compute its own contribution.
  • Validation is in __post_init__. You can't construct an invalid object. You can't create an OrderItem with zero quantity.

Value Objects

Money and Discount are value objects. The term comes from domain-driven design, but the concept is simple: an object defined entirely by its value, not by an identity.

Two Money(100.0, "USD") instances are equal and interchangeable. You don't care which one you have they're the same. In contrast, two Order objects are not interchangeable even if they have the same items. They're distinct entities with their own identities and histories.

Value objects have two properties worth building in from the start:

Immutability. Use frozen=True on the dataclass. A Money object doesn't change arithmetic operations return new instances. This eliminates a whole class of bugs where shared state gets mutated in unexpected places.

Self-validation. Validate in __post_init__. The rule lives with the type. Any code that creates a Money gets the validation for free there's no way to bypass it accidentally.

The practical test: if you find yourself writing if amount < 0: raise ValueError in three different places in your codebase, you have a value that wants to be a value object.


How the Service Simplifies

With behavior on the objects, the service gets thinner:

from app.domain.ports import CustomerRepository, OrderRepository
from app.domain.models import Order, OrderItem
from typing import List


class OrderService:
    def __init__(
        self,
        customer_repo: CustomerRepository,
        order_repo: OrderRepository,
    ):
        self.customer_repo = customer_repo
        self.order_repo = order_repo

    def place_order(self, customer_id: str, items: List[OrderItem]) -> Order:
        customer = self.customer_repo.find_by_id(customer_id)
        if not customer:
            raise ValueError(f"Customer {customer_id} not found")

        order = Order(
            customer_id=customer_id,
            items=items,
            discount=customer.discount(),
        ).place()

        return self.order_repo.save(order)
Enter fullscreen mode Exit fullscreen mode

Compare this to the Post 2 version. We removed order.total = order.calculate_total() and order.status = "pending" both are now handled by the domain objects themselves. The service coordinates. It doesn't compute.

This is the distinction: services orchestrate, domain objects encapsulate. When the service is doing business calculations, something has leaked.


What's Next

You now have a domain worth protecting: real behavior, real rules, real validation all on the objects that own them.

The last piece is wiring. You have domain objects. You have ports. You have adapters. Now you need a place that assembles them: the application layer. Dependency injection. The startup wiring that connects a real SQLAlchemy repository to the service that declares it needs something that can save an order.

That's Post 4: "Wiring It All Together: Adapters, DI, and the Application Layer".

Top comments (0)