¿Tiene sentido el patrón repositorio en Laravel?

El patrón repositorio consiste en añadir una capa de clases que se encargue a acceder al origen de datos y obtener los distintos modelos de datos.
Tienen métodos como el find
, findAll
, create
o update
entre otros y son muy habituales en frameworks como Symfony pero no tanto en Laravel.
Hay artículos, vídeos e incluso librerías para implementar este patrón en Laravel, pero ¿tiene sentido?
Vídeo: Patrón repositorio en Laravel
Laravel es Active Record
En Laravel tenemos Eloquent ORM está basado en el patrón Active Record y Doctrine, de Symfony, está basado en el patrón repositorio.
En el patrón active record, cada modelo corresponde con una tabla en nuestra base de datos y este propio modelo es nuestra forma de acceso a esta tabla. Podremos buscar, crear o actualizar registros en la tabla usando directamente el modelo.
<?php
// Obtener el usuario con id = 1
User::find(1);
// Algunas forma de buscar/crear usuarios
User::all();
User::where('email', '=', 'hola@victorfalcon.es')->first();
User::create([ ... ]);
Esto ha hecho que mucha gente califique a active record como un anti-patrón. Concretamente rompe con el Single Responsabily Principle de SOLID, ya que cada modelo es responsable tanto de interactuar con la base de datos como con sus relaciones y además, al ser un modelo, contiene también cierta lógica de dominio/negocio.
Y no sé si estas de acuerdo con esto o no, yo tengo mi opinión, pero lo que no tienes que hacer NUNCA es mezclar ambos patrones.
Patrón repositorio en Laravel
Para empezar, querer aplicar este patrón en Laravel, con Eloquent ORM ya suena muy mal. Estamos haciendo que una librería basada en active record funcione como un ORM basado en repository cuando uno no tiene nada que ver con el otro.
Pero bueno, somos cabezotas, queremos que nuestra aplicación Laravel tenga repositorios, vamos a ello.
Crear nuestro repositorio
Vamos a crear nuestro UserRepository
que tendrá una pinta parecida a esta:
<?php
namespace App\Repositories;
interface UserRepositoryInterface
{
public function all();
public function create(array $data);
public function update(array $data, $id);
public function delete($id);
public function find($id);
}
<?php
namespace App\Repositories;
use App\Model\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class UserRepository implements UserRepositoryInterface
{
protected $model;
public function __construct(User $user)
{
$this->model = $user;
}
public function all()
{
return $this->model->all();
}
public function create(array $data)
{
return $this->model->create($data);
}
public function update(array $data, $id)
{
return $this->model->where('id', $id)
->update($data);
}
public function delete($id)
{
return $this->model->destroy($id);
}
public function find($id)
{
if (null == $user = $this->model->find($id)) {
throw new ModelNotFoundException("User not found");
}
return $user;
}
}
Y por último crearemos un repository service container en el que bindeamos la interfaz a la implementación del repositorio para luego poder inyectar en donde lo necesitemos.
public function register()
{
$this->app->bind(
'App\Repositories\UserRepositoryInterface',
'App\Repositories\UserRepository'
);
}
Como vemos ahora hemos encapsulado todo el acceso a los datos en una clase específica y ya podemos dejar de usar el modelo para esto aunque, en realidad, nuestro modelo sigue teniendo los mismos métodos y el repositorio depende directamente del modelo y de que tenga esta herencia con Eloquent.
Y la pregunta es:
Yo — ¿Hemos ganado algo? ¿hay alguna diferencia entre hacer User::all()
o hacer $this->repository->all()
?
Symfony dev — Bueno, a pesar de que estamos aumentando la complejidad y, peor aún, duplicando código, ¡ahora podemos crear un mock del repositorio y hacer tests sin acceder a base de datos, y eso mola!
Yo — Cierto, pero si ese era tu problema, haberlo dicho antes. Eso tiene solución y no tenemos que cambiar Eloquent para conseguirlo.
¿Por qué no tiene ningún sentido?
La mayoría de las veces que alguien rechaza active record es porque no es testeable, no puedes hacer un test de una clase sencilla sin tener que montar una base de datos, ya que no hay forma de cambiar o reemplazar el modelo que es parte de nuestro código de dominio y necesitamos probar.
Y estoy sería un gran problema si, en Laravel, no fuera tan sencillo hacer un test con la base de datos.
Como vemos en el siguiente test, Laravel viene preparado para hacer este tipo de test automáticos de una forma sencilla y clara y, con Laravel Sail, ni siquiera tenemos que preocuparnos por los contenedores de Docker.
use DatabaseMigrations;
class UserCreatorTest extends TestCase
{
use DatabaseMigrations;
private $service;
protected function setUp(): void
{
$this->service = new UserCreator();
}
public function test_it_creates_an_user(): void
{
$data = [
'name' => 'Víctor',
'email' => 'hola@victorfalcon.es',
];
($this->service)($data);
$this->assertDatabaseHas('users', $data);
}
}
Además, las últimas versiones de Laravel vienen incluso preparadas para lanzar estos tests en paralelo haciendo que se ejecuten más rápido y que el tiempo no sea un problema.
Y por último, estos tests nos aportan mucho más valor que los tests unitarios sin infraestructura y, en la mayoría de los casos, aunque hagamos tests unitarios también tendremos que hacer test funcionales con base de datos para asegurarnos de que todo va según lo esperado.
Conclusión
En definitiva, tenemos que asumir que los tests unitarios en Laravel no son comunes, pero que hacer tests funcionales es cuestión de segundos y por tanto, adoptamos la facilidad de active record.