Aggregates, Repositories and Factories
Protect invariants with aggregates and persist them cleanly.
Aggregates, Repositories and Factories is a free PHP Academy lesson on CoddyKit — lesson 2 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the PHP Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Protecting invariants
Once you have entities and value objects, you need patterns to keep clusters of them consistent and to persist them cleanly. DDD answers with three tactical patterns: the Aggregate (a consistency boundary), the Repository (a collection-like persistence abstraction), and the Factory (complex construction). This lesson shows how they fit together in PHP.
What is an Aggregate
An Aggregate is a cluster of entities and value objects treated as a single unit for data changes. One entity is the Aggregate Root — the only member outside code may hold a reference to. All modifications go through the root, which enforces the aggregate's invariants. The aggregate is also the transactional boundary: it is loaded and saved atomically.
Root guards the whole
Outside callers never touch inner members directly. To add a line item you call a method on the root, which validates and maintains consistency (totals, limits). This keeps invariants in one place.
<?php
final class LineItem {
public function __construct(
public readonly string $sku,
public readonly int $qty,
public readonly int $unitCents
) {}
public function subtotal(): int { return $this->qty * $this->unitCents; }
}
final class Order { // Aggregate Root
/** @var LineItem[] */
private array $items = [];
public function __construct(public readonly string $id) {}
public function addItem(string $sku, int $qty, int $unitCents): void {
if ($qty < 1) { throw new DomainException('qty must be >= 1'); }
$this->items[] = new LineItem($sku, $qty, $unitCents);
}
public function total(): int {
return array_sum(array_map(fn(LineItem $i) => $i->subtotal(), $this->items));
}
}
$o = new Order('o1');
$o->addItem('A', 2, 500);
$o->addItem('B', 1, 300);
echo $o->total(), PHP_EOL; // 1300
Designing small aggregates
A frequent mistake is making aggregates too big (an Order that also owns the full Customer graph). Rules of thumb:
- Keep aggregates small; reference other aggregates by id, not by holding the object.
- One transaction should modify one aggregate; coordinate across aggregates with domain events.
- Invariants that must always hold define the boundary.
Reference by id
The order stores a customerId value object, not a Customer entity. This keeps the consistency boundary tight and avoids loading huge object graphs. Cross-aggregate consistency becomes eventual, handled by events rather than one giant transaction.
<?php
final class CustomerId {
public function __construct(public readonly string $value) {}
}
final class Order {
public function __construct(
public readonly string $id,
public readonly CustomerId $customerId // reference, not object
) {}
}
$order = new Order('o1', new CustomerId('cus_99'));
echo $order->customerId->value, PHP_EOL;
The Repository contract
A Repository gives the illusion of an in-memory collection of aggregate roots. The domain depends only on the interface; the implementation (Doctrine, PDO, in-memory) lives in the infrastructure layer. Repositories deal in whole aggregates, never partial rows.
<?php
interface OrderRepository {
public function ofId(string $id): ?Order;
public function save(Order $order): void;
public function nextIdentity(): string;
}
An in-memory implementation
An in-memory repository is invaluable for fast, database-free unit tests. Because the domain depends on the interface, you swap implementations freely (Dependency Inversion in action).
<?php
interface OrderRepository {
public function ofId(string $id): ?object;
public function save(object $order): void;
public function nextIdentity(): string;
}
final class Order { public function __construct(public readonly string $id) {} }
final class InMemoryOrderRepository implements OrderRepository {
private array $store = [];
public function ofId(string $id): ?object { return $this->store[$id] ?? null; }
public function save(object $order): void { $this->store[$order->id] = $order; }
public function nextIdentity(): string { return 'o_' . bin2hex(random_bytes(4)); }
}
$repo = new InMemoryOrderRepository();
$repo->save(new Order('o1'));
var_dump($repo->ofId('o1') !== null);
Repository is not a DAO
A Repository is not a generic CRUD DAO. It exposes domain-meaningful queries (findOverdueOrders()) and reconstitutes full aggregates with their invariants intact. It deliberately hides SQL and ORM details so the domain stays persistence-ignorant. Avoid leaking query builders or save($anyEntity) generic methods into the domain.
Factories for complex creation
When constructing an aggregate involves real logic — generating identity, assembling value objects, enforcing creation-time invariants — move it into a Factory (a dedicated class or a static named constructor). This keeps the entity's constructor honest and centralizes the rules of valid creation.
<?php
final class Order {
private function __construct(
public readonly string $id,
public readonly string $customerId
) {}
public static function place(string $customerId): self {
if ($customerId === '') { throw new DomainException('customer required'); }
return new self('o_' . bin2hex(random_bytes(4)), $customerId);
}
}
$order = Order::place('cus_1');
echo $order->id, PHP_EOL;
How they collaborate
The typical flow in an application service:
- A Factory (or named constructor) creates a valid aggregate.
- The aggregate root's methods enforce invariants during use.
- A Repository persists and later reconstitutes the whole aggregate.
The application service orchestrates these inside one transaction per aggregate, depending only on interfaces.
Enforcing a whole-aggregate invariant
The real value of the root is enforcing invariants that span members. Here the order rejects a line item if it would push the total over a credit limit — a rule no single LineItem could enforce alone. Because all changes go through the root, the rule can never be bypassed.
<?php
final class Order {
private array $items = [];
public function __construct(
public readonly string $id,
private int $creditLimitCents
) {}
public function addItem(int $cents): void {
if ($this->total() + $cents > $this->creditLimitCents) {
throw new DomainException('Exceeds credit limit');
}
$this->items[] = $cents;
}
public function total(): int { return array_sum($this->items); }
}
$o = new Order('o1', 1000);
$o->addItem(600);
try { $o->addItem(600); } catch (DomainException $e) { echo $e->getMessage(), PHP_EOL; }
echo $o->total(), PHP_EOL; // 600
Quick Check
Aggregate design.
Recap
You learned to protect invariants and persist cleanly. Aggregates form a consistency and transactional boundary, modified only through their root and kept small by referencing other aggregates by id. Repositories present aggregate roots as a collection behind a domain interface, hiding ORM/SQL and enabling in-memory test doubles. Factories centralize complex, invariant-enforcing creation. Together they keep your domain model consistent and persistence-ignorant.
Frequently asked questions
Is the “Aggregates, Repositories and Factories” lesson free?
Yes — the full text of “Aggregates, Repositories and Factories” is free to read here on the web, and the PHP Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the PHP Academy course, upgrade to CoddyKit PRO.
What will I learn in “Aggregates, Repositories and Factories”?
Protect invariants with aggregates and persist them cleanly. You practise PHP Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start PHP Academy?
No prior experience is required. PHP Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 2 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Aggregates, Repositories and Factories” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this PHP Academy lesson?
Yes. Every PHP Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- DDD Building Blocks: Entities and Value Objects
- Aggregates, Repositories and Factories
- Domain Events and Domain Services
- Bounded Contexts and Context Mapping