Repository Pattern — Collection-Like Persistence, Query Boundaries, and Why Generic Repositories Fail
Understand the Repository pattern as a collection-like boundary between the domain and persistence; how to define a repository interface plus a concrete implementation in PHP; where to draw the line between what belongs on a repository versus a dedicated query service; and why a single generic CRUD-everything repository is usually an anti-pattern once an application's read needs grow past trivial lookups.
Once aggregates exist (as covered in the DDD lesson), something has to load them from storage and save them back without the rest of the domain code needing to know whether that storage is MySQL, Postgres, or an in-memory array in a test. The Repository pattern is that boundary: a collection-like interface -- add, remove, find-by-ID -- that hides the SQL, the ORM, and the table structure behind a small, intention-revealing API. Done well, it lets domain and application code talk entirely in terms of aggregates. Done badly -- most commonly by reaching for one generic repository shared across every entity in the system -- it becomes a leaky abstraction that neither the domain layer nor the database layer is well served by. This lesson covers both halves: how to build a good repository, and specifically why the generic version breaks down.
The Repository Pattern: A Collection Illusion Over Persistence
A repository presents itself as if the aggregates it manages already live in memory as a simple collection: $repository->save($order), $repository->byId($orderId). The illusion is deliberate -- domain and application code should be able to write $order = $orders->byId($id); $order->ship(); $orders->save($order); without knowing or caring that save() internally runs an UPDATE across three normalized tables inside a transaction. This is what actually decouples the domain layer from infrastructure: the domain layer depends only on a repository INTERFACE it defines the shape of, and the concrete implementation (which knows about PDO, SQL, or an ORM) lives in the infrastructure layer and is swapped in at runtime.
The interface belongs conceptually to the domain (it is a domain-facing contract expressed in domain terms -- OrderId, Order -- not array $row or PDOStatement), while the implementation belongs to infrastructure. This inversion -- the domain defines the interface, infrastructure implements it -- is what makes it possible to swap a real PdoOrderRepository for an InMemoryOrderRepository in tests without touching a single line of domain or application code, and is the same Dependency Inversion idea that underlies most testable PHP architectures.
A repository always deals in whole aggregates: save(Order $order) takes a complete, valid aggregate and persists whatever changed; it never exposes updateField('status', 'shipped') or accepts partial data, because a partial update is exactly the kind of operation that could bypass the aggregate's own invariant checks if it were allowed to happen outside the aggregate's methods.
• A repository presents a collection-like illusion: add/save, remove, byId -- hiding SQL/ORM details entirely. • The domain layer defines the repository INTERFACE in domain terms; infrastructure provides the concrete implementation. • This inversion (domain defines, infra implements) is what allows swapping a real implementation for an in-memory test double. • A repository always works with whole aggregates -- never partial-field updates that could bypass the aggregate's own invariant checks.
A Concrete Repository: Interface and PDO Implementation
In practice this means one interface per aggregate type, named for what it manages (OrderRepository, not Repository), with methods named for domain operations rather than generic CRUD verbs where that reads more clearly -- byId() rather than find(), nextIdentity() for generating a new ID before an aggregate exists in storage, and a small, closed set of finder methods that reflect genuine use cases the application actually has (byCustomerAndStatus), not an open-ended generic query builder exposed to callers.
The concrete implementation is where SQL, prepared statements, and the translation between database rows and aggregate objects (hydration) actually live -- and critically, that translation logic stays entirely inside the repository implementation, never leaking into application or domain code. If the storage layer later changes from three normalized tables to two, or from MySQL to Postgres, only the implementation class changes; every caller of OrderRepository continues to work unmodified because it only ever depended on the interface.
• One interface per aggregate type, named specifically (OrderRepository), not a generic Repository shared by everything. • Method names reflect domain operations (byId, nextIdentity, byCustomerAndStatus) rather than a raw, open-ended query API. • Row-to-aggregate hydration and all SQL specifics live entirely inside the concrete implementation, never leaking outward. • Callers depend only on the interface, so a storage change (different schema, different database) never requires touching calling code.
Query Boundaries: What Belongs on a Repository versus a Query Service
A repository's job is loading aggregates FOR MUTATION -- fetch a whole Order, change it via its own methods, save it back. This naturally means a repository should expose only the finder methods actual write use cases need: 'load the order about to be shipped', 'load the customer's draft order'. It should NOT grow into a general-purpose query API for every read the application ever needs, because most reads don't need a full aggregate at all.
A read like 'show me a paginated table of the last 500 orders with customer name, total, and status, sorted by date, filterable by status' does not need Order aggregates hydrated with all their invariant-enforcing machinery -- it needs a flat, denormalized projection, often built with a single efficient SQL JOIN query that would be awkward or slow to build by loading and re-flattening full aggregates. This is exactly the kind of read that belongs on a separate query service (sometimes called a read model, finder, or view service) that queries the database directly and returns lightweight read DTOs, bypassing the repository and the aggregate reconstruction entirely.
The practical rule: if a read exists to support a WRITE (load this aggregate so I can change and save it), it belongs on the repository. If a read exists purely to DISPLAY data (a list, a report, a dashboard, a dropdown of options), it belongs on a query service that talks to the database (or a dedicated read replica, or even a separate read-optimized table) independently of the repository and the aggregate model entirely. This split is also the core idea behind CQRS (Command Query Responsibility Segregation) at a lighter, more practical scale than a full event-sourced implementation requires.
• Repository reads exist to support a WRITE: load an aggregate so it can be mutated and saved back. • Display-only reads (lists, reports, dashboards) belong on a separate query service, bypassing aggregate reconstruction. • A query service can use efficient flat SQL/JOINs and return lightweight read DTOs -- no need to hydrate a full aggregate just to display it. • This split is a lightweight, practical version of the idea behind CQRS.
Why a Single Generic Repository Is Usually an Anti-Pattern
A 'generic repository' -- one Repository class (or interface) with find($id), findAll(), save($entity), delete($id) reused across every entity type in the system, sometimes via a single class parameterized by table name or a base class every specific repository extends without adding anything -- looks like it reduces duplication. In practice it usually just relocates the real problem: every entity's actual needs (which finder methods make sense, which fields matter for uniqueness, what 'delete' even means for that aggregate -- hard delete? soft delete? forbidden entirely?) get forced through one interface that can only express the LOWEST common denominator of all of them.
The consequence shows up quickly: a generic findAll() on an Orders table with ten million rows is either a footgun (loads everything into memory) or gets special-cased with pagination parameters bolted onto a supposedly generic method, and a generic save() cannot express 'Order requires a valid customer reference' or 'Product cannot be deleted while referenced by an open order' without either leaking those specifics back into the generic base class (defeating its own genericness) or silently NOT enforcing them at all.
The deeper issue is architectural: a truly generic repository is a thin wrapper around an ORM's own persistence API (which already IS generic) -- adding a same-shaped interface on top of an ORM that already provides find/save/delete adds an indirection layer with no actual abstraction benefit, because it still exposes exactly the ORM's generic capabilities, just renamed. The Repository pattern's real value was never 'hide that a database exists' in the abstract -- it was 'express THIS aggregate's specific, meaningful operations in domain terms, hiding the specific SQL behind them'. A generic repository throws away that specificity to chase code reduction that a well-factored base class (shared connection handling, shared hydration helpers) can achieve without collapsing every aggregate's distinct real interface into one.
This does not mean small, genuinely trivial lookup tables (a Country reference table with no business rules at all) can't reasonably share a thin generic finder -- the anti-pattern specifically bites once an entity has real invariants, real domain-specific queries, or real deletion semantics that a one-size-fits-all interface cannot express without either bloating itself into meaninglessness or quietly not enforcing them.
• A generic repository forces every entity's real, different needs (which finders matter, what delete means) through one lowest-common-denominator interface. • Generic findAll() on a large table is a memory footgun; retrofitting pagination/filtering onto a 'generic' method un-generics it anyway. • A generic save() cannot express aggregate-specific rules without leaking them into the shared base -- defeating the point of genericness. • A fully generic repository mostly just re-wraps the ORM's own already-generic API with no real abstraction gained. • Reasonable exception: trivial reference/lookup entities with no invariants and no domain-specific queries can share a thin generic finder without harm.
Code Examples
Application and domain code depend only on the OrderRepository interface, expressed in domain terms (Order, OrderId, customerId) with no PDO or SQL visible. PdoOrderRepository is the only class that knows how an Order is actually stored across two tables -- if that schema changes, or the app moves to Postgres, only this one class changes.
interface OrderRepository { public function byId(string $orderId): ?Order; ... } // domain-owned contract
Common mistake: Letting a controller or application service run raw SQL directly, bypassing the repository, means the aggregate-hydration logic gets duplicated (and inevitably drifts) everywhere a query is needed.
The generic interface forces both Order and Product through find/findAll/save/delete even though 'delete' means 'forbidden without a rule check' for one and something else entirely for the other -- the shared interface has no vocabulary to express either aggregate's actual rules, so those rules either leak into the generic base class (destroying its genericness) or silently go unenforced. Specific repositories, each shaped around one aggregate's real operations, avoid this entirely.
public function discontinue(string $productId): void; // a named domain operation, not a generic delete()
Common mistake: Reaching for one shared Repository base class across every entity 'to reduce duplication' usually just hides each entity's real, different rules behind an interface too generic to express any of them -- the duplication it removes was often the useful kind (each repository accurately reflecting its own aggregate).
<?php
declare(strict_types=1);
// Lives in the domain layer -- speaks only in domain terms.
interface OrderRepository
{
public function byId(string $orderId): ?Order;
public function save(Order $order): void;
public function nextIdentity(): string;
// A finder that exists because a real WRITE use case needs it --
// not a generic, open-ended query method.
public function draftOrderForCustomer(string $customerId): ?Order;
}
// Lives in the infrastructure layer -- knows about PDO, SQL, table shape.
final class PdoOrderRepository implements OrderRepository
{
public function __construct(private readonly PDO $pdo) {}
public function byId(string $orderId): ?Order
{
$stmt = $this->pdo->prepare('SELECT * FROM orders WHERE id = :id');
$stmt->execute(['id' => $orderId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row === false) {
return null;
}
return $this->hydrate($row, $this->fetchLines($orderId));
}
public function save(Order $order): void
{
$this->pdo->beginTransaction();
try {
$this->pdo->prepare(
'INSERT INTO orders (id, custom
…<?php
declare(strict_types=1);
// THE ANTI-PATTERN: one generic repository reused for every entity.
interface GenericRepository
{
public function find(string $id): ?object;
public function findAll(): array; // on a 10M-row table, this is a memory bomb
public function save(object $entity): void; // cannot express any entity-specific rule
public function delete(string $id): void; // "delete" means something different per entity
}
// Forcing Order through it: no room for a domain-specific, real use case...
final class GenericOrderRepository implements GenericRepository
{
public function find(string $id): ?object { /* ... */ return null; }
public function findAll(): array { /* loads EVERYTHING -- no pagination hook exists */ return []; }
public function save(object $entity): void { /* cannot validate this is even an Order */ }
public function delete(string $id): void {
// Order should almost never be hard-deleted (audit/compliance) --
// but the generic interface has no way to express "deletion is forbidden here".
}
}
// Forcing Product through the SAME interface: "delete" means something totally different --
// a Product r
…Repository interface (domain) vs. implementation (infrastructure), plus the query-service split
Writes go through the repository and a full aggregate; display-only reads go around it through a lightweight query service.
Domain layer Infrastructure layer
+------------------------+ +--------------------------------+
| interface |<-----------| final class |
| OrderRepository | implements | PdoOrderRepository |
| + byId(OrderId): ?Order | implements OrderRepository |
| + save(Order): void | | - SQL, PDO, row hydration |
| + nextIdentity(): OrderId | - all storage detail lives |
+------------------------+ | here, nowhere else |
^ +--------------------------------+
| used by (for WRITES)
+------------------------+
| Application service |
| load -> mutate -> save |
+------------------------+
Separate path, for DISPLAY-ONLY reads (bypasses the repository entirely):
+------------------------+ +--------------------------------+
| OrderListQueryService | --------> | flat SQL JOIN across tables |
| ->recentOrders(page) | | returns OrderListRow[] (DTOs) |
+------------------------+ | no aggregate hydration at all |
…Important Rules
A repository interface belongs to the domain layer; its concrete implementation belongs to infrastructure and is injected in.
A repository deals only in whole aggregates -- never partial-field updates.
Name repository methods for domain operations (byId, nextIdentity, draftOrderForCustomer), not generic CRUD only.
Reads that exist to support a write (load, mutate, save) belong on the repository; display-only reads belong on a separate query service.
A query service can bypass aggregate hydration entirely and return lightweight, flat read DTOs from efficient SQL.
Avoid a single generic repository shared across every entity once any entity has real invariants, specific queries, or non-trivial deletion semantics.
Where this matters at scale
An inventory-management system started with one GenericRepository shared across Product, Warehouse, and StockMovement, chasing DRY. Within two quarters, a bug shipped where a Product was hard-deleted via the generic delete() while still referenced by open StockMovement records, corrupting historical reports, because the generic interface had no way to express 'deletion requires this check' -- only ProductRepository could have. The fix was splitting into per-aggregate repositories, each with named operations (discontinue() instead of delete()), and adding a separate StockMovementQueryService for the reporting dashboard's flat, paginated reads that never needed a fully hydrated aggregate at all.
interface Repository { function find($id); function findAll(); function save($e); function delete($id); } // reused for Order, Product, User, Invoice... every entity forced through the same four methods interface OrderRepository { function byId(string $id): ?Order; function save(Order $o): void; function nextIdentity(): string; function draftOrderForCustomer(string $cid): ?Order; } // one interface per aggregate, shaped around what THAT aggregate actually needs
A generic repository interface can only express the lowest common denominator of every entity forced through it -- real, entity-specific rules (Product cannot be deleted while referenced by an open Order; Order should never be hard-deleted at all) either leak into the shared base class or, more often, simply go unenforced because the generic interface has no vocabulary for them.
In the Repository pattern, where should the repository INTERFACE conceptually live, and why? A. In the infrastructure layer, because it deals with persistence. B. In the domain layer, expressed in domain terms -- so the domain depends only on the interface, and infrastructure provides the implementation, allowing it to be swapped (e.g. for tests) without touching domain code. C. It doesn't matter where it lives as long as it compiles. D. In the presentation/controller layer, since that's where it gets called from.
B. In the domain layer, expressed in domain terms -- so the domain depends only on the interface, and infrastructure provides the implementation, allowing it to be swapped (e.g. for tests) without touching domain code. — This is Dependency Inversion applied to persistence: the domain defines the contract it needs in its own vocabulary, and a concrete infrastructure class implements it. That inversion is what makes swapping a real PdoOrderRepository for an InMemoryOrderRepository in tests possible without changing any domain or application code.
A dashboard needs a paginated, flat list of '500 most recent orders with customer name, total, and status' purely for display. Where should this read live? A. As a new generic findAll() method added to OrderRepository. B. On a separate query service that runs an efficient flat SQL query and returns lightweight read DTOs, bypassing aggregate hydration entirely. C. It must load every Order aggregate via the repository and extract the needed fields in a loop, since that's the only correct architecture. D. Inside the Order aggregate itself, as a static method.
B. On a separate query service that runs an efficient flat SQL query and returns lightweight read DTOs, bypassing aggregate hydration entirely. — Repository reads exist to support a write (load an aggregate to mutate and save it). A display-only read like this doesn't need full aggregates with their invariant-enforcing machinery -- it belongs on a separate query service that can use an efficient JOIN and return flat DTOs, which is both simpler and faster.
Why is a single generic repository (one find/findAll/save/delete interface shared by every entity) usually considered an anti-pattern beyond trivial cases? A. Because PHP interfaces cannot have more than four methods. B. It forces every entity's real, different needs (which finders matter, what deletion means, which rules to enforce) through one lowest-common-denominator interface, so entity-specific rules either leak into the shared base or silently go unenforced. C. Because generic repositories are always slower at runtime than specific ones. D. Because ORMs do not support generic interfaces.
B. It forces every entity's real, different needs (which finders matter, what deletion means, which rules to enforce) through one lowest-common-denominator interface, so entity-specific rules either leak into the shared base or silently go unenforced. — Different aggregates genuinely need different operations and different rules (e.g. Product cannot be deleted while referenced by an open Order; Order should never be hard-deleted). A one-size-fits-all generic interface has no vocabulary to express those differences, so it either bloats itself trying to accommodate every case or quietly fails to enforce rules that only a purpose-built, aggregate-specific repository could.
What should a repository's save(Order $order) method NOT accept, and why? A. It should not accept a full, valid Order aggregate -- only individual scalar fields. B. It should not accept partial-field updates (like updateField('status', 'shipped')), because that would allow bypassing the aggregate's own invariant-enforcing methods. C. It should not be allowed to run inside a database transaction. D. It should never accept an Order that has raised any domain events.
B. It should not accept partial-field updates (like updateField('status', 'shipped')), because that would allow bypassing the aggregate's own invariant-enforcing methods. — A repository always works with whole, already-valid aggregates. Accepting partial updates would let calling code mutate persisted state without going through the aggregate's own methods, which is exactly where invariants are enforced -- defeating the purpose of the aggregate boundary.
Design a ReviewRepository interface for a Review aggregate (a customer's review of a product) with exactly the operations a real application would need: byId, save, and byProductId (used to check 'has this customer already reviewed this product' before allowing a new review, and to list a product's reviews). Explain, in a short comment, why a paginated 'list latest 50 reviews site-wide, sorted by rating, for a moderation dashboard' should NOT be a method on this repository.
interface ReviewRepository { public function byId(string $reviewId): ?Review; public function save(Review $review): void; // supports the real write use case: 'has this customer already reviewed this product?' public function byProductAndCustomer(string $productId, string $customerId): ?Review; } // A 'latest 50 reviews site-wide for moderation' read does NOT belong here: it is display-only, // wants a flat paginated projection across many reviews with no mutation intent, and hydrating // full Review aggregates just to show a moderation table would be wasteful and out of scope for // a repository -- it belongs on a separate ReviewModerationQueryService instead.
Ask: does the read exist to load an aggregate for mutation, or purely to display data? The moderation dashboard read is the second kind.