17
Demystifying the “Repository Pattern” in PHP
Hi, I'm Valerio, CTO and founder at Inspector. In this article I would talk about the Repository Pattern and how we implemented it in our Laravel application to solve a scalability problem.
The Repository Pattern is one of the most disccussed patterns due to a lot of conflict with ORMs. Many developers think that not all ORMs are suitable for this type of design.
We discuss this topic in details below explaining why and how we implemented it in our application.
The most obvious reason of why an abstraction layer exists in almost any application, is to drastically reduce code duplication.
ORM is the most known abstraction used to easy access and modify data against SQL databases. Laravel has Eloquent, Symfony has Doctrine, etc.
Using an ORM your business logic could be somthing like:
$user = (new User())->find($id);
$user->first_name = "Valerio";
$user->save();
This is just business logic, it doesn’t care about how and where the data are stored, it depends by the internal ORM implementation and configuration. If you are a developer with professional experience, surely you are already using an ORM every day.
They already provide an abstraction layer to have a smart access to data. So, why we should use also the “Repository Pattern”?
In fact, you shouldn’t use it by default contrary to what many developers think.
Most of the technical articles I have read on the subject talk about Repository Pattern in theory, probably just to push some contents in the Google results for that specific topic. They start from simplistic assumptions that are not reflected in practical experience, so I struggled to understand if it was the right solution for me, or if I could focus on other more important tasks.
If you've had the same problem as me, you may find our experience helpful.
I would start talking about some bogus problems, in order to clarify immediately when it is “not” necessary to introduce the Repository layer.
Many developers think about the Repository Pattern like an insurance:
"If you need to change XXX in the future you can do it without having to break the whole application".
It could be:
- Change the database – but ORMs are already designed for this;
- Change the ORM – Changing the ORM is such a drastic step that in 99% of cases it happens because you have to change the whole framework you are working with, or even completely change technology. Be careful, in 99% of cases you are over engineering your code.
I think this isn't the right way to think, because with the limited time available and the tight budget, we cannot focus on scenarios that may never occur in the life span of the project. We need to solve problems we are facing now.
The need to an additional layer on top of the default data-access layer provided by the ORM could comes in several scenarios. ORMs interact with databases, but don’t necessarily incapsulate complex statements.
- You may have some complex query that you need to call from different place in your code;
- You may need to implement custom actions on an entity model that performs some data manipulation statements moving data from/to the database;
- Embrace new technologies like cache systems on top of your standard connection with the database.
A Repository acts like a collection of domain objects, with powerful querying and data manipulation capabilities. Its main function is to provide collection like (query-enabled) access to domain objects (whether they are gotten from a database is besides the point). Repositories may (and often will) contain ORMs operations themselves.
The more you find that you are using elaborate query logic or custom actions in your ORM, the more you want to start thinking about decoupling that logic into a repository while leaving your ORM to serve their main function, mapping domain objects to the database and vice versa.
Our decision to introduce the "Repository layer" in Inspector was dictated by two of the three reasons mentioned above:
- We have several custom actions on various models that we want to group in a central place instead of repeating them in different parts of the code;
- We want to add a cache layer on top of the database to increase performance.
Thanks to the Laravel IoC container, we were able to create a specific Repository layer for each of these problems.
namespace App\Repositories\Contracts;
use App\Models\Organization;
use Illuminate\Database\Eloquent\Collection;
interface OrganizationRepository
{
public function getActiveSince(\DateTimeInterface $date): Collection;
public function get($id): Organization;
public function create(array $attributes): Organization;
public function update($id, array $attributes): Organization;
public function updateCurrentBillingConsumption($id, $value = null): Organization;
public function addBonusTransactions($id, int $qty): Organization;
public function lock($id): Organization;
public function unlock($id): Organization;
public function delete($id);
}
namespace App\Repositories\Eloquent;
use App\Events\OrganizationLocked;
use App\Events\OrganizationUnlocked;
use App\Models\Organization;
use App\Repositories\Contracts\OrganizationRepository;
use Illuminate\Database\Eloquent\Collection;
class OrganizationEloquentRepository implements OrganizationRepository
{
public function getActiveSince(\DateTimeInterface $date): Collection
{
return Organization::withAndWhereHas('projects', function ($query) use ($date) {
$query->whereNotNull('last_usage_day')
->whereDate('last_usage_day', '>=', $date);
})->get();
}
public function get($id): Organization
{
return Organization::with('cluster', 'projects')->findOrFail($id);
}
public function create(array $attributes): Organization
{
return Organization::create($attributes);
}
public function update($id, array $attributes): Organization
{
$organization = $this->get($id);
if (!empty($attributes)) {
$organization->update($attributes);
}
return $organization;
}
public function updateCurrentBillingConsumption($id, $value = null): Organization
{
$organization = $this->get($id);
// Recalculate consumption on current billing period
return $organization;
}
public function addBonusTransactions($id, int $qty): Organization
{
$organization = $this->get($id);
// ...
return $organization;
}
public function lock($id): Organization
{
$organization = $this->get($id);
$organization->update(['locked_at' => now()]);
event(new OrganizationLocked($organization));
return $organization;
}
public function unlock($id): Organization
{
$organization = $this->get($id);
$organization->update(['locked_at' => null]);
event(new OrganizationUnlocked($organization));
return $organization;
}
public function delete($id)
{
return Organization::destroy($id);
}
}
namespace App\Repositories\Cache;
use App\Models\Organization;
use App\Repositories\Contracts\OrganizationRepository;
use App\Repositories\ModelCacheRepository;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
class OrganizationCacheRepository extends ModelCacheRepository implements OrganizationRepository
{
/**
* @var OrganizationRepository
*/
protected $repository;
/**
* @var string
*/
protected $model = Organization::class;
/**
* General TTL for cached items.
*/
const CACHE_TTL = 86400; // 1 day
/**
* CacheOrganizationRepository constructor.
*
* @param Repository $cache
* @param OrganizationRepository $repository
*/
public function __construct(Repository $cache, OrganizationRepository $repository)
{
parent::__construct($cache);
$this->repository = $repository;
}
/**
* @inheritDoc
*/
public function updateCache(Model $organization): Model
{
$this->cache->put($organization->getKey(), $organization);
return $organization;
}
public function getActiveSince(\DateTimeInterface $date): Collection
{
return $this->cache->tags('active')->remember($date->format('Y-m-d'), self::CACHE_TTL, function () use ($date) {
return $this->repository->getActiveSince($date);
});
}
public function get($id): Organization
{
return $this->cache->remember($id, self::CACHE_TTL, function () use ($id) {
return $this->repository->get($id);
});
}
public function create(array $attributes): Organization
{
return $this->updateCache(
$this->repository->create($attributes)
);
}
// ... other methods ...
}
In the AppServiceProvider I defined the binding of the interface with the concrete implementaiton:
$this->app->singleton(OrganizationRepository::class, function () {
return new OrganizationCacheRepository(
$this->app->make(Repository::class),
new OrganizationEloquentRepository()
);
});
In this way I layered the the OrganizationCacheRepository up to the OrganizationEloquetRespoitory.
Now the container is able to type hint the organization's repository when needed in my classes. Like in the controllers:
class OrganizationController extends Controller
{
/**
* @var OrganizationRepository
*/
protected $repository;
/**
* OrganizationController constructor.
*
* @param OrganizationRepository $repository
*/
public function __construct(OrganizationRepository $repository)
{
$this->repository = $repository;
}
/**
* Display a listing of the resource.
*
* @param Request $request
* @return OrganizationResource
*/
public function index(Request $request)
{
return new OrganizationResource(
$this->repository->get($request->user()->organization_id)
);
}
// ... other methods ...
}
This architecture gave me some breathing room, and made me think about what the next bottlenecks we could have in the future, as new applications were being connected to our Code Execution Monitoring engine almost every day.
A cache layer in front of our SQL database has increased the amount of traffic we can handle by 5x without the need to change our infrastructure, but in many articles I have read tips like: "Add a cache layer" or "Use Redis". Yes, it is a good suggestion, but... How? Often it's not only about the tool, you need to understand how to change your application to embrace new technologies. Sometimes we need to recognize that code organization need to change to overcome critical performance issues.
Create a monitoring environment specifically designed for software developers avoiding any server or infrastructure configuration that many developers hate to deal with.
Thanks to Inspector, you will never have the need to install things at the server level or make complex configuration inyour cloud infrastructure.
Inspector works with a lightweight software library that you can install in your application like any other dependencies. In case of Laravel you have our official Laravel package at your disposal. Developers are not always comfortable installing and configuring software at the server level, because these installations are out of the software development lifecycle, or are even managed by external teams.
Visit our website for more details: https://inspector.dev/laravel/
Or share this article with your network if you think it could help others.
17