0

Domain-Driven Design basics — entities, value objects, aggregates

Advanced5 min read·eng-05-007
interviewcompare

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
<?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 = '',
    ) {}
}