tutoriales.com

C#: Desentrañando el Patrón Repository y Unit of Work para una Persistencia Robusta

Este tutorial profundiza en la implementación de los patrones Repository y Unit of Work en C#. Exploraremos cómo estos patrones, en conjunto con Entity Framework Core, permiten crear una capa de persistencia de datos limpia, desacoplada y fácilmente testeable, mejorando la arquitectura de tus aplicaciones.

Intermedio15 min de lectura5 views
Reportar error

📖 Introducción a la Persistencia de Datos y la Complejidad

En el desarrollo de software moderno, la persistencia de datos es un pilar fundamental. Casi todas las aplicaciones necesitan almacenar y recuperar información de una base de datos. Sin embargo, interactuar directamente con la base de datos desde la lógica de negocio puede llevar a un código acoplado, difícil de mantener y propenso a errores. Aquí es donde entran en juego los patrones de diseño, y en particular, el Patrón Repository y Unit of Work (Unidad de Trabajo) se destacan como soluciones elegantes y potentes para gestionar la persistencia de datos.

Este tutorial te guiará a través de una implementación práctica de estos patrones en C#, utilizando Entity Framework Core como ORM. Aprenderás a construir una capa de abstracción que te permitirá trabajar con tus datos de una manera más limpia, desacoplada y testeable.

¿Por qué necesitamos estos patrones? 🤔

Imagina una aplicación donde la lógica de negocio interactúa directamente con el DbContext de Entity Framework Core. Cada vez que necesitas acceder o modificar datos, invocas métodos específicos del DbContext. Esto genera varias problemáticas:

  • Alto Acoplamiento: Tu lógica de negocio queda fuertemente acoplada a la tecnología de persistencia (Entity Framework Core en este caso). Si decides cambiar el ORM o la base de datos, tendrías que modificar una gran cantidad de código.
  • Duplicación de Código: Es probable que repitas el mismo código de acceso a datos en diferentes partes de tu aplicación.
  • Dificultad de Pruebas: Probar la lógica de negocio se vuelve complicado, ya que cada prueba requeriría una base de datos real o un complejo mocking del DbContext.
  • Gestión de Transacciones: Gestionar transacciones explícitamente en múltiples servicios puede ser propenso a errores y repetitivo.

Los patrones Repository y Unit of Work abordan estas preocupaciones, proporcionando una capa intermedia que abstrae los detalles de la persistencia.

🎯 Entendiendo el Patrón Repository

El Patrón Repository es un patrón de diseño que media entre la capa de dominio y la de mapeo de datos, comportándose como una colección de objetos en memoria. Proporciona una interfaz para acceder a los datos, encapsulando la lógica real de acceso a la base de datos.

💡 Consejo: Piensa en un Repository como una biblioteca que te permite "guardar", "cargar", "buscar" y "eliminar" entidades de tu sistema, sin que te importe cómo se realiza esa operación por debajo.

Características clave del Repository:

  • Abstracción de la Persistencia: Oculta los detalles de cómo se almacenan y recuperan los datos. Los consumidores del Repository no necesitan saber si estás usando SQL Server, PostgreSQL, MongoDB, o un sistema de archivos.
  • Colección de Entidades: Presenta una vista de "colección" de objetos, permitiendo operaciones CRUD (Crear, Leer, Actualizar, Eliminar) básicas sobre ellos.
  • Desacoplamiento: Reduce la dependencia de la lógica de negocio sobre la tecnología de persistencia.
  • Mejora la Testeabilidad: Facilita la creación de mocks o stubs del Repository para pruebas unitarias de la lógica de negocio, sin necesidad de una base de datos real.

🛠️ Implementando el Patrón Repository en C#

Vamos a empezar construyendo un Repository genérico que pueda manejar cualquier tipo de entidad, y luego, un Repository específico para una entidad Producto.

1. Definir la Interfaz Genérica IRepository

La interfaz IRepository<TEntity> definirá las operaciones básicas que cualquier repositorio debe ofrecer. TEntity será una clase que representa una entidad de la base de datos.

namespace RepositoryUoW.Core.Interfaces
{
    public interface IRepository<TEntity> where TEntity : class
    {
        Task<TEntity?> GetByIdAsync(int id);
        Task<IEnumerable<TEntity>> GetAllAsync();
        Task AddAsync(TEntity entity);
        void Update(TEntity entity);
        void Remove(TEntity entity);
        Task AddRangeAsync(IEnumerable<TEntity> entities);
        void RemoveRange(IEnumerable<TEntity> entities);
    }
}
📌 Nota: Hemos elegido métodos asíncronos (`Task`) para las operaciones que involucran I/O de base de datos, lo cual es una buena práctica en .NET para mejorar la escalabilidad y responsividad de las aplicaciones.

2. Implementar el Repository Genérico Base

Ahora creamos una implementación concreta de IRepository<TEntity> que utilizará Entity Framework Core. Esta clase Repository<TEntity> encapsulará la interacción directa con el DbContext.

using Microsoft.EntityFrameworkCore;
using RepositoryUoW.Core.Interfaces;

namespace RepositoryUoW.Infrastructure.Data
{
    public class Repository<TEntity> : IRepository<TEntity> where TEntity : class
    {
        protected readonly DbContext _dbContext;
        protected readonly DbSet<TEntity> _dbSet;

        public Repository(DbContext dbContext)
        {
            _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
            _dbSet = _dbContext.Set<TEntity>();
        }

        public async Task<TEntity?> GetByIdAsync(int id)
        {
            return await _dbSet.FindAsync(id);
        }

        public async Task<IEnumerable<TEntity>> GetAllAsync()
        {
            return await _dbSet.ToListAsync();
        }

        public async Task AddAsync(TEntity entity)
        {
            await _dbSet.AddAsync(entity);
        }

        public void Update(TEntity entity)
        {
            _dbSet.Attach(entity);
            _dbContext.Entry(entity).State = EntityState.Modified;
        }

        public void Remove(TEntity entity)
        {
            _dbSet.Remove(entity);
        }

        public async Task AddRangeAsync(IEnumerable<TEntity> entities)
        {
            await _dbSet.AddRangeAsync(entities);
        }

        public void RemoveRange(IEnumerable<TEntity> entities)
        {
            _dbSet.RemoveRange(entities);
        }
    }
}

3. Crear Entidades de Ejemplo

Para nuestros ejemplos, definiremos dos entidades simples: Producto y Categoria.

namespace RepositoryUoW.Core.Entities
{
    public class Producto
    {
        public int Id { get; set; }
        public string Nombre { get; set; } = string.Empty;
        public decimal Precio { get; set; }
        public int CategoriaId { get; set; }
        public Categoria? Categoria { get; set; } // Propiedad de navegación
    }

    public class Categoria
    {
        public int Id { get; set; }
        public string Nombre { get; set; } = string.Empty;
        public ICollection<Producto>? Productos { get; set; } // Propiedad de navegación
    }
}

4. Configurar el DbContext

Nuestro ApplicationDbContext heredará de DbContext y configurará las entidades.

using Microsoft.EntityFrameworkCore;
using RepositoryUoW.Core.Entities;

namespace RepositoryUoW.Infrastructure.Data
{
    public class ApplicationDbContext : DbContext
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { }

        public DbSet<Producto> Productos { get; set; }
        public DbSet<Categoria> Categorias { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);

            modelBuilder.Entity<Producto>()
                .HasOne(p => p.Categoria)
                .WithMany(c => c.Productos)
                .HasForeignKey(p => p.CategoriaId);

            // Datos semilla para demostración
            modelBuilder.Entity<Categoria>().HasData(
                new Categoria { Id = 1, Nombre = "Electrónica" },
                new Categoria { Id = 2, Nombre = "Hogar" }
            );

            modelBuilder.Entity<Producto>().HasData(
                new Producto { Id = 1, Nombre = "Televisor", Precio = 1200.00M, CategoriaId = 1 },
                new Producto { Id = 2, Nombre = "Refrigerador", Precio = 800.00M, CategoriaId = 2 },
                new Producto { Id = 3, Nombre = "Laptop", Precio = 1500.00M, CategoriaId = 1 }
            );
        }
    }
}

✨ Extendiendo el Repository: Especificaciones

El repositorio genérico es útil para operaciones básicas, pero a menudo necesitamos consultas más complejas (filtrado, ordenación, inclusión de propiedades de navegación). Una forma elegante de manejar esto es con el patrón Specification.

1. Interfaz IQuerySpecification

Definimos una interfaz para nuestras especificaciones, que nos permitirá construir expresiones de consulta.

using System.Linq.Expressions;

namespace RepositoryUoW.Core.Interfaces
{
    public interface IQuerySpecification<TEntity>
    {
        Expression<Func<TEntity, bool>>? Criteria { get; }
        List<Expression<Func<TEntity, object>>> Includes { get; }
        Expression<Func<TEntity, object>>? OrderBy { get; }
        Expression<Func<TEntity, object>>? OrderByDescending { get; }
        int Take { get; }
        int Skip { get; }
        bool IsPagingEnabled { get; }

        // Métodos para agregar criterios, includes, etc.
        IQuerySpecification<TEntity> AddCriteria(Expression<Func<TEntity, bool>> criteria);
        IQuerySpecification<TEntity> AddInclude(Expression<Func<TEntity, object>> includeExpression);
        IQuerySpecification<TEntity> AddOrderBy(Expression<Func<TEntity, object>> orderByExpression);
        IQuerySpecification<TEntity> AddOrderByDescending(Expression<Func<TEntity, object>> orderByDescendingExpression);
        IQuerySpecification<TEntity> ApplyPaging(int skip, int take);
    }
}

2. Implementación Base de Specification

Una clase base BaseSpecification<TEntity> que implementa la interfaz.

using System.Linq.Expressions;
using RepositoryUoW.Core.Interfaces;

namespace RepositoryUoW.Core.Specifications
{
    public abstract class BaseSpecification<TEntity> : IQuerySpecification<TEntity> where TEntity : class
    {
        public Expression<Func<TEntity, bool>>? Criteria { get; private set; }
        public List<Expression<Func<TEntity, object>>> Includes { get; } = new List<Expression<Func<TEntity, object>>>();
        public Expression<Func<TEntity, object>>? OrderBy { get; private set; }
        public Expression<Func<TEntity, object>>? OrderByDescending { get; private set; }
        public int Take { get; private set; }
        public int Skip { get; private set; }
        public bool IsPagingEnabled { get; private set; }

        public BaseSpecification() { }

        protected BaseSpecification(Expression<Func<TEntity, bool>> criteria)
        {
            Criteria = criteria;
        }

        public IQuerySpecification<TEntity> AddCriteria(Expression<Func<TEntity, bool>> criteria)
        {
            Criteria = criteria;
            return this;
        }

        public IQuerySpecification<TEntity> AddInclude(Expression<Func<TEntity, object>> includeExpression)
        {
            Includes.Add(includeExpression);
            return this;
        }

        public IQuerySpecification<TEntity> AddOrderBy(Expression<Func<TEntity, object>> orderByExpression)
        {
            OrderBy = orderByExpression;
            return this;
        }

        public IQuerySpecification<TEntity> AddOrderByDescending(Expression<Func<TEntity, object>> orderByDescendingExpression)
        {
            OrderByDescending = orderByDescendingExpression;
            return this;
        }

        public IQuerySpecification<TEntity> ApplyPaging(int skip, int take)
        {
            Skip = skip;
            Take = take;
            IsPagingEnabled = true;
            return this;
        }
    }
}

3. Evaluador de Especificaciones

Una clase SpecificationEvaluator que aplica las especificaciones a un IQueryable.

using Microsoft.EntityFrameworkCore;
using RepositoryUoW.Core.Interfaces;

namespace RepositoryUoW.Infrastructure.Data
{
    public static class SpecificationEvaluator<TEntity>
        where TEntity : class
    {
        public static IQueryable<TEntity> GetQuery(IQueryable<TEntity> inputQuery, IQuerySpecification<TEntity> specification)
        {
            var query = inputQuery;

            // Aplicar criterio de filtrado
            if (specification.Criteria != null)
            {
                query = query.Where(specification.Criteria);
            }

            // Aplicar Includes (ej. .Include(p => p.Category))
            query = specification.Includes.Aggregate(query, (current, include) => current.Include(include));

            // Aplicar ordenación
            if (specification.OrderBy != null)
            {
                query = query.OrderBy(specification.OrderBy);
            }
            else if (specification.OrderByDescending != null)
            {
                query = query.OrderByDescending(specification.OrderByDescending);
            }

            // Aplicar paginación
            if (specification.IsPagingEnabled)
            {
                query = query.Skip(specification.Skip).Take(specification.Take);
            }

            return query;
        }
    }
}

4. Actualizar IRepository y Repository para usar Especificaciones

Necesitamos agregar un método ListAsync a IRepository que acepte una especificación.

// En RepositoryUoW.Core.Interfaces/IRepository.cs
// ... (código existente)
        Task<IEnumerable<TEntity>> ListAsync(IQuerySpecification<TEntity> spec);
        Task<int> CountAsync(IQuerySpecification<TEntity> spec);
    }
}

Y su implementación en Repository<TEntity>:

// En RepositoryUoW.Infrastructure.Data/Repository.cs
// ... (código existente)
        public async Task<IEnumerable<TEntity>> ListAsync(IQuerySpecification<TEntity> spec)
        {
            return await SpecificationEvaluator<TEntity>.GetQuery(_dbSet, spec).ToListAsync();
        }

        public async Task<int> CountAsync(IQuerySpecification<TEntity> spec)
        {
            return await SpecificationEvaluator<TEntity>.GetQuery(_dbSet, spec).CountAsync();
        }
    }
}

5. Crear una Especificación Concreta

Ahora podemos crear especificaciones específicas para nuestras necesidades, como obtener productos de una categoría específica y ordenarlos.

using RepositoryUoW.Core.Entities;
using RepositoryUoW.Core.Specifications;

namespace RepositoryUoW.Core.ProductSpecifications
{
    public class ProductosPorCategoriaSpec : BaseSpecification<Producto>
    {
        public ProductosPorCategoriaSpec(int categoriaId)
            : base(p => p.CategoriaId == categoriaId)
        {
            AddInclude(p => p.Categoria);
            AddOrderBy(p => p.Nombre);
        }

        public ProductosPorCategoriaSpec(int categoriaId, int skip, int take)
            : base(p => p.CategoriaId == categoriaId)
        {
            AddInclude(p => p.Categoria);
            AddOrderBy(p => p.Nombre);
            ApplyPaging(skip, take);
        }
    }

    public class ProductoMasCaroSpec : BaseSpecification<Producto>
    {
        public ProductoMasCaroSpec()
        {
            AddOrderByDescending(p => p.Precio);
            ApplyPaging(0, 1); // Tomar solo el primero (más caro)
        }
    }
}

🔄 Desentrañando el Patrón Unit of Work

El Patrón Unit of Work (Unidad de Trabajo) es un patrón de diseño que se utiliza para agrupar una o varias operaciones de base de datos en una única transacción lógica. En el contexto de Entity Framework Core, el DbContext ya implementa inherentemente muchas de las funcionalidades de un Unit of Work. Sin embargo, abstraerlo a través de una interfaz IUnitOfWork ofrece beneficios clave:

  • Centralización de Cambios: Agrupa todos los cambios de un conjunto de operaciones en una sola unidad antes de commitarlos a la base de datos. Si algo falla, se puede deshacer toda la unidad.
  • Consistencia Transaccional: Asegura que todas las operaciones dentro de la unidad de trabajo se completen con éxito o que ninguna de ellas se aplique (transaccionalidad).
  • Reducción de Código Repetitivo: Evita tener que llamar _dbContext.SaveChanges() en cada operación CRUD individual, centralizando el guardado de cambios.
  • Abstracción de DbContext: Permite que tus servicios de aplicación trabajen con una interfaz genérica IUnitOfWork en lugar de un DbContext específico, aumentando el desacoplamiento.
Capa de Presentación Capa de Servicio IUnitOfWork IRepository<Producto> IRepository<Categoria> DbContext Base de Datos

1. Definir la Interfaz IUnitOfWork

Esta interfaz expondrá los repositorios específicos y el método CompleteAsync() para guardar los cambios.

using RepositoryUoW.Core.Entities;
using RepositoryUoW.Core.Interfaces;

namespace RepositoryUoW.Core.Interfaces
{
    public interface IUnitOfWork : IDisposable
    {
        IRepository<Producto> Productos { get; }
        IRepository<Categoria> Categorias { get; }
        Task<int> CompleteAsync();
    }
}
🔥 Importante: La interfaz `IUnitOfWork` expone interfaces específicas de `IRepository`, no las clases de implementación `Repository`. Esto mantiene el desacoplamiento.

2. Implementar UnitOfWork

La implementación de UnitOfWork contendrá instancias de nuestros repositorios y gestionará el DbContext.

using Microsoft.EntityFrameworkCore;
using RepositoryUoW.Core.Entities;
using RepositoryUoW.Core.Interfaces;

namespace RepositoryUoW.Infrastructure.Data
{
    public class UnitOfWork : IUnitOfWork
    {
        private readonly ApplicationDbContext _dbContext;
        private IRepository<Producto>? _productos;
        private IRepository<Categoria>? _categorias;

        public UnitOfWork(ApplicationDbContext dbContext)
        {
            _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
        }

        public IRepository<Producto>
            Productos => _productos ??= new Repository<Producto>(_dbContext);

        public IRepository<Categoria>
            Categorias => _categorias ??= new Repository<Categoria>(_dbContext);

        public async Task<int> CompleteAsync()
        {
            return await _dbContext.SaveChangesAsync();
        }

        public void Dispose()
        {
            _dbContext.Dispose();
            GC.SuppressFinalize(this);
        }
    }
}
💡 Consejo: Estamos utilizando el patrón *null-coalescing assignment* `??=` para inicializar los repositorios solo la primera vez que se acceden (carga perezosa). Esto es opcional, pero puede ser útil si no todos los repositorios son usados en cada transacción.

🔗 Inyección de Dependencias y Configuración

Para que estos patrones sean efectivos, necesitamos registrarlos en nuestro contenedor de Inyección de Dependencias (DI) de ASP.NET Core.

1. Configurar Servicios en Program.cs

En un proyecto ASP.NET Core, configuraríamos esto en Program.cs (o Startup.cs en versiones anteriores).

using Microsoft.EntityFrameworkCore;
using RepositoryUoW.Core.Interfaces;
using RepositoryUoW.Infrastructure.Data;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Configurar DbContext con SQL Server (ejemplo)
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

// Registrar IUnitOfWork y Repositories
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
// Si se necesitaran repositorios específicos que no se gestionan directamente por UoW:
// builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

// Migrar base de datos al inicio si está en desarrollo (opcional)
using (var scope = app.Services.CreateScope())
{
    var services = scope.ServiceProvider;
    try
    {
        var context = services.GetRequiredService<ApplicationDbContext>();
        context.Database.Migrate(); // Aplica migraciones pendientes
    }
    catch (Exception ex)
    {
        var logger = services.GetRequiredService<ILogger<Program>>();
        logger.LogError(ex, "An error occurred while migrating the database.");
    }
}

app.Run();

Para appsettings.json:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=RepositoryUoWDB;Trusted_Connection=True;MultipleActiveResultSets=true"
  }
}
⚠️ Advertencia: La cadena de conexión en `appsettings.json` es para desarrollo local. En producción, usa variables de entorno o un sistema de secretos.

🚀 Uso de los Patrones en un Servicio de Aplicación

Ahora, veamos cómo usar IUnitOfWork en un servicio de aplicación (o un controlador) para realizar operaciones con datos.

1. Definir un Servicio de Productos

using RepositoryUoW.Core.Entities;
using RepositoryUoW.Core.Interfaces;
using RepositoryUoW.Core.ProductSpecifications;

namespace RepositoryUoW.Application.Services
{
    public class ProductoService
    {
        private readonly IUnitOfWork _unitOfWork;

        public ProductoService(IUnitOfWork unitOfWork)
        {
            _unitOfWork = unitOfWork;
        }

        public async Task<IEnumerable<Producto>> GetAllProductosAsync()
        {
            return await _unitOfWork.Productos.GetAllAsync();
        }

        public async Task<Producto?> GetProductoByIdAsync(int id)
        {
            return await _unitOfWork.Productos.GetByIdAsync(id);
        }

        public async Task<Producto> AddProductoAsync(Producto producto)
        {
            await _unitOfWork.Productos.AddAsync(producto);
            await _unitOfWork.CompleteAsync(); // Guardar cambios
            return producto;
        }

        public async Task UpdateProductoAsync(Producto producto)
        {
            _unitOfWork.Productos.Update(producto);
            await _unitOfWork.CompleteAsync(); // Guardar cambios
        }

        public async Task DeleteProductoAsync(int id)
        {
            var productoToDelete = await _unitOfWork.Productos.GetByIdAsync(id);
            if (productoToDelete != null)
            {
                _unitOfWork.Productos.Remove(productoToDelete);
                await _unitOfWork.CompleteAsync(); // Guardar cambios
            }
        }

        public async Task<IEnumerable<Producto>> GetProductosPorCategoriaAsync(int categoriaId, int pageIndex, int pageSize)
        {
            var spec = new ProductosPorCategoriaSpec(categoriaId, (pageIndex - 1) * pageSize, pageSize);
            return await _unitOfWork.Productos.ListAsync(spec);
        }

        public async Task<Producto?> GetProductoMasCaroAsync()
        {
            var spec = new ProductoMasCaroSpec();
            return (await _unitOfWork.Productos.ListAsync(spec)).FirstOrDefault();
        }

        public async Task ActualizarProductoYCategoriaAsync(Producto producto, Categoria categoria)
        {
            // Ambas operaciones forman parte de la misma unidad de trabajo/transacción
            _unitOfWork.Productos.Update(producto);
            _unitOfWork.Categorias.Update(categoria);
            await _unitOfWork.CompleteAsync(); // Si una falla, ambas se revierten
        }
    }
}

2. Registrar el Servicio en DI

En Program.cs, añade:

// ... (después de registrar IUnitOfWork)
builder.Services.AddScoped<ProductoService>();
// ...

3. Usar el Servicio en un Controlador (ejemplo simple)

using Microsoft.AspNetCore.Mvc;
using RepositoryUoW.Application.Services;
using RepositoryUoW.Core.Entities;

namespace RepositoryUoW.Web.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class ProductosController : ControllerBase
    {
        private readonly ProductoService _productoService;

        public ProductosController(ProductoService productoService)
        {
            _productoService = productoService;
        }

        [HttpGet]
        public async Task<ActionResult<IEnumerable<Producto>>> GetProductos()
        {
            var productos = await _productoService.GetAllProductosAsync();
            return Ok(productos);
        }

        [HttpGet("{id}")]
        public async Task<ActionResult<Producto>> GetProducto(int id)
        {
            var producto = await _productoService.GetProductoByIdAsync(id);
            if (producto == null)
            {
                return NotFound();
            }
            return Ok(producto);
        }

        [HttpPost]
        public async Task<ActionResult<Producto>> PostProducto(Producto producto)
        {
            await _productoService.AddProductoAsync(producto);
            return CreatedAtAction(nameof(GetProducto), new { id = producto.Id }, producto);
        }

        [HttpGet("porcategoria/{categoriaId}")]
        public async Task<ActionResult<IEnumerable<Producto>>> GetProductosPorCategoria(int categoriaId, [FromQuery] int pageIndex = 1, [FromQuery] int pageSize = 10)
        {
            var productos = await _productoService.GetProductosPorCategoriaAsync(categoriaId, pageIndex, pageSize);
            return Ok(productos);
        }

        // Otros métodos (Put, Delete) se implementarían de manera similar...
    }
}

✅ Beneficios de Usar Repository y Unit of Work

La implementación de estos patrones trae consigo una serie de ventajas significativas:

  • Desacoplamiento: La lógica de negocio no tiene conocimiento de los detalles de persistencia. Esto facilita el cambio de ORM o tecnología de base de datos en el futuro.
  • Testeabilidad: Los repositorios pueden ser fácilmente mocked o stubbed para probar la lógica de negocio de forma aislada, sin depender de una base de datos real. Esto acelera las pruebas unitarias.
  • Organización del Código: La capa de acceso a datos se vuelve más limpia y organizada, con responsabilidades claramente definidas.
  • Consistencia Transaccional: El Unit of Work asegura que múltiples operaciones de base de datos se manejen como una sola transacción atómica, manteniendo la integridad de los datos.
  • Reusabilidad: Las especificaciones y los métodos de repositorio pueden ser reutilizados en diferentes partes de la aplicación.
  • Mantenibilidad: El código es más fácil de entender, modificar y extender a medida que la aplicación crece.
90% Mejora en la Mantenibilidad

⚠️ Consideraciones y Posibles Desventajas

Aunque los patrones Repository y Unit of Work son muy beneficiosos, es importante conocer sus posibles inconvenientes y cuándo su uso puede no ser óptimo:

  • Complejidad Adicional: Introducen una capa de abstracción adicional, lo que puede aumentar la complejidad inicial del proyecto, especialmente en aplicaciones pequeñas donde el DbContext directo podría ser suficiente.
  • Over-Engineering: Para operaciones CRUD muy simples, la sobrecarga de implementar estos patrones puede no valer la pena.
  • Abstracción de ORM (a veces insuficiente): Si bien abstraen la base de datos, no siempre abstraen completamente el ORM. Por ejemplo, algunas características muy específicas de Entity Framework Core podrían ser difíciles de encapsular completamente sin exponer detalles del ORM en el Repository.
  • El DbContext ya es un UoW: Como mencionamos, DbContext ya funciona como una Unidad de Trabajo. La implementación de un IUnitOfWork personalizado es principalmente para desacoplar y centralizar el SaveChanges y la gestión de transacciones cuando se usan múltiples repositorios en una misma operación lógica.
¿Cuándo el Repository y Unit of Work son más útiles? Son especialmente útiles en aplicaciones de tamaño mediano a grande, donde la complejidad de la lógica de negocio es considerable, se requiere una alta testeabilidad, y hay una probabilidad razonable de cambiar la tecnología de persistencia en el futuro. También son muy valiosos en arquitecturas de dominio dirigidas (DDD).

🚀 Conclusión

En este tutorial, hemos explorado en profundidad cómo implementar los patrones Repository y Unit of Work en aplicaciones C# con Entity Framework Core. Hemos visto cómo el Repository abstrae las operaciones de acceso a datos, mientras que el Unit of Work asegura la consistencia transaccional al agrupar múltiples operaciones.

Al combinar estos patrones con la Inyección de Dependencias, hemos logrado una arquitectura de persistencia robusta, desacoplada y fácilmente testeable. Aunque introducen cierta complejidad, los beneficios a largo plazo en términos de mantenibilidad y escalabilidad hacen que esta inversión valga la pena para la mayoría de las aplicaciones empresariales.

Empieza a aplicar estos patrones en tus propios proyectos y experimenta la diferencia en la calidad y la estructura de tu código.

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!