Domain-Driven Design basics — entities, value objects, aggregates
Concept
Domain-Driven Design (DDD) is an approach to software development that focuses the design on the core domain and domain logic. Eric Evans introduced it in 2003. The key idea: the code should reflect the business domain so closely that domain experts and developers can communicate using the same language.
Ubiquitous Language: The vocabulary shared between domain experts and developers. Words in code match words in business conversations. An Order in the domain is an Order in the code — not a DataRecord or DTO.
Building blocks:
Entity: An object defined by its identity, not its attributes. Two users with the same name are different users (different IDs). Entities have a lifecycle, they change over time. Example: User, Order, Product.
Value Object: An object defined entirely by its attributes. Two addresses with the same street, city, and zip are the same address. Immutable — no setters. Example: Money, Address, Email, DateRange.
Aggregate: A cluster of entities and value objects that are treated as a unit for data changes. Has an Aggregate Root — the only entry point into the aggregate. Enforces invariants (business rules) for the whole cluster. Example: Order (root) + OrderItems. You access OrderItem only through Order, never directly.
Bounded Context: A boundary within which a specific domain model applies. The word "Account" might mean different things in billing vs. authentication. Each context has its own model.
Repository: An abstraction over storage that speaks the domain language. OrderRepository::findById() vs. raw SQL.
Domain Event: Something meaningful that happened in the domain. OrderPlaced, PaymentFailed, UserRegistered. Immutable facts.
Code Example
<?php
// VALUE OBJECT — immutable, equality by value
final class Money
{
public function __construct(
public readonly int $amount, // in cents (avoid float precision issues)
public readonly string $currency,
) {}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException("Cannot add {$this->currency} to {$other->currency}");
}
return new self($this->amount + $other->amount, $this->currency);
}
public function equals(Money $other): bool
{
return $this->amount === $other->amount && $this->currency === $other->currency;
}
public function format(): string
{
return number_format($this->amount / 100, 2) . ' ' . $this->currency;
}
}
final class Email
{
public readonly string $value;
public function __construct(string $email)
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email: {$email}");
}
$this->value = strtolower($email);
}
public function equals(Email $other): bool { return $this->value === $other->value; }
public function __toString(): string { return $this->value; }
}
// ENTITY — defined by identity (id), mutable
class User
{
public function __construct(
private readonly string $id,
private Email $email,
private string $name,
) {}
public function changeEmail(Email $newEmail): void { $this->email = $newEmail; }
public function getId(): string { return $this->id; }
public function getEmail(): Email { return $this->email; }
public function equals(User $other): bool { return $this->id === $other->id; }
}
// AGGREGATE ROOT — enforces invariants for the whole cluster
class Order
{
private array $items = [];
private string $status = 'pending';
public function __construct(private readonly string $id, private readonly string $customerId) {}
public function addItem(string $productId, int $quantity, Money $unitPrice): void
{
if ($this->status !== 'pending') {
throw new \DomainException('Cannot modify a confirmed order.');
}
if ($quantity <= 0) {
throw new \DomainException('Quantity must be positive.');
}
$this->items[] = new OrderItem($productId, $quantity, $unitPrice);
}
public function confirm(): void
{
if (empty($this->items)) {
throw new \DomainException('Cannot confirm an empty order.');
}
$this->status = 'confirmed';
}
public function total(): Money
{
return array_reduce(
$this->items,
fn(Money $carry, OrderItem $item) => $carry->add($item->subtotal()),
new Money(0, 'USD'),
);
}
}
// OrderItem is part of the Order aggregate — accessed only through Order
class OrderItem
{
public function __construct(
public readonly string $productId,
public readonly int $quantity,
public readonly Money $unitPrice,
) {}
public function subtotal(): Money
{
return new Money($this->unitPrice->amount * $this->quantity, $this->unitPrice->currency);
}
}
// DOMAIN EVENT
readonly class OrderPlaced
{
public function __construct(
public readonly string $orderId,
public readonly string $customerId,
public readonly int $totalCents,
public readonly string $occurredAt = '',
) {}
}