Diseño y selección de repositorios en arquitectura de software

Introducción al patrón Repositorio

El concepto de repositorio fue popularizado por Eric Evans en su obra fundamental Domain-Driven Design, donde se presenta como una abstracción para acceder a entidades del dominio sin exponer detalles de persistencia. Aunque el libro no proporciona implementaciones concretas, este patrón ha evolucionado en la práctica para adaptarse a distintos escenarios: tamaño del proyecto, necesidades de escalabilidad, concurrencia y mantenibilidad. A continuación, se exploran tres enfoques progresivos para diseñar repositorios eficientes.

  1. Implementación básica: Proyectos pequeños y corta vida útil

Para aplicaciones simples con bajo mantenimiento futuro, un diseño directo del repositorio puede ser suficiente. Se define una interfaz genérica que encapsula operaciones CRUD estándar:

public interface IGenericRepository<T> where T : class, new()
{
    T Insert(T entity);
    T Update(T entity);
    T GetById(Guid id);
    T FindOne(Expression<Func<T, bool>> filter);
    int Delete(Guid id);
    int BulkDelete(IEnumerable<Guid> ids);
    List<T> GetAll();
    List<T> GetPaginated(
        Expression<Func<T, bool>> filter,
        Expression<Func<T, object>> orderBy,
        SortDirection direction,
        int offset,
        int limit,
        out int totalCount);
}

La implementación utiliza Entity Framework con contexto por operación, garantizando simplicidad:

public class BasicRepository<T> : IGenericRepository<T> where T : class, new()
{
    private readonly string _connectionString;

    public BasicRepository(IDbConnectionFactory connectionFactory)
    {
        _connectionString = connectionFactory.GetConnectionString();
    }

    public T Insert(T entity)
    {
        using var context = new ApplicationDbContext(_connectionString);
        context.Set<T>().Add(entity);
        return context.SaveChanges() > 0 ? entity : null;
    }

    public T Update(T entity)
    {
        using var context = new ApplicationDbContext(_connectionString);
        context.Entry(entity).State = EntityState.Modified;
        return context.SaveChanges() > 0 ? entity : null;
    }

    public T GetById(Guid id)
    {
        using var context = new ApplicationDbContext(_connectionString);
        return context.Set<T>().Find(id);
    }

    public int Delete(Guid id)
    {
        using var context = new ApplicationDbContext(_connectionString);
        var entity = context.Set<T>().Find(id);
        if (entity == null) throw new ArgumentException("Entidad no encontrada");
        context.Entry(entity).State = EntityState.Deleted;
        return context.SaveChanges();
    }

    // Resto de métodos similares...
}

Este enfoque es adecuado para prototipos o sistemas con poco crecimiento esperado. Sin embargo, viola el principio abierto/cerrado, ya que cualquier nueva funcionalidad requiere modificar la interfaz base.

  1. Diseño extensible: Proyectos medianos o grandes

Cuando se anticipan cambios frecuentes o lógica de negocio especializada, convieen extender el repositorio mediante herencia e interfaces específicas. El truco clave es marcar todos los métodos como virtual para permitir la sobreescritura:

public class ExtendableRepository<T> : IGenericRepository<T> where T : class, new()
{
    protected ApplicationDbContext Context { get; }

    public ExtendableRepository(ApplicationDbContext context)
    {
        Context = context;
    }

    public virtual T Insert(T entity)
    {
        Context.Set<T>().Add(entity);
        Context.SaveChanges();
        return entity;
    }

    public virtual T GetById(Guid id)
    {
        return Context.Set<T>().Find(id);
    }

    // Todos los demás métodos declarados como 'virtual'
}

Luego, para una entidad como User, se crea una interfaz específica:

public interface IUserRepository : IGenericRepository<User>
{
    void BulkUpdateLastLogin(DateTime newDate);
    Task<List<User>> FindInactiveUsersAsync(int daysThreshold);
}

Y su implementación hereda del repositorio genérico:

public class UserRepository : ExtendableRepository<User>, IUserRepository
{
    public UserRepository(ApplicationDbContext context) : base(context) { }

    public void BulkUpdateLastLogin(DateTime newDate)
    {
        var users = Context.Users.Where(u => u.Name == "zhang").ToList();
        foreach (var user in users)
        {
            user.LastLogin = newDate;
            Context.Entry(user).State = EntityState.Modified;
        }
        Context.SaveChanges();
    }

    public async Task<List<User>> FindInactiveUsersAsync(int daysThreshold)
    {
        var cutoff = DateTime.UtcNow.AddDays(-daysThreshold);
        return await Context.Users.Where(u => u.LastLogin < cutoff).ToListAsync();
    }
}

En el servicio de negocio:

public class UserService
{
    private readonly IUserRepository _userRepository;

    public UserService(IUserRepository userRepository)
    {
        _userRepository = userRepository;
    }

    public void RegisterNewUser()
    {
        var user = new User { Id = Guid.NewGuid(), Name = "Alice" };
        _userRepository.Insert(user);
    }

    public void RefreshOldSessions() => _userRepository.BulkUpdateLastLogin(DateTime.UtcNow);
}

Aunque aumenta la cantidad de archivos, este modelo mejora notablemente la cohesión y facilita pruebas unitarias.

  1. Alto rendimiento y concurrencia: Repositorio asíncrono + Unidad de Trabajo

En sistemas con alta demanda de escritura o acceso concurrente, es crítico usar programación asincrónica y agrupar operaciones en una única tranascción. Aquí entra en juego el patrón Unit of Work.

Primero, se redefine el repositorio con soporte asíncrono:

public interface IAsyncRepository<T> where T : class, IAggregateRoot
{
    Task<T> GetByIdAsync(Guid id);
    Task<T> FindSingleAsync(Expression<Func<T, bool>> predicate);
    Task<IReadOnlyList<T>> FindAllAsync(Expression<Func<T, bool>> predicate);
    Task<IReadOnlyList<T>> GetPagedAsync(
        Expression<Func<T, bool>> filter,
        Expression<Func<T, object>> sort,
        SortDirection dir,
        int page,
        int size,
        out int count);
    
    void Add(T entity);
    void Update(T entity);
    void Remove(T entity);
}

El repositorio delega las operaciones de estado al contexto compartido:

public class AsyncRepository<T> : IAsyncRepository<T> where T : class, IAggregateRoot
{
    protected readonly ApplicationDbContext DbContext;

    public AsyncRepository(ApplicationDbContext dbContext)
    {
        DbContext = dbContext;
    }

    public async Task<T> GetByIdAsync(Guid id)
    {
        return await DbContext.Set<T>().FindAsync(id);
    }

    public void Add(T entity) => DbContext.Set<T>().Add(entity);
    public void Update(T entity) => DbContext.Entry(entity).State = EntityState.Modified;
    public void Remove(T entity) => DbContext.Entry(entity).State = EntityState.Deleted;
}

La unidad de trabajo coordina todas las operaciones pendientes:

public interface IUnitOfWork : IAsyncDisposable
{
    IAsyncRepository<User> UserRepository { get; }
    IAsyncRepository<Order> OrderRepository { get; }
    Task SaveChangesAsync();
}

public class UnitOfWork : IUnitOfWork
{
    private readonly ApplicationDbContext _context;
    private IAsyncRepository<User> _userRepository;

    public UnitOfWork(ApplicationDbContext context)
    {
        _context = context;
    }

    public IAsyncRepository<User> UserRepository =>
        _userRepository ??= new AsyncRepository<User>(_context);

    public async Task SaveChangesAsync()
    {
        await _context.SaveChangesAsync();
    }

    public async ValueTask DisposeAsync()
    {
        await _context.DisposeAsync();
    }
}

Esta arquitectura permite acumular múltiples operaciones y ejecutarlas en una sola ronda contra la base de datos, reduciendo latencia y mejorando el manejo de concurrencia con mecanismos como SaveChangesAsync() y reintento ante conflictos.

Etiquetas: repository-pattern unit-of-work Entity-Framework CSharp async-programming

Publicado el 8-20 10:01