The Repository Pattern — The Boundary Between Domain and Persistence
What is a repository
This chapter explains in detail the "repository interface" we saw in the architecture chapter.
This chapter explains a repository implementation that uses Laravel's Eloquent ORM. The basics of Eloquent (model definition, CRUD operations, relationships) are explained in Chapter 2 "The Basics of Laravel API Development".
The origin of the repository pattern
The repository pattern is a pattern defined by Martin Fowler in "Patterns of Enterprise Application Architecture."
The role of a repository is to absorb the gap between the domain model and the persistence layer (the database). Specifically, it has the following responsibilities.
- Collection-like behavior: lets you treat the database as a "collection of objects"
- Hiding queries: hides SQL statements and search logic from the domain layer
- Abstracting the persistence mechanism: lets you write domain logic without worrying about the type of DB (MySQL, PostgreSQL, MongoDB, etc.)
Why place the interface in the domain layer
What is important is that the repository interface belongs to the domain layer and the implementation belongs to the infrastructure layer.
Why should the interface live in the domain layer? There are clear reasons.
Reason 1: The Dependency Inversion Principle (DIP)
The domain layer is "the essence of the business" and should not depend on technical details (the database, the framework).
The arrow direction is "infrastructure layer → domain layer"; the direction of dependency is inverted.
Reason 2: Improved testability
A domain-layer use case depends on the repository interface. If this interface lives in the same domain layer, it is easy to inject a mock during testing.
// During testing: inject a mock repository
$mockRepository = $this->createMock(OrderRepositoryInterface::class);
$mockRepository->method('findById')->willReturn($testOrder);
// The domain-layer use case depends on the interface
$useCase = new ConfirmOrderUseCase($mockRepository);
$useCase->execute($orderId); // can test the business logic without a DB
Reason 3: Describe in business language
The repository interface expresses "the operations the business needs." It should be described in business language, not in technical details (SQL, Eloquent).
// Domain-layer interface: business language
interface OrderRepositoryInterface
{
public function findById(OrderId $id): ?Order;
public function save(Order $order): void;
public function nextIdentity(): OrderId;
}
// Infrastructure-layer implementation: technical details
class EloquentOrderRepository implements OrderRepositoryInterface
{
public function findById(OrderId $id): ?Order
{
// Technical details such as Eloquent, SQL, and query optimization
$model = OrderModel::with('orderLines')->find($id->value());
return $model ? $this->toEntity($model) : null;
}
}
One aggregate = one repository
In DDD, you create one repository per aggregate.
| Approach | Correct? | Reason |
|---|---|---|
| Create OrderRepository and OrderLineRepository separately | NG | Risk of breaking the aggregate's consistency |
| OrderRepository handles both orders and order_lines | OK | An aggregate should always be saved together |
The repository interface
Define the interface in the domain layer.
// app/Domain/Order/OrderRepositoryInterface.php
// Repository interface: placed in the domain layer
// It does not know the implementation details (Eloquent, DB). It defines only the "contract" for saving and retrieving the aggregate.
interface OrderRepositoryInterface
{
/**
* Find an order by ID
* @return Order|null returns null if not found (not an exception)
*/
public function findById(OrderId $id): ?Order;
/**
* Persist an order (handles both creation and update)
* Saves the entire aggregate (Order + OrderLine) at once
*/
public function save(Order $order): void;
/**
* Issue a new order ID
* The implementation (DB sequence, UUID generation, etc.) is hidden
*/
public function nextIdentity(): OrderId;
/**
* Issue a new order line ID
*/
public function nextLineIdentity(): OrderLineId;
}
The repository implementation
Place the implementation in the infrastructure layer.
// app/Infrastructure/Repository/EloquentOrderRepository.php
// Repository implementation class: placed in the infrastructure layer
// Implements the domain interface using the Eloquent ORM
final class EloquentOrderRepository implements OrderRepositoryInterface
{
/**
* Find an order by ID
*/
public function findById(OrderId $id): ?Order
{
// with('orderLines'): eager loading to avoid the N+1 problem (described later)
// find(): search by primary key. Returns null if not found
$model = OrderModel::with('orderLines')->find($id->value());
// Ternary operator: if the model exists, convert it to an entity; otherwise null
return $model ? $this->toEntity($model) : null;
}
/**
* Persist an order (handles both creation and update)
*/
public function save(Order $order): void
{
// DB::transaction(): for saving a single aggregate, manage the transaction inside the repository
// Saving orders and order_lines together guarantees consistency
DB::transaction(function () use ($order) {
// updateOrCreate(): update if it exists, create if it does not
// 1st argument: search condition (unique key)
// 2nd argument: data to save
$orderModel = OrderModel::updateOrCreate(
['id' => $order->id()->value()],
[
'status' => $order->status()->value,
// Extract each field from the value object and save it to a column
'shipping_prefecture' => $order->shippingAddress()->prefecture(),
'shipping_city' => $order->shippingAddress()->city(),
'shipping_street' => $order->shippingAddress()->street(),
]
);
// Save the related order lines at the same time
$this->saveOrderLines($orderModel, $order->orderLines());
});
}
/**
* Convert Eloquent model → domain entity (mapping)
* An important method that absorbs the gap between the DB structure (tables/columns) and the domain model
*/
private function toEntity(OrderModel $model): Order
{
// Convert Eloquent Collection → array
// map(): a Laravel Collection method that transforms each element
// fn($line) => ...: PHP 8.0+ arrow function (short anonymous function)
$orderLines = $model->orderLines->map(fn($line) => new OrderLine(
new OrderLineId($line->id),
new ProductId($line->product_id), // DB column name (snake_case)
$line->quantity,
new Money($line->unit_price, 'JPY'),
))->toArray();
// reconstruct(): a dedicated factory method for restoring from the DB
// create() is for new creation, reconstruct() is for restoration—used distinctly
return Order::reconstruct(
new OrderId($model->id),
// from(): convert a DB string to an enum (PHP 8.1+).
// Throws a ValueError if an unexpected value is stored in the DB.
// Failing fast is safer than restoring an "order in an invalid state"
OrderStatus::from($model->status),
new ShippingAddress(
$model->shipping_prefecture,
$model->shipping_city,
$model->shipping_street
),
$orderLines,
);
}
// The implementations of nextIdentity(), nextLineIdentity(), and saveOrderLines() are
// shown in [Chapter 19 "In Practice: Implementing the Order System"](./19-practice-order-system.md).
}
The N+1 problem and eager loading
The repository implementation uses OrderModel::with('orderLines'). This is to avoid the N+1 problem.
What is the N+1 problem
The N+1 problem is one of the most frequent performance problems when using an ORM. Because beginners are bound to run into it, let us understand it in detail.
The essence of the problem
It is the problem where, after retrieving the "parent" data, you retrieve the "child" data one record at a time, causing a large number of queries to be issued.
[The pattern that causes the N+1 problem]
1. Retrieve parent data (1 query)
Query 1: SELECT * FROM orders LIMIT 10 -- retrieve 10 orders
2. Retrieve child data for each parent (N queries)
Query 2: SELECT * FROM order_lines WHERE order_id = 1
Query 3: SELECT * FROM order_lines WHERE order_id = 2
Query 4: SELECT * FROM order_lines WHERE order_id = 3
...
Query 11: SELECT * FROM order_lines WHERE order_id = 10
Total: 1 + N = 11 queries (N is the number of parent records)
If there are 10 orders, that is 11 queries; 100 orders, 101 queries; 1,000 orders, 1,001 queries.
Why it becomes a performance problem
- Network round trips: each query causes communication between the application and the DB
- Query execution overhead: query parsing and execution-plan creation happen every time
- It does not scale: as the number of records grows, the number of queries grows linearly
Consider the case of retrieving 1,000 orders.
- With the N+1 problem: 1,001 queries → could take several seconds to tens of seconds
- With eager loading: 2 queries → completes in under 0.1 seconds
The problematic code
// NG: the N+1 problem occurs
public function findById(OrderId $id): ?Order
{
$model = OrderModel::find($id->value()); // 1 query
if (!$model) {
return null;
}
// An additional query is issued every time you access $model->orderLines
$orderLines = $model->orderLines; // +1 query
// ...
}
// Even more serious for list retrieval
public function findAll(): array
{
$models = OrderModel::all(); // 1 query
return $models->map(function ($model) {
// Retrieve order_lines for each order → N queries
return $this->toEntity($model);
})->toArray();
}
Eager loading vs lazy loading
Eloquent relationships have two approaches: lazy loading and eager loading.
Lazy loading
Relationship data is loaded only when it is actually accessed.
// Retrieve N orders (1 query)
$orders = OrderModel::all(); // SELECT * FROM orders
foreach ($orders as $order) {
// A query is issued when each order's orderLines is accessed → N queries
foreach ($order->orderLines as $line) { // +N times: SELECT * FROM order_lines WHERE order_id = ?
echo $line->quantity;
}
}
// Total 1 + N queries (the N+1 problem)
Pros: you can load only the data you need. Cons: using it inside a loop causes the N+1 problem.
Eager loading
Relationship data is loaded in advance together with the parent data.
$orders = OrderModel::with('orderLines')->get();
// Query 1: SELECT * FROM orders
// Query 2: SELECT * FROM order_lines WHERE order_id IN (1,2,3,...)
foreach ($orders as $order) {
foreach ($order->orderLines as $line) { // no additional query
echo $line->quantity;
}
}
Pros: the number of queries is constant, and the N+1 problem does not occur. Cons: you may load data you do not need.
Solving it with eager loading
// OK: optimize to 1+1=2 queries with eager loading
public function findById(OrderId $id): ?Order
{
$model = OrderModel::with('orderLines')->find($id->value());
// Query 1: SELECT * FROM orders WHERE id = ?
// Query 2: SELECT * FROM order_lines WHERE order_id IN (?)
return $model ? $this->toEntity($model) : null;
}
// List retrieval also completes in 2 queries
public function findAll(): array
{
$models = OrderModel::with('orderLines')->get();
// Query 1: SELECT * FROM orders
// Query 2: SELECT * FROM order_lines WHERE order_id IN (1,2,3,...)
return $models->map(fn($model) => $this->toEntity($model))->toArray();
}
Eager loading of nested relationships
When a relationship has multiple levels, specify it with dot notation.
// Eager-load the 3 levels: order → order line → product
$orders = OrderModel::with('orderLines.product')->get();
// Query 1: SELECT * FROM orders
// Query 2: SELECT * FROM order_lines WHERE order_id IN (...)
// Query 3: SELECT * FROM products WHERE id IN (...)
// Completes in 3 queries (no N+1 problem)
Best practices in the repository
| Pattern | Method | When to use |
|---|---|---|
| Single retrieval | with('relation')->find($id) | When reconstructing a whole aggregate |
| List retrieval | with('relation')->get() | When retrieving multiple aggregates |
| Only the relations you need | with(['relation1', 'relation2']) | When there are multiple relations |
The N+1 problem often sneaks in without you noticing, so use detection tools from the start of development.
Detection in the development environment
- Laravel Debugbar: shows the query count and execution time at the bottom of the screen
- DB::enableQueryLog(): records the executed queries to a log
DB::enableQueryLog();
// Run the code
$queries = DB::getQueryLog();
dump(count($queries), $queries); // output the query count and details
Monitoring in production
With Laravel Telescope, you can monitor queries even in production.
- Query execution time
- Automatic detection of the N+1 problem
- Identifying slow queries
Reference links
- Laravel Eloquent Relationships - the official documentation for relationships and eager loading
- Laravel Telescope - a query monitoring tool
The Eloquent Model
Define the Eloquent Model in the infrastructure layer.
// app/Infrastructure/Eloquent/OrderModel.php
final class OrderModel extends Model
{
protected $table = 'orders';
protected $fillable = ['status', 'shipping_prefecture', 'shipping_city', 'shipping_street'];
public function orderLines(): HasMany
{
return $this->hasMany(OrderLineModel::class, 'order_id');
}
}
// app/Infrastructure/Eloquent/OrderLineModel.php
final class OrderLineModel extends Model
{
protected $table = 'order_lines';
protected $fillable = ['order_id', 'product_id', 'quantity', 'unit_price'];
}
Mass-assignment protection
The $fillable property is an important security feature for preventing mass-assignment attacks.
// Dangerous: all attributes can be mass-assigned
$order = OrderModel::create($request->all()); // malicious data could overwrite id or created_at
// Safe: only the columns specified in $fillable can be assigned
$order = OrderModel::create([
'status' => 'draft',
'shipping_prefecture' => $address->prefecture(),
// id and created_at are ignored because they are not in $fillable
]);
| Property | Description | Recommendation |
|---|---|---|
$fillable | Specifies the columns that can be assigned (allowlist) | Recommended |
$guarded | Specifies the columns that cannot be assigned (denylist) | Not recommended (risk of introducing a vulnerability by forgetting to list a newly added column) |
Eloquent's where() and find() methods internally use prepared statements and are protected from SQL injection attacks. However, you need to be careful when using DB::raw() or whereRaw().
// Safe: Eloquent methods use parameter binding
$model = OrderModel::where('status', $userInput)->first();
// Caution: when using raw SQL, make parameter binding explicit
$model = OrderModel::whereRaw('status = ?', [$userInput])->first(); // OK
$model = OrderModel::whereRaw("status = '$userInput'")->first(); // NG: risk of SQL injection
ServiceProvider (registering in the DI container)
Register the binding between the interface and the implementation class.
// app/Infrastructure/Provider/RepositoryServiceProvider.php
final class RepositoryServiceProvider extends ServiceProvider
{
// $bindings: create a new instance every time it is resolved (equivalent to bind()).
// Repositories are stateless, so bind is usually sufficient.
// If you want to share an instance within a request, use $singletons.
public array $bindings = [
OrderRepositoryInterface::class => EloquentOrderRepository::class,
];
}
By registering it in the providers array in config/app.php, the DI container automatically resolves the dependencies.
The repository's responsibilities
A repository has the following responsibilities.
- Persisting domain entities: save the aggregate to tables
- Restoring domain entities: reconstruct the aggregate from tables
- Hiding the mapping details: the domain layer does not need to know the table structure
- Managing transactions: guarantee consistency within the aggregate (for a single aggregate)
The benefits of using a repository
| Benefit | Description |
|---|---|
| Testability | Mock the repository to test without a DB |
| Flexibility | Changing the DB does not affect the domain layer |
| Separation of concerns | Hide persistence details in the infrastructure layer |
// During testing, inject a mock
$mockRepository = $this->createMock(OrderRepositoryInterface::class);
$mockRepository->method('findById')->willReturn($testOrder);
$useCase = new ConfirmOrderUseCase($mockRepository);
$useCase->execute($orderId); // can test without a DB
Reference resources
If you want to learn more deeply about the repository pattern and its implementation in Laravel, refer to the resources below.
The theory of the repository pattern
- Martin Fowler - Repository Pattern - the original source of the repository pattern
Details of the Laravel implementation
- Laravel Eloquent Relationships - the official documentation for relationships and eager loading
- Laravel Telescope - a development tool that detects the N+1 problem
In the next chapter, we will look at the relationship between the domain model and table design.