El patrón repositorio genérico pretende extraer el factor común y establecer un contrato que le de las bases comunes a nuestros repositorios. Sin embargo, hay diferentes formas de crear «capas de abstracción» y «flexibilizar» su implementación dependiendo de las necesidades de tu proyecto.
Para esta implementación vamos a ir de la forma más simple hacia formas más complejas, granulares y flexibles, indicando cuándo y cómo se implementarían.
#1 – Repositorio genérico único
Es por donde suelo comenzar cualquier proyecto o si veo que todas mis entidades van a tener CRUD. En muchos casos con esta implementación es más que suficiente para las necesidades de pequeños proyectos con lógicas simples.
public interface IGenericRepository<T>
{
T GetById(int id);
IList<T> GetAll();
void Add(T entity);
void Update(T entity);
void Delete(int id);
}
En este caso, la declaración de la interfaz indica que esos métodos serán implementados por una clase «T», que es la forma de denominar a los tipos genéricos. Cuando queramos declarar una interfaz a partir de este punto, podemos hacer dos cosas:
1. Declararla de forma directa con esta nueva interfaz y pasándole la clase para la que realizará el repositorio.
using System.Data.SqlClient;
namespace PatronRepositorioConADONET.Repositories
{
public class CategoriaRepository : IGenericRepository<Categoria>
{
private string connectionString = "String de conexión SQL";
public IList<Categoria> GetAll(){ }
public Categoria GetById(int id){ }
public void Add(Categoria entity){ }
public void Delete(int id){ }
public void Update(Categoria entity){ }
}
}
2. Declararla en otra interfaz que sea específica para la clase que realizará el repositorio y esta nueva interfaz sería la que implementásemos en la clase repositorio final.
using System.Data.SqlClient;
namespace PatronRepositorioConADONET.Repositories
{
public interface ICategoriaRepository : IGenericRepository<Categoria>
{
}
public class CategoriaRepository : ICategoriaRepository
{
private string connectionString = "String de conexión SQL";
public IList<Categoria> GetAll(){ }
public Categoria GetById(int id){ }
public void Add(Categoria entity){ }
public void Delete(int id){ }
public void Update(Categoria entity){ }
}
}
La diferencia realmente reside en si luego necesitamos implementar en algún repositorio algún método extra que no queremos que el resto implemente. Personalmente, porque tampoco implica una carga extra de trabajo sustancial, suelo optar por la segunda opción.
Para visualizar esta situación, vamos a decir que mi repositorio para mis autores quiero que me pueda dar un listado de artículos que han escrito y otro con los artículos y sus categorias. Y esto va a ser porque en una pantalla donde mostremos el listado de artículos no vamos a necesitar que se vean las categorias, pero en otro sí.
Por otro lado, podemos irnos a la situación opuesta. En este caso consideramos que la interfaz IGenericRepository puede ser demasiado genérico para tu proyecto. Quizás no necesites que todas las clases implementen un repositorio que pueda hacer todo el CRUD, sino que solo quieres que implemente algún otro método, lo que nos lleva a punto 2.
#2 – División del repositorio en Read-Only y CRUD completo
public interface IReadOnlyRepository<T>
{
T GetById(int id);
IList<T> GetAll();
}
public interface IRepository<T> : IReadOnlyRepository<T>
{
void Add(T entity);
void Update(T entity);
void Delete(int id);
}
Con esta implementación ahora podemos tener dos tipos de repositorios, el que ya teníamos previamente y uno nuevo solo para consulta. Esto es especialmente útil cuando tenemos clases auxiliares con datos introducidos desde el inicio.
Un ejemplo de esto podría ser una clase que sea «Pais». Esta clase realmente es muy posible que no queramos que sea modificada por los usuarios y que sea básicamente de consumo para componer otras clases.
Podeis comparar las implementaciones necesarias si no tuvieramos un repositorio ReadOnly y teniendo uno.
public interface IPaisRepository : IReadOnlyRepository<Pais> { }
public class PaisRepository : IPaisRepository
{
public IList<Pais> GetAll()
{
// Implementado
}
public Pais GetById(int id)
{
// Implementado
}
}
public interface IPaisRepository : IRepository<Pais> { }
public class PaisRepository : IPaisRepository
{
/* No queremos que se puedan añadir, borrar o actualizar */
public void Add(Pais entity)
=> throw new NotImplementedException();
public void Delete(int id)
=> throw new NotImplementedException();
public void Update(Pais entity)
=> throw new NotImplementedException();
public IList<Pais> GetAll()
{
// Implementado
}
public Pais GetById(int id)
{
// Implementado
}
}
public class Pais
{
public int Id { get; set; }
public string Name { get; set; }
}
Creo que se puede apreciar la ventaja, ¿no? Tener métodos que devuelven excepciones sólo para que no que ese método no pueda utilizarse no es una buena práctica. Citando a Steve Smith (Ardalis):
Haz que la vía correcta sea fácil y la incorrecta díficil. Fuerza a los desarrolladores a caer por el abismo del éxito.
Steve Smith (Ardalis) – «Clean Architecture with ASP.NET Core» [Video].
#3 – Fragmentación total del repositorio genérico y recoposición por repositorio
Pero… ¿Y si hay algunos en los que sí que quiero que haya inserciones pero que no se puedan eliminar una vez insertados?
Este dilema sigue la misma premisa que el anterior, solo que lo lleva un paso más allá. Cada método del CRUD puede ser descompuesto en una interfaz independiente y que los repositorios implementen aquello que van a utilizar.
public interface IRead<T>
{
T GetById(int id);
IList<T> GetAll();
}
public interface ICreate<T>
{
void Add(T entity);
}
public interface IUpdate<T>
{
void Update(T entity);
}
public interface IDelete<T>
{
void Delete(int id);
}
public interface IReadOnlyRepository<T> : IRead<T>{ }
public interface IRepository<T> : ICreate<T>, IRead<T>, IUpdate<T>, IDelete<T> { }
Si bien este sistema aporta muchísima flexibilidad y máximiza la reutilización, nunca me he visto en la necesidad de granularizar tanto el repositorio. Pero que no lo haya tenido que usar no significa que no sea una herramienta útil a tener en mente, simplemente hay que recordar que no hace falta sobreoptimizar prematuramente. Si os veis en una situación como la de la pregunta inicial de este apartado, entonces planteaos este cambio.
La parte buena de estos 3 «niveles» de granularización es que puedes ir pasando del 1 al 3 sin provocar cambios en la lógica de persistencia. Al tratarse de interfaces que finalmente van a implementar los mismos métodos con los mismos nombres y parámetros, da igual comenzar con el repositorio genérico del nivel 1 y luego pasarlo directamente al del punto 3, no vas a tener que modificar los métodos repositorios ni implementaciones de los mismos que ya hayas creado, así que los cambios son mínimos 😁👌.