Domain-Driven Design Basics
Table of Contents
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:
- Banking
- E-commerce
- Task management
- Online learning
- Hospital management
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:
- Domain experts
- Product owners
- Developers
- Testers
- Documentation
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:
- Business calls it a “basket”
- Frontend calls it a “cart”
- Backend database table is
shopping_bag - API response calls it
orderDraft
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:
- Business documents say “Cart”
- User stories say “As a user I want to add a product to my Cart”
- API endpoints use
/carts - Database table is
carts - Code classes are
Cart,CartItem
Ubiquitous Language in code
You show the language directly in your code structure.
Example, task management domain:
- Domain expert says: “A task can be completed, reopened, or archived.”
- You create a
Taskentity with methodscomplete(),reopen(),archive().
Python example:
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.ARCHIVEDNotice:
- Class is
Task, notRow,Record, orTblTask. - Status is
OPEN,COMPLETED,ARCHIVED, matching how people talk. - Methods are verbs from the domain:
complete,reopen,archive.
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:
- Maintain a simple glossary of important terms and their meaning.
- Update the glossary when business rules change.
- Rename code concepts when the language evolves.
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:
- Ordering Context
- An “Order” is what the customer creates when they checkout.
- Focus on products, quantities, prices, shipping address.
- Warehouse Context
- An “Order” is an instruction to pick items from shelves and package them.
- Focus on items location, picking routes, packing steps.
- Accounting Context
- An “Order” might become an “Invoice” or “Receivable.”
- Focus on payments, taxes, financial records.
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:
- A module in a monolithic application, such as
ordering,billing,inventory. - A microservice, such as
orders-service,payments-service.
You can reflect this with directory structure:
src/
ordering/
domain/
application/
infrastructure/
billing/
domain/
application/
infrastructure/
The ordering context might have:
Order,OrderItem,Cart- Rules about discounts, shipping, etc.
The billing context might have:
Invoice,Payment,Refund- Rules about payment gateways, fees, etc.
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:
- Ordering context publishes an event:
OrderPlaced. - Billing context subscribes and creates an
Invoice. - Warehouse context subscribes and creates a
PickingList.
These interactions are often done with:
- Events over a message queue (for microservices).
- Application services calling each other (in a monolith).
- REST APIs between services.
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:
- Entities
- Value Objects
This distinction helps you model your data and behavior more clearly.
Entities
An Entity:
- Has a unique identity that stays the same over time.
- Can change attributes, but is still considered the same thing.
- Is usually stored in a database table with a primary key.
Examples:
UserOrderAccountTask
A User is still the same user even if they change their email or name.
In code:
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_emailThe identity attribute is critical. It lets you track the same entity across time and across systems.
Value Objects
A Value Object:
- Has no identity by itself.
- Is defined only by its values.
- Is usually immutable.
- Is often small and focused on a concept.
Examples:
Money(amount + currency)Address(street, city, country, zip)EmailAddressDateRange(start, end)
Two Value Objects are equal if all their fields are equal.
Example in code:
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:
Moneyhas noid.- It is immutable, thanks to
frozen=True. - Operations return new instances.
Why Value Objects are useful
Value Objects:
- Group related fields together, so you do not pass many separate parameters.
- Encapsulate rules and behavior. For example,
Moneyprevents mixing currencies. - Make your code safer. For example, you cannot pass a string instead of an
EmailAddresstype.
Example: Address as a Value Object.
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:
@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
| Feature | Entity | Value Object |
|---|---|---|
| Has identity | Yes | No |
| Equals by | Identity (id) | All field values |
| Usually mutable | Often yes | Often no (immutable) |
| Stored in database | Usually separate table | Often embedded / inline |
| Examples | User, Order, Account | Money, 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:
- A group of closely related Entities and Value Objects.
- Treated as a single unit for changes and consistency.
- Has one main Entity called the Aggregate Root.
You always access and modify the Aggregate, not inner parts, through the Aggregate Root.
Example: Order aggregate
You might define:
- Aggregate Root:
Order - Child entities:
OrderItem - Value objects:
Money,Address
In code:
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 totalHere:
Orderis the Aggregate Root.OrderItemis not loaded or saved by itself, but only as part ofOrder.- If there are consistency rules, such as “order must have at least one item,” they live in
Order.
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:
- Consistency: All rules for a cluster of objects live in one place.
- Transaction boundaries: You usually save changes to an Aggregate in one database transaction.
- Performance decisions: You decide how big each Aggregate is, which affects how much data is loaded and updated together.
Example rules you might enforce inside an Aggregate:
- An
Accountcannot go below its overdraft limit. - An
Ordercannot be paid if it is already canceled. - A
Cartcannot contain products that are out of stock.
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:
- Does not belong naturally to a single Entity.
- Uses multiple Aggregates or interacts with external systems.
In that case DDD introduces Domain Services and Application Services.
Domain Services
A Domain Service:
- Contains domain logic that does not fit a single Entity or Value Object.
- Uses the Ubiquitous Language.
- Is part of the domain model.
- Is free of technical details such as HTTP, database calls, message queues.
Example: A discount calculation that depends on both Customer and Order:
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:
- This is pure domain logic.
- It returns a
MoneyValue Object. - It can be tested without database or HTTP.
Application Services
An Application Service:
- Orchestrates use cases.
- Coordinates multiple domain operations.
- Talks to repositories, message queues, HTTP layer, external APIs.
- Calls Entities, Value Objects, and Domain Services to do the real domain work.
- Does not usually contain complex domain rules itself.
It lives one level above the domain.
Example in a typical backend use case, “Place Order”:
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.idWhere does this sit in a backend?
- Your FastAPI endpoint calls
PlaceOrderService.place_order. - The service uses repositories instead of direct database queries.
- Domain logic such as validating quantities or calculating total lives in
OrderandDiscountPolicy.
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:
- Define Bounded Contexts as separate modules inside one repository.
- Use Entities, Value Objects, Aggregates, Domain Services, Application Services.
- Keep the domain model free from HTTP, database, and frameworks.
Example folder layout:
src/
task_management/
domain/
entities/
value_objects/
services/
application/
use_cases/
infrastructure/
repositories/
fastapi/- Domain layer: pure business objects.
- Application layer: use cases.
- Infrastructure: database, HTTP controllers, ORM, message brokers.
In microservices
Often:
- Each Bounded Context becomes its own service.
- Each service has its own database and domain model.
- Services communicate via APIs or messages.
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
- The domain is rich and complex.
- Business rules change often.
- You work closely with domain experts.
- The same data means different things to different parts of the system.
- You need long term maintainability.
Examples:
- Financial systems.
- Large e-commerce platforms.
- Logistics and supply chain.
- Healthcare records.
- Complex subscription and billing systems.
When full DDD might be too much
- A very small project, such as a personal todo app.
- A thin CRUD application with almost no business logic.
- A prototype that will likely be thrown away.
In these cases you can still borrow simple DDD ideas:
- Use clear names that match the business.
- Group related fields into Value Objects.
- Separate core business logic from framework-specific code.
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:
- Ubiquitous Language
Use one shared language between code and business. Reflect it in class names, method names, and APIs. - Bounded Contexts
Divide a large system into smaller parts where each has its own model and language. - Entities and Value Objects
Entities have identity and change over time. Value Objects are defined by their values and are often immutable. - Aggregates and Aggregate Roots
Group related Entities and Value Objects into a unit that you modify through one root to maintain consistency. - Domain Services and Application Services
Domain Services hold domain logic that does not fit an Entity. Application Services orchestrate use cases and talk to repositories and frameworks.
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
KAHIBARO