Skip to main content

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.

About the Eloquent ORM

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.

ApproachCorrect?Reason
Create OrderRepository and OrderLineRepository separatelyNGRisk of breaking the aggregate's consistency
OrderRepository handles both orders and order_linesOKAn 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

PatternMethodWhen to use
Single retrievalwith('relation')->find($id)When reconstructing a whole aggregate
List retrievalwith('relation')->get()When retrieving multiple aggregates
Only the relations you needwith(['relation1', 'relation2'])When there are multiple relations
Tools for detecting and addressing the N+1 problem

The N+1 problem often sneaks in without you noticing, so use detection tools from the start of development.

Detection in the development environment

  1. Laravel Debugbar: shows the query count and execution time at the bottom of the screen
  2. 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

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
]);
PropertyDescriptionRecommendation
$fillableSpecifies the columns that can be assigned (allowlist)Recommended
$guardedSpecifies the columns that cannot be assigned (denylist)Not recommended (risk of introducing a vulnerability by forgetting to list a newly added column)
Eloquent ORM security

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.

  1. Persisting domain entities: save the aggregate to tables
  2. Restoring domain entities: reconstruct the aggregate from tables
  3. Hiding the mapping details: the domain layer does not need to know the table structure
  4. Managing transactions: guarantee consistency within the aggregate (for a single aggregate)

The benefits of using a repository

BenefitDescription
TestabilityMock the repository to test without a DB
FlexibilityChanging the DB does not affect the domain layer
Separation of concernsHide 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

Details of the Laravel implementation

In the next chapter, we will look at the relationship between the domain model and table design.