Domain-Driven Design — Entities, Value Objects, Aggregates, and the Ubiquitous Language
Understand Domain-Driven Design (DDD) as a discipline for modeling complex business logic in code: the domain and bounded contexts that scope a model, entities versus value objects, aggregates and the aggregate root that guards their invariants, repositories as the collection-like boundary to persistence, the split between domain services and application services, domain events for reacting to state changes, and the ubiquitous language that keeps code and business conversation using the same words.
Most PHP tutorials teach CRUD: a User class with public properties, a controller that loads a row, mutates it, and saves it back. That works fine until the business logic itself is complicated -- an order that can only be shipped once, a wallet that can never go negative, a subscription that has rules about upgrades, downgrades, and prorated billing that no single if statement captures cleanly. Domain-Driven Design, introduced by Eric Evans, is a set of patterns and a way of talking about code that keeps complex business rules organized around the business concepts themselves rather than around database tables or framework conventions. It will not help a simple blog. It earns its cost precisely where the business logic is the hard part: billing, inventory, scheduling, anything with rules that change depending on who you ask in the business and that a spreadsheet-style Order class with forty public properties will eventually mangle.
Domain and Bounded Context
The domain is the subject matter the software exists to serve -- the real-world problem space, independent of any code: for an e-commerce system, the domain is 'selling goods to customers', encompassing catalog, pricing, ordering, shipping, and payment as sub-areas. DDD's central claim is that the code's structure should mirror this problem space closely enough that a domain expert (someone who understands the business but has never read a line of PHP) can look at the class and method names and recognize their own vocabulary.
A single domain is almost always too large to model with one consistent set of rules, which is where a bounded context comes in: a bounded context is an explicit boundary -- usually a module, a service, or in a large system a separate deployable application -- inside which a particular model and its vocabulary are consistent and unambiguous. The word 'Product' means something different to the Catalog bounded context (description, images, category, searchable attributes) than it does to the Shipping bounded context (weight, dimensions, fragility, hazmat class) or to the Billing bounded context (price, tax category, SKU). Rather than forcing one giant Product class to serve all three, DDD says: let each bounded context define its own Product model shaped for what IT needs, and translate between them at the boundary where they integrate.
This is the antidote to the single shared 'God entity' that accumulates fields for every team's use case until nobody can safely change it. A bounded context is a modeling decision, not primarily a technical one -- but in PHP it is usually realized as a namespace (App\Catalog\..., App\Shipping\..., App\Billing\...) or, at larger scale, a separate service with its own database, its own Product class, and an explicit translation layer (an API call, an event, a shared kernel) wherever two contexts need to talk.
• Domain -- the real-world subject matter the software models (e.g. 'selling goods to customers'). • Bounded context -- an explicit boundary inside which one model and vocabulary stay consistent; the same word can mean different things in different contexts. • A bounded context is usually a namespace or a separate service, each with its own version of a shared-sounding concept like 'Product'. • The alternative -- one shared model trying to serve every context -- produces a God entity nobody can change safely.
Entities and Value Objects
An entity is defined by identity, not by its attributes: two Order objects with identical line items, totals, and dates are still two different orders if they have different order IDs, and a single Order remains the SAME order even after its status changes from 'pending' to 'shipped' -- its identity persists across mutation. Entities are typically compared by ID ($order->id() === $other->id()), not by comparing every field.
A value object, in contrast, is defined entirely by its attributes and has no identity of its own: two Money objects representing 500 cents in USD are simply equal, interchangeable, and neither one is 'the' original. Value objects are immutable by convention -- instead of mutating a Money in place, an operation like add() returns a NEW Money instance -- which eliminates an entire category of bugs where one part of the code mutates a value another part is still holding a reference to. Common value objects: Money, EmailAddress, DateRange, Address, OrderLine (quantity + product + unit price, with no identity of its own outside the order that contains it).
The practical test that separates the two: if you would ever ask 'is this the SAME one, or just an equal one?', it is an entity. If equality is all that ever matters and swapping one equal instance for another changes nothing, it is a value object. Getting this distinction right is the single highest-leverage DDD decision in a codebase, because value objects can be freely copied, compared, and shared, while entities require careful tracking of identity and mutation.
• Entity -- has identity; compared by ID; the same entity can change over time and remain 'the same one'. • Value object -- has no identity; compared by value; immutable, so operations return new instances instead of mutating in place. • Examples of value objects: Money, EmailAddress, DateRange, Address, OrderLine. • Test: 'same one vs. merely equal one' matters -> entity. Only equality ever matters -> value object.
Aggregates, the Aggregate Root, and Invariants
An aggregate is a cluster of entities and value objects that must change together as a single consistency unit -- an Order together with its OrderLine value objects is a classic aggregate, because adding a line item might need to re-check a discount rule, and shipping the order might need to guarantee every line has stock reserved, rules that only make sense evaluated against the WHOLE order, not one line in isolation. The aggregate root is the single entity inside that cluster that external code is allowed to reference and call methods on; every other object inside the aggregate (the OrderLines here) is reached only THROUGH the root, never held onto or mutated directly by outside code.
This matters because an aggregate's whole job is to protect its invariants -- business rules that must always hold true, like 'an order cannot be shipped with zero line items' or 'an order's total can never go negative'. If external code can reach in and mutate an OrderLine directly, or push a new line onto an internal array without going through the root, the aggregate can no longer guarantee its own invariants -- the rule becomes 'usually enforced, except when someone forgets', which in a large codebase means 'eventually violated'. Concretely: the root exposes intention-revealing methods (addLine(), ship(), cancel()) that internally validate the invariant before applying the change, and it does NOT expose a public setter or a mutable getter that hands out its internal collection by reference.
Aggregate boundaries should be as small as the invariant requires and no larger -- a common mistake is modeling 'Customer with all their Orders' as one aggregate, which makes loading a customer expensive and creates lock contention when two orders for the same customer are placed concurrently, when in fact Order and Customer almost never need to change atomically together and should be separate aggregates referenced by ID.
• Aggregate -- a cluster of entities/value objects that must stay consistent together, sharing one transactional boundary. • Aggregate root -- the single entity external code holds a reference to; everything inside is reached only through it. • Invariant -- a business rule the aggregate must never violate (e.g. 'total >= 0', 'cannot ship with zero lines'). • Keep aggregates small: only group what truly must change atomically together, referencing other aggregates by ID instead.
Repository, Domain Service, Application Service
A repository presents a collection-like interface over persistence for one aggregate type -- save(Order $order), byId(OrderId $id): ?Order -- so the rest of the domain code can work with aggregates as if they lived in an in-memory collection, without knowing or caring whether they are actually rows in MySQL, documents in Mongo, or rows split across three normalized tables. A repository always speaks in terms of whole aggregates, never partial updates or raw SQL fragments leaking into domain code; the next lesson goes deep on this pattern specifically.
Not every piece of business logic naturally belongs to one entity. When a rule genuinely spans multiple aggregates -- 'transferring money debits one Account and credits another, and the two must never be observed in an inconsistent state' -- that logic belongs in a domain service: a stateless class expressing a domain concept that is a verb rather than a noun (MoneyTransferService), still part of the domain layer, still full of business rules, just not naturally owned by any single entity.
An application service (sometimes called a use case or command handler) is a different layer entirely: it orchestrates -- start a transaction, load the aggregate(s) via a repository, call domain methods, save, publish events, commit -- but it contains NO business rules of its own. PlaceOrderService::handle(PlaceOrderCommand $cmd) fetches the cart and inventory, calls Order::place(...) (which enforces the actual invariants), saves via the repository, and returns. If you find an if statement enforcing a business rule inside an application service, that logic has leaked out of the domain layer and belongs on the aggregate or in a domain service instead.
• Repository -- collection-like persistence boundary for one aggregate type; speaks in whole aggregates, hides storage details. • Domain service -- stateless, holds business logic that spans multiple aggregates or doesn't naturally belong to one entity. • Application service -- orchestrates a use case (load, call domain methods, save, publish events); contains no business rules itself. • A business if inside an application service is a signal that logic has leaked out of the domain layer.
Domain Events and the Ubiquitous Language
A domain event is an immutable record that something meaningful happened in the domain -- OrderPlaced, OrderShipped, PaymentFailed -- named in the past tense because it describes a fact that already occurred. Aggregates raise domain events internally when their state changes in a way other parts of the system might care about, WITHOUT the aggregate knowing or caring who (if anyone) is listening -- the Order aggregate records that it raised OrderShipped, and the application service, after saving, is responsible for actually dispatching that event to whatever listeners are registered (send a confirmation email, decrement inventory, notify a partner API). This keeps the aggregate decoupled from side effects it should never need to know about directly.
The ubiquitous language is the discipline of using exactly the same terms in code, in conversations with domain experts, in tests, and in documentation -- if the business calls the moment an order can no longer be modified 'order placed', the code should have a method or event literally named place() / OrderPlaced, not finalize(), submit(), or lock(). When code and business conversation drift onto different words for the same concept, translation errors creep in silently: a developer implements 'cancel' to mean what the business calls 'refund', and the bug isn't caught until real money moves incorrectly. The ubiquitous language is not a naming nicety -- it is the mechanism that keeps the model an accurate, checkable translation of the business rules, and it should evolve through direct conversation with domain experts, not be decided unilaterally by engineering.
• Domain event -- an immutable, past-tense fact ('OrderPlaced') that an aggregate raises when something meaningful changes. • The aggregate raises events without knowing who consumes them; the application service dispatches them after a successful save. • Ubiquitous language -- the same terms used in code, conversation, and docs, so the model stays an accurate translation of real business rules. • Vocabulary drift between code and the business is a silent source of bugs, not just an aesthetic complaint.
Code Examples
Money has no ID field and no setters. add() never mutates $this -- it always returns a fresh instance, so a Money instance handed to two different parts of the code can never be silently changed out from under either of them. equals() compares by value, which is the defining trait of a value object.
return new self($this->cents + $other->cents, $this->currency); // never mutate, always return new
Common mistake: Adding a public setCents() method, or making $cents non-readonly, turns Money into a mutable bag of fields -- exactly the bug class immutability exists to prevent.
Order is an entity (identified by $id, comparable across its whole lifecycle) and the aggregate root over its OrderLine value objects. Every mutation goes through a method that checks an invariant first (no lines -> cannot place; wrong status -> cannot ship), and the aggregate raises domain events internally without knowing who, if anyone, will consume them -- an application service is responsible for calling pullEvents() after a successful save and dispatching them.
if (count($this->lines) === 0) { throw new DomainException(...); } // the invariant a naive setter would bypass
Common mistake: Exposing a public array $lines property, or a lines-by-reference getter, lets outside code push/remove line items without ever going through place()/ship() -- the invariants become optional.
<?php
declare(strict_types=1);
final class Money
{
private function __construct(
private readonly int $cents,
private readonly string $currency,
) {}
public static function fromCents(int $cents, string $currency): self
{
return new self($cents, strtoupper($currency));
}
public function add(Money $other): self
{
$this->assertSameCurrency($other);
return new self($this->cents + $other->cents, $this->currency);
}
public function isNegative(): bool
{
return $this->cents < 0;
}
public function equals(Money $other): bool
{
// Value objects compare by VALUE, never by identity.
return $this->cents === $other->cents && $this->currency === $other->currency;
}
private function assertSameCurrency(Money $other): void
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Cannot combine different currencies.');
}
}
public function __toString(): string
{
return number_format($this->cents / 100, 2) . ' ' . $this->currency;
}
}
$price = Money::fromCents(1999, 'usd');
$tax = Money::fromCe
…<?php
declare(strict_types=1);
final class OrderLine
{
public function __construct(
public readonly string $productId,
public readonly int $quantity,
public readonly Money $unitPrice,
) {
if ($quantity < 1) {
throw new InvalidArgumentException('Quantity must be at least 1.');
}
}
public function lineTotal(): Money
{
$total = $this->unitPrice;
for ($i = 1; $i < $this->quantity; $i++) {
$total = $total->add($this->unitPrice);
}
return $total;
}
}
final class OrderPlaced
{
public function __construct(public readonly string $orderId) {}
}
final class OrderShipped
{
public function __construct(public readonly string $orderId) {}
}
final class Order // Aggregate Root -- an ENTITY, identified by $id
{
/** @var OrderLine[] */
private array $lines = [];
private string $status = 'draft';
/** @var object[] */
private array $recordedEvents = [];
public function __construct(private readonly string $id) {}
public function id(): string
{
return $this->id;
}
// The ONLY way to add a line -- no public $lines property,
…An Order aggregate and its boundary
External code never mutates an OrderLine or the lines array directly -- every change goes through a root method that can check the invariant first.
outside code (application service)
|
v only ever talks to the ROOT
+---------------------------------+
| Order (Aggregate Root, entity)|
| - id: OrderId |
| - lines: OrderLine[] (value) |
| - status: OrderStatus |
| |
| + addLine(product, qty) | <-- enforces invariants
| + ship() | <-- 'cannot ship 0 lines'
| + pulledEvents(): DomainEvent[]| <-- OrderPlaced, OrderShipped
+---------------------------------+
|
v reached ONLY through the root, never directly
+---------------------------------+
| OrderLine (Value Object) |
| product, quantity, unitPrice |
| immutable, no identity of own |
+---------------------------------+Important Rules
Model the ubiquitous language directly in class and method names -- if the business says 'place an order', the code should have place(), not submit() or finalize().
Entities are compared by identity; value objects are compared by value and should be immutable.
External code holds a reference only to the aggregate root; everything else inside the aggregate is reached through it.
Keep aggregate boundaries small -- group only what truly must be consistent in the same transaction.
A repository always deals in whole aggregates, never partial field updates.
Domain events are named in the past tense and raised by the aggregate without it knowing who will handle them.
Where DDD earns its keep
A payments platform used DDD specifically for its Ledger and Settlement bounded contexts -- where 'a transaction can never be partially applied' and 'a balance can never go negative except in an explicitly authorized overdraft' are rules that absolutely must hold, and a bug there means real money is wrong. Its Marketing and Content-management areas, by contrast, stayed deliberately simple CRUD with Eloquent models and no aggregates at all, because there was no complex invariant to protect there -- applying DDD's discipline uniformly everywhere would have been pure overhead. DDD is a tool for taming complex business logic, not a default architecture for every module.
class Order { public array $lines = []; public string $status = 'draft'; } // controller code elsewhere: $order->lines[] = new OrderLine(...); $order->status = 'shipped'; // bypasses every invariant check class Order { private array $lines = []; private string $status = 'draft'; public function addLine(...): void { /* validate */ } public function ship(): void { /* validate status, THEN change it */ } }
Public mutable properties on an aggregate root turn it into an 'anemic' data bag -- any invariant enforced inside a method can be silently skipped by code that mutates the fields directly instead. This is the single most common DDD mistake in PHP codebases, because PHP makes public properties so easy to reach for.
What distinguishes an entity from a value object in DDD? A. Entities are always stored in a database; value objects are always kept in memory only. B. An entity is defined by identity that persists across change; a value object is defined entirely by its attributes and compared by value. C. Entities must be immutable; value objects are always mutable. D. There is no real difference -- the terms are interchangeable naming conventions.
B. An entity is defined by identity that persists across change; a value object is defined entirely by its attributes and compared by value. — Identity vs. value is the defining distinction: an Order with a changed status is still 'the same' order (identity), while two Money objects with equal cents and currency are simply equal (value), with neither one being 'the original'.
Why should external code hold a reference only to an aggregate's root, never to its internal entities/value objects directly? A. PHP does not allow references to nested objects. B. It makes serialization to JSON faster. C. Only the root can enforce the aggregate's invariants; bypassing it lets code mutate internals in ways that violate business rules. D. It is purely a performance optimization with no correctness implications.
C. Only the root can enforce the aggregate's invariants; bypassing it lets code mutate internals in ways that violate business rules. — The aggregate root's methods are where invariants are checked before a mutation is applied. Reaching internals directly (e.g. pushing onto a public $lines array) skips that check entirely, so the invariant is no longer guaranteed.
What is a domain service used for, as distinct from an application service? A. A domain service handles HTTP routing; an application service handles database queries. B. A domain service holds business logic that spans multiple aggregates or doesn't naturally belong to one entity; an application service orchestrates a use case (load, call domain methods, save) without containing business rules itself. C. They are the same thing, just named differently by convention. D. A domain service is always stateful; an application service is always stateless. E. Domain services replace repositories entirely.
B. A domain service holds business logic that spans multiple aggregates or doesn't naturally belong to one entity; an application service orchestrates a use case (load, call domain methods, save) without containing business rules itself. — Domain services still belong to the domain layer and carry real business rules (e.g. transferring money between two Accounts); application services are orchestration only -- a business if found inside one is a sign logic has leaked out of the domain layer.
Why is the ubiquitous language considered important rather than just a naming nicety? A. It makes PHP code run faster because shorter method names compile quicker. B. It is required by PSR-12 coding standards. C. Using the exact same terms in code and business conversation keeps the model an accurate, checkable translation of real business rules and prevents silent misunderstandings. D. It only matters for documentation, never for the actual code.
C. Using the exact same terms in code and business conversation keeps the model an accurate, checkable translation of real business rules and prevents silent misunderstandings. — When code and business conversation drift onto different words for related-but-not-identical concepts (e.g. code's 'cancel' meaning the business's 'refund'), the mismatch is a silent source of real bugs, not a cosmetic issue.
Model a Wallet aggregate: it has a balance (Money) that can never go negative. Implement deposit(Money $amount) and withdraw(Money $amount), where withdraw() throws a DomainException if the resulting balance would be negative. Raise a WalletDebited domain event on a successful withdrawal. Do not expose a public setter for the balance.
final class Wallet { private Money $balance; private array $events = []; public function __construct(private readonly string $id, Money $opening) { $this->balance = $opening; } public function deposit(Money $amount): void { $this->balance = $this->balance->add($amount); } public function withdraw(Money $amount): void { $projected = $this->balance->add(Money::fromCents(-1 * $amount->cents(), $amount->currency())); if ($projected->isNegative()) { throw new DomainException('Insufficient funds.'); } $this->balance = $projected; $this->events[] = new WalletDebited($this->id, $amount); } }
Keep $balance private, and inside withdraw(), compute what the new balance WOULD be before deciding whether to apply it -- check the invariant before mutating state, not after.