KAHIBARO
Discord Login Register

Domain-Driven Design Basics

Why Domain-Driven Design Matters

Domain-Driven Design, often shortened to DDD, is a way of designing software that starts from the business problem, not from the database or the framework.

In backend development, it is easy to begin with tables, controllers, and routes. DDD flips that: you begin with the domain and the language of the people who use the system, and then you shape your code around that.

Examples of domains:

In each case, DDD asks:
What are the important concepts? How do experts talk about them? What rules must always hold?

Core idea: DDD focuses your design on the business domain and uses that to drive your code structure, object model, and conversations with stakeholders.

You do not need to apply every DDD pattern to benefit from it. Even a few basic ideas can greatly improve a backend codebase.

Ubiquitous Language

One of the most powerful and simple ideas in DDD is Ubiquitous Language.

A Ubiquitous Language is a shared vocabulary used by:

and also reflected directly in the code.

Why Ubiquitous Language matters

In many projects, people use different names for the same thing, or the same name for different things. This creates bugs and confusion.

Example problem in an e-commerce system:

Now every conversation requires translation, and people misunderstand each other.

With Ubiquitous Language you agree on one consistent term. For example, everyone agrees to call it a Cart:

Ubiquitous Language in code

You show the language directly in your code structure.

Example, task management domain:

Python example:

python
from enum import Enum, auto
from dataclasses import dataclass
from datetime import datetime
class TaskStatus(Enum):
    OPEN = auto()
    COMPLETED = auto()
    ARCHIVED = auto()
@dataclass
class Task:
    id: int
    title: str
    status: TaskStatus = TaskStatus.OPEN
    created_at: datetime = datetime.utcnow()
    completed_at: datetime | None = None
    def complete(self) -> None:
        if self.status == TaskStatus.ARCHIVED:
            raise ValueError("Cannot complete an archived task")
        self.status = TaskStatus.COMPLETED
        self.completed_at = datetime.utcnow()
    def reopen(self) -> None:
        if self.status == TaskStatus.ARCHIVED:
            raise ValueError("Cannot reopen an archived task")
        self.status = TaskStatus.OPEN
        self.completed_at = None
    def archive(self) -> None:
        self.status = TaskStatus.ARCHIVED

Notice:

Rule: Use the same words in discussions, documentation, API, database, and code. If the domain language changes, update the code to match.

Catching misunderstandings early

Imagine a conversation:

Expert: “A user can only have one active subscription.”
Developer: “So a user can have multiple active subscriptions?”
Expert: “No, I said only one.”

This seems trivial, but misunderstandings like that cause serious bugs. Ubiquitous Language and frequent conversation help you refine the terms until everyone agrees exactly what they mean.

Good practice:

Bounded Contexts

As systems grow large, not everyone uses each word in the same way. DDD introduces Bounded Contexts to handle this.

A Bounded Context is a clear boundary in which a particular Ubiquitous Language applies and a particular model is valid.

Within that boundary, a term has a specific meaning. Outside it, the same term might mean something else.

Example: The word “Order”

In an e-commerce platform, you might have:

All these areas may use the word “Order,” but they do not need to share one huge model. Each context can have its own Order concept, rules, and fields, tuned to its needs.

Definition: A Bounded Context is a boundary inside which one specific model and language are consistent. Outside that boundary, do not assume terms have the same meaning.

How bounded contexts relate to code

A Bounded Context often maps to:

You can reflect this with directory structure:

text
src/
  ordering/
    domain/
    application/
    infrastructure/
  billing/
    domain/
    application/
    infrastructure/

The ordering context might have:

The billing context might have:

It is fine that both have something like OrderId, but they are not the same entity. They have different responsibilities and different life cycles.

Communication between contexts

Bounded Contexts are not isolated silos. They interact, but do so explicitly.

Examples:

These interactions are often done with:

Key idea: Do not share domain model classes across contexts. Instead, share only simple data structures or events.

Entities and Value Objects

DDD distinguishes between two important kinds of objects in your domain:

This distinction helps you model your data and behavior more clearly.

Entities

An Entity:

Examples:

A User is still the same user even if they change their email or name.

In code:

python
from dataclasses import dataclass
@dataclass
class User:
    id: int           # identity
    email: str
    name: str
    def change_email(self, new_email: str) -> None:
        # here you could validate domain rules
        self.email = new_email

The identity attribute is critical. It lets you track the same entity across time and across systems.

Value Objects

A Value Object:

Examples:

Two Value Objects are equal if all their fields are equal.

Example in code:

python
from dataclasses import dataclass
@dataclass(frozen=True)
class Money:
    amount: int      # store cents to avoid floating point issues
    currency: str    # "USD", "EUR", etc.
    def add(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError("Currency mismatch")
        return Money(amount=self.amount + other.amount, currency=self.currency)
    def multiply(self, factor: int) -> "Money":
        return Money(amount=self.amount * factor, currency=self.currency)

Here:

Why Value Objects are useful

Value Objects:

Example: Address as a Value Object.

python
from dataclasses import dataclass
@dataclass(frozen=True)
class Address:
    street: str
    city: str
    country: str
    postal_code: str
    def is_in_eu(self) -> bool:
        eu_countries = {"DE", "FR", "ES", "PL", "IT"}  # simplified
        return self.country in eu_countries

Used in an Order entity:

python
@dataclass
class Order:
    id: int
    customer_id: int
    shipping_address: Address

If the user updates their shipping address for a future order, that does not change the Order entity created in the past, because that Order stores its own Address Value Object.

Rule:

  • Use an Entity when identity over time matters.
  • Use a Value Object when values and behavior matter, not identity.

Comparing Entities and Value Objects

FeatureEntityValue Object
Has identityYesNo
Equals byIdentity (id)All field values
Usually mutableOften yesOften no (immutable)
Stored in databaseUsually separate tableOften embedded / inline
ExamplesUser, Order, AccountMoney, Address, Email, DateRange

Aggregates and Aggregate Roots

When your domain grows, you will have many entities and value objects that are related.

For example, an Order has many OrderItems, uses an Address, involves Money, and has rules like “total price must equal sum of item prices plus shipping.”

DDD uses Aggregates to manage this complexity.

An Aggregate is:

You always access and modify the Aggregate, not inner parts, through the Aggregate Root.

Example: Order aggregate

You might define:

In code:

python
from dataclasses import dataclass, field
from typing import List
@dataclass(frozen=True)
class ProductId:
    value: int
@dataclass
class OrderItem:
    product_id: ProductId
    quantity: int
    price: Money    # price for one unit
    def line_total(self) -> Money:
        return self.price.multiply(self.quantity)
@dataclass
class Order:
    id: int
    customer_id: int
    shipping_address: Address
    items: List[OrderItem] = field(default_factory=list)
    def add_item(self, product_id: ProductId, price: Money, quantity: int = 1) -> None:
        if quantity <= 0:
            raise ValueError("Quantity must be positive")
        # simple rule: do not duplicate products, increase quantity instead
        for item in self.items:
            if item.product_id == product_id:
                item.quantity += quantity
                return
        self.items.append(OrderItem(product_id=product_id, quantity=quantity, price=price))
    def total(self) -> Money:
        total = Money(amount=0, currency="USD")
        for item in self.items:
            total = total.add(item.line_total())
        return total

Here:

Rule: An Aggregates internal objects are modified only through its Aggregate Root.
Outside code does not directly change child entities.

Why aggregates matter

Aggregates help with:

Example rules you might enforce inside an Aggregate:

You put these rules in methods on the Aggregate Root.

Domain Services and Application Services

In DDD you primarily put behavior inside Entities and Value Objects. However, sometimes an important domain action:

In that case DDD introduces Domain Services and Application Services.

Domain Services

A Domain Service:

Example: A discount calculation that depends on both Customer and Order:

python
from dataclasses import dataclass
@dataclass
class DiscountPolicy:
    vip_discount_percent: int = 10
    def calculate_discount(self, customer, order: Order) -> Money:
        """Domain logic, independent of frameworks."""
        if not order.items:
            return Money(amount=0, currency="USD")
        base_total = order.total()
        if customer.is_vip:
            discount_amount = base_total.amount * self.vip_discount_percent // 100
            return Money(amount=discount_amount, currency=base_total.currency)
        return Money(amount=0, currency=base_total.currency)

Note:

Application Services

An Application Service:

It lives one level above the domain.

Example in a typical backend use case, “Place Order”:

python
class OrderRepository:
    # imagine this uses an ORM under the hood
    def save(self, order: Order) -> None: ...
    def get(self, order_id: int) -> Order: ...
class CustomerRepository:
    def get(self, customer_id: int): ...
class PlaceOrderService:
    def __init__(
        self,
        order_repo: OrderRepository,
        customer_repo: CustomerRepository,
        discount_policy: DiscountPolicy,
    ):
        self.order_repo = order_repo
        self.customer_repo = customer_repo
        self.discount_policy = discount_policy
    def place_order(self, customer_id: int, shipping_address: Address, items_data: list[dict]) -> int:
        customer = self.customer_repo.get(customer_id)
        order = Order(id=0, customer_id=customer_id, shipping_address=shipping_address)
        for item in items_data:
            product_id = ProductId(item["product_id"])
            price = Money(amount=item["price_cents"], currency=item["currency"])
            order.add_item(product_id=product_id, price=price, quantity=item["quantity"])
        discount = self.discount_policy.calculate_discount(customer, order)
        # maybe you also subtract discount from order total, update some fields, etc.
        self.order_repo.save(order)
        return order.id

Where does this sit in a backend?

Guideline:

  • Put business rules in Entities, Value Objects, and Domain Services.
  • Use Application Services to coordinate a use case by calling domain objects and infrastructure.

DDD in a Monolith vs Microservices

DDD is not tied to microservices. It works just as well in a monolithic backend.

In a monolith

You can still:

Example folder layout:

text
src/
  task_management/
    domain/
      entities/
      value_objects/
      services/
    application/
      use_cases/
    infrastructure/
      repositories/
      fastapi/

In microservices

Often:

DDD helps decide what belongs to which service and how they should communicate, but microservices are an architectural choice that you can add later. For beginners, it is often better to start with a modular monolith that already uses DDD concepts, and move to microservices only if needed.

When to Use DDD (and When Not To)

DDD has powerful ideas, but it also adds structure and vocabulary that may be overkill for small or simple projects.

When DDD is helpful

Examples:

When full DDD might be too much

In these cases you can still borrow simple DDD ideas:

You do not need to implement every DDD pattern or layer.

Practical advice: Start small.
Use Ubiquitous Language and basic Entities / Value Objects first.
Add Aggregates, Domain Services, and Bounded Contexts as the domain grows.

Summary

In this chapter you learned the basics of Domain-Driven Design and how it connects to backend architecture:

These concepts help you design backends that are closer to the business, easier to change, and easier to reason about as they grow.

Views: 8

Comments

Please login to add a comment.

Don't have an account? Register now!