Domain-Driven Design
Domain-Driven Design
Domain-Driven Design (DDD) is a software development approach that centres the design of your system around the business domain — the problem space that your application exists to solve. Rather than letting technical concerns drive the structure of your code, DDD asks you to model the software after the language, concepts, and boundaries that the business itself uses.
The core idea is simple: if the business calls its users "Customers", your code should too. Classes, methods, and modules should be named after the concepts they represent in the real world. This alignment between the codebase and the business makes the code easier to understand, easier to discuss with non-technical stakeholders, and easier to maintain as requirements evolve.
Why DDD Matters
Software systems that drift away from the language of the business become progressively harder to work with. When a
developer reads a class called UserRecord but the product team talks about "Customers", every conversation requires
a mental translation step. Over time, these mismatches accumulate and the codebase becomes a world unto itself —
disconnected from the problem it is supposed to solve.
DDD provides a vocabulary and a set of building blocks to keep your code firmly rooted in the business domain.
Core Concepts
Entities
An entity is an object defined primarily by its identity rather than its attributes. Two entities are considered different if they have different identities, even if every other attribute is identical.
A good example is a customer account. Two accounts might belong to people with the same name and email address, but they are still distinct accounts because they have different account numbers.
class Customer
{
public function __construct(
private readonly string $id,
private string $name,
private string $email,
) {}
public function getId(): string
{
return $this->id;
}
public function changeName(string $name): void
{
$this->name = $name;
}
}
Value Objects
A value object, by contrast, has no identity of its own. It is defined entirely by its attributes, and two value objects with the same attributes are considered equal. Value objects are immutable: if you need a different value, you create a new object.
An email address is a classic value object. You do not track "which email address object" something is — you only care about the address itself.
final class Email
{
public readonly string $value;
public function __construct(string $value)
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email: {$value}");
}
$this->value = strtolower($value);
}
public function equals(Email $other): bool
{
return $this->value === $other->value;
}
}
Notice that the Email class also validates and normalises the address on construction. This is one of the key
benefits of value objects: they encapsulate domain rules about what constitutes a valid value.
Aggregates
An aggregate is a cluster of entities and value objects that are treated as a single unit for the purposes of data consistency. Every aggregate has one special entity called the aggregate root, which is the only object that external code is allowed to interact with directly.
Consider an order. An order contains line items, each with a product and a quantity. You should never add or remove a line item by reaching into the order's internals — you should always go through the order itself:
final class Order
{
/** @var LineItem[] */
private array $lineItems = [];
public function __construct(
private readonly string $id,
private readonly string $customerId,
) {}
public function addItem(string $productId, int $quantity, float $price): void
{
$this->lineItems[] = new LineItem($productId, $quantity, $price);
}
public function total(): float
{
return array_sum(array_map(
fn(LineItem $item) => $item->subtotal(),
$this->lineItems,
));
}
}
The Order is the aggregate root. All changes to line items flow through it, which keeps the aggregate in a
consistent state at all times.
Bounded Contexts
A large system rarely has a single, unified model of the world. Different parts of the business think about the same concepts differently. A "product" means one thing to the marketing team and something else to the warehouse. DDD acknowledges this by dividing the system into bounded contexts — distinct regions of the codebase, each with its own model and its own vocabulary.
For example, an e-commerce platform might have these bounded contexts:
- Sales — deals with orders, pricing, and discounts.
- Inventory — tracks stock levels and warehouse locations.
- Shipping — manages delivery addresses, carriers, and tracking numbers.
Each context has its own set of classes and its own definition of shared terms. The Sales context might have a Product
that carries a price; the Inventory context has a Product that carries a stock count. They are different classes, and
that is intentional.
Ubiquitous Language
The ubiquitous language is the shared vocabulary that both the development team and the business use to talk about a particular bounded context. The word "ubiquitous" is deliberate: this language should appear everywhere — in conversations, in requirements documents, in class names, in method names, and in database columns.
When the language is consistent, communication becomes fast and unambiguous. When it drifts, bugs and misunderstandings follow.
When to Use DDD
DDD is most valuable in complex domains where the business logic is rich and the rules are likely to change over time. If you are building a system where deep understanding of the problem space matters — financial platforms, healthcare systems, logistics engines — DDD gives you the tools to keep the code aligned with that complexity.
For simple applications with straightforward CRUD operations, DDD can introduce more structure than you need. Use your judgement: the goal is to match the complexity of your design to the complexity of the problem.
A Practical Example
Suppose you are building an online bookshop. The business refers to purchasers as "Customers", each purchase as an "Order", and each book as a "Product". Here is how DDD shapes the code:
// Bounded context: Sales
final class ProductId
{
public function __construct(public readonly string $value) {}
}
final class Money
{
public function __construct(
public readonly float $amount,
public readonly string $currency,
) {}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException('Cannot add different currencies');
}
return new self($this->amount + $other->amount, $this->currency);
}
}
final class OrderLine
{
public function __construct(
private readonly ProductId $productId,
private readonly int $quantity,
private readonly Money $unitPrice,
) {}
public function subtotal(): Money
{
return new Money($this->unitPrice->amount * $this->quantity, $this->unitPrice->currency);
}
}
final class Order
{
/** @var OrderLine[] */
private array $lines = [];
public function __construct(
private readonly string $id,
private readonly string $customerId,
) {}
public function addLine(ProductId $productId, int $quantity, Money $unitPrice): void
{
$this->lines[] = new OrderLine($productId, $quantity, $unitPrice);
}
public function total(): Money
{
$total = new Money(0.0, 'GBP');
foreach ($this->lines as $line) {
$total = $total->add($line->subtotal());
}
return $total;
}
}
Every class here maps directly to a concept the business already understands. Money is a value object that enforces
currency consistency. ProductId is a value object that wraps a raw string into something with meaning. Order is the
aggregate root that owns the lines and enforces the rule that you can only interact with them through the order itself.
Summary
Domain-Driven Design is not a technology or a framework. It is a way of thinking about how to structure your code so that it stays close to the problem it is solving. The building blocks — entities, value objects, aggregates, bounded contexts, and ubiquitous language — give you a shared vocabulary for making those decisions. Used well, DDD produces codebases that are not only technically sound but genuinely easier to understand, extend, and maintain.