Repository Pattern en C#: cuándo sí y cuándo sobra
El Repository Pattern en C# es uno de los patrones más usados y, a la vez, más mal aplicados que conozco. Casi nadie discute cuándo sí compensa — eso lo explica cualquier tutorial. Lo que casi nadie escribe es cuándo sobra, y sobra más veces de las que la mayoría de proyectos reconocen.
Qué es el Repository Pattern en C# y qué promete resolver
El patrón Repository propone una capa que abstrae el acceso a datos detrás de una interfaz, para que el resto de la aplicación no dependa directamente de cómo se guardan o recuperan los datos. La promesa es doble: poder cambiar de mecanismo de persistencia sin tocar la lógica de negocio, y poder probar esa lógica sin necesitar una base de datos real.
public interface IRepositorioPedidos
{
Task<Pedido?> ObtenerPorIdAsync(Guid id);
Task GuardarAsync(Pedido pedido);
}
Hasta aquí, la teoría es sólida. El problema aparece en cómo se implementa en la práctica.
Cuándo sí compensa
Compensa cuando de verdad hay lógica de consulta que conviene centralizar y reutilizar — combinaciones de filtros complejas, reglas de qué se considera «activo» o «visible» que se repiten en varios sitios del código — y cuando aislar el acceso a datos facilita probar la lógica de negocio con una implementación en memoria, sin depender de una base de datos real en cada test.
También compensa cuando existe una posibilidad real, no hipotética, de cambiar el mecanismo de persistencia — no «por si algún día migramos a otra base de datos», sino porque el proyecto ya sabe que necesita convivir con dos orígenes de datos distintos, o migrar de uno a otro en un plazo conocido.
// Aquí sí compensa: la interfaz oculta una consulta compleja que se reutiliza en varios sitios
public interface IRepositorioPedidosActivos
{
Task<List<Pedido>> ObtenerPendientesDeEnvioAsync();
}
public class RepositorioPedidosActivos : IRepositorioPedidosActivos
{
private readonly ContextoDatos _contexto;
public RepositorioPedidosActivos(ContextoDatos contexto) => _contexto = contexto;
public async Task<List<Pedido>> ObtenerPendientesDeEnvioAsync() =>
await _contexto.Pedidos
.Where(p => p.Estado == EstadoPedido.Pagado && p.FechaEnvio == null)
.Where(p => !p.Cancelado)
.OrderBy(p => p.FechaCreacion)
.ToListAsync();
}
Esta interfaz sí abstrae algo real: una regla de negocio (qué cuenta como «pendiente de envío») que, sin este repositorio, se repetiría o se interpretaría de forma distinta en cada sitio del código que la necesite. Esa es la diferencia que marco en cada code review: una interfaz que protege una regla concreta se queda; una interfaz que solo redirige a lo que el ORM ya hacía, se revisa con lupa antes de aceptarla.
Cuándo sobra: el repositorio genérico encima de un ORM que ya es un repositorio
Aquí está la parte que casi nadie se moja en decir. Un ORM moderno, con su forma de consultar datos y su seguimiento de cambios, ya implementa el patrón Repository y el patrón Unit of Work por debajo. Añadir una interfaz IRepositorio<T> genérica encima, que internamente solo llama a los mismos métodos que el propio ORM ya expone, no añade una abstracción real: añade una capa que traduce un método por otro, sin cambiar nada de fondo.
// Repositorio genérico que no abstrae nada: solo envuelve el ORM
public interface IRepositorioGenerico<T> where T : class
{
Task<T?> ObtenerPorIdAsync(object id);
Task<List<T>> ObtenerTodosAsync();
Task AgregarAsync(T entidad);
void Actualizar(T entidad);
void Eliminar(T entidad);
}
public class RepositorioGenerico<T> : IRepositorioGenerico<T> where T : class
{
private readonly ContextoDatos _contexto;
public RepositorioGenerico(ContextoDatos contexto) => _contexto = contexto;
public async Task<T?> ObtenerPorIdAsync(object id) =>
await _contexto.Set<T>().FindAsync(id);
public async Task<List<T>> ObtenerTodosAsync() =>
await _contexto.Set<T>().ToListAsync();
public async Task AgregarAsync(T entidad) =>
await _contexto.Set<T>().AddAsync(entidad);
public void Actualizar(T entidad) => _contexto.Set<T>().Update(entidad);
public void Eliminar(T entidad) => _contexto.Set<T>().Remove(entidad);
}
Esta clase no oculta el ORM: lo envuelve con nombres distintos. Cuando alguien necesita una consulta que el repositorio genérico no contempla —casi siempre pasa pronto—, la solución habitual es exponer el IQueryable por debajo, lo que rompe la abstracción que supuestamente protegía, o seguir añadiendo métodos específicos al repositorio genérico hasta que deja de ser genérico y se convierte en una clase enorme con un método por cada consulta que alguna vez hizo falta.
La capa que existe porque el manual dice que debe existir
He visto proyectos donde el repositorio genérico se añadió el primer día, antes de escribir la primera consulta real, porque «así se hace un proyecto bien estructurado» según el tutorial de turno. Nadie volvió a preguntarse si aportaba algo, porque ya estaba ahí y quitarlo parecía más trabajo que mantenerlo. Esa es la capa que existe porque el manual dice que debe existir, no porque resuelva un problema del proyecto — y es exactamente el tipo de decisión que reviso con más escepticismo cuando entro en un equipo nuevo.
La alternativa que suelo aplicar, cuando el ORM ya cubre razonablemente el acceso a datos, es exponer directamente lo que el ORM ofrece a través de una capa de consultas específicas para cada caso de uso, en vez de una interfaz genérica que nadie usa entera. Es menos código, no más, y cada consulta se entiende leyendo su propio nombre en vez de reconstruyendo qué hace un método genérico con tres parámetros opcionales.
Cómo pruebo la lógica de negocio sin arrastrar una base de datos real
Uno de los argumentos más repetidos a favor del repositorio genérico es que facilita el testing: sustituyes la implementación real por una en memoria y pruebas la lógica de negocio sin tocar una base de datos. Es cierto, pero no es exclusivo del repositorio genérico — se consigue igual con interfaces específicas por caso de uso, como las de los ejemplos anteriores.
public class RepositorioPedidosActivosEnMemoria : IRepositorioPedidosActivos
{
private readonly List<Pedido> _pedidos;
public RepositorioPedidosActivosEnMemoria(List<Pedido> pedidos) => _pedidos = pedidos;
public Task<List<Pedido>> ObtenerPendientesDeEnvioAsync() =>
Task.FromResult(_pedidos
.Where(p => p.Estado == EstadoPedido.Pagado && p.FechaEnvio == null && !p.Cancelado)
.OrderBy(p => p.FechaCreacion)
.ToList());
}
La diferencia es que esta implementación de prueba solo tiene que reproducir una consulta concreta, con un nombre que dice exactamente qué hace. Una implementación en memoria de un repositorio genérico, en cambio, tiene que simular el comportamiento completo del ORM —seguimiento de cambios, relaciones, carga diferida— para cualquier consulta que alguien decida hacer con él, lo que en la práctica rara vez se hace bien del todo.
Un término medio que uso a menudo: consultas específicas, no un repositorio que lo cubra todo
Entre el repositorio genérico que envuelve el ORM sin aportar nada y no tener ninguna capa de abstracción, hay un término medio que aplico en la mayoría de proyectos: una clase o interfaz por cada consulta o grupo de consultas relacionadas, con un nombre que describe la regla de negocio, no la operación técnica. IRepositorioPedidosActivos dice algo; IRepositorio<Pedido> no dice nada que el propio tipo Pedido no dijera ya.
Este enfoque no elimina el acceso directo al ORM para lo que no necesita una regla de negocio propia — una consulta simple por identificador no necesita una interfaz dedicada, el propio contexto de datos ya es suficientemente claro para eso. Reservo la abstracción para donde de verdad protege algo: una regla que se repite, una consulta compleja, o un punto de extensión que sé que va a necesitar cambiar.
Cómo decido, caso por caso
Antes de añadir esta capa, me hago tres preguntas: ¿hay lógica de consulta real que se repite y merece un sitio propio? ¿Necesito de verdad sustituir la implementación de datos por otra en los tests, o me basta con una base de datos en memoria para eso? ¿Existe una posibilidad real de cambiar de mecanismo de persistencia, no solo teórica? Si las tres respuestas son no, el ORM que ya usa el proyecto es, en la práctica, el repositorio — y añadir uno propio encima es una capa que existe por costumbre, no por necesidad.
Esto no es una postura en contra del patrón, es una postura en contra de aplicarlo sin preguntarse si hace falta. El Repository Pattern en C# sigue siendo una herramienta válida y la sigo usando cuando las preguntas anteriores responden que sí — la diferencia es que la decisión se toma consultando el proyecto real, no copiando la estructura de carpetas de un tutorial que resolvía un problema distinto al tuyo.
¿Estás decidiendo la arquitectura de acceso a datos de un proyecto nuevo? En Desarrollo .NET a medida tomo este tipo de decisión con criterio, no por costumbre.
Fuente externa citada: Infrastructure persistence layer design, Microsoft Learn — guía oficial de Microsoft sobre diseño de la capa de persistencia, incluido el patrón Repository.
