Clean Architecture en una aplicación web en .NET basada en CRUDs (V): La capa de aplicación (II)

Introducción

En este artículo vamos a ver algunos temas más avanzados de la capa de aplicación, como el filtrado mediante query parameters a la hora de ejecutar una query para pedir alguna entidad de nuestro sistema. Para ello, vamos a implementar el patrón Specification. La motivación de este patrón, los problemas relacionados, etc., se pueden leer en multitud de artículos, como este.

Básicamente, imaginemos que queremos pedir los jugadores por su DNI. Sería tan fácil como implementar un método GetBySpanishId en el repositorio, ¿no? Pero, ¿qué pasaría si además queremos pedir los jugadores por posición? ¿Implementamos también un método GetByPosition? ¿Y si quisiéramos combinarlos? ¿Necesitaríamos un métrodo GetBySpanishIdAndPosition y otro GetBySpanishIdOrPosition?

Podemos ver como claramente esto escala fatal, por lo que necesitamos una forma genérica de pedir todos. También podríamos por supuesto llamar al método GetAll y ejecutar el filtrado sobre todos, pero esto es muy ineficiente, evidentemente.

Por todo ello y mucho más (si es algo que realmente te interesa, el artículo citado antes explica claramente muchos de los problemas), vamos a implementar este patrón para simplificar la vida.

El patrón Specification

Primera implementación

A simple vista, lo que necesitamos es algo (una clase) que nos permita generar expresiones de LINQ complejas, que es básicamente lo que le vamos a pasar al repositorio para que acceda mediante Entity Framework a la base de datos y se traiga los objetos que necesitamos. También podría ser que en algún momento quisiéramos comprobar si una entidad concreta cumple con esas condiciones.

Para ello, implementamos la siguiente interfaz:

using System.Linq.Expressions;

namespace BasketballTournaments.SeedWork;

public interface ISpecification<T>
{
    Expression<Func<T, bool>> ToExpression();

    bool IsSatisfiedBy(T entity);
}

Y podemos realizar una primera aproximación de cómo se implementaría esto:

using System.Linq.Expressions;

namespace BasketballTournaments.SeedWork;

public abstract class Specification<T> : ISpecification<T>
{
    public abstract Expression<Func<T, bool>> ToExpression();

    public bool IsSatisfiedBy(T entity)
    {
        Func<T, bool> predicate = ToExpression().Compile();
        return predicate(entity);
    }
}

Muy bien, ya tenemos ambas cosas. Pero si nos fijamos, el método ToExpression, que es el que realmente hace la magia, es abstracto, lo cuál significa que cada Specification concreta que generemos será la encargada de implementar ese método. Es en este método donde escribiríamos la lambda de LINQ.

Esto sin embargo todavía tiene un problema, y es que no podemos combinar de ninguna forma las especificaciones, por lo que si queremos múltiples condiciones tendríamos que implementar una especificación que las agrupe (y sigue sin escalar bien del todo).

Vamos a ver cómo lo arreglamos.

Combinando especificaciones

Necesitamos, así a simple vista, poder combinar especificaciones con operadores lógicos And y Or. Para ello, vamos a utilizar ExpressionVisitor y un poco de magia.

using System.Linq.Expressions;

namespace BasketballTournaments.SeedWork;

public class AndSpecification<T> : Specification<T>
{
    private readonly Specification<T> _left;
    private readonly Specification<T> _right;

    public AndSpecification(Specification<T> left, Specification<T> right)
    {
        _left = left;
        _right = right;
    }

    public override Expression<Func<T, bool>> ToExpression()
    {
        Expression<Func<T, bool>> leftExpression = _left.ToExpression();
        Expression<Func<T, bool>> rightExpression = _right.ToExpression();

        var visitor = new SwapVisitor(leftExpression.Parameters[0], rightExpression.Parameters[0]);
        var binaryExpression = Expression.AndAlso(visitor.Visit(leftExpression.Body), rightExpression.Body);
        var lambda = Expression.Lambda<Func<T, bool>>(binaryExpression, rightExpression.Parameters);
        return lambda;
    }
}
using System.Linq.Expressions;

namespace BasketballTournaments.SeedWork;

public class OrSpecification<T> : Specification<T>
{
    private readonly Specification<T> _left;
    private readonly Specification<T> _right;

    public OrSpecification(Specification<T> left, Specification<T> right)
    {
        _left = left;
        _right = right;
    }

    public override Expression<Func<T, bool>> ToExpression()
    {
        Expression<Func<T, bool>> leftExpression = _left.ToExpression();
        Expression<Func<T, bool>> rightExpression = _right.ToExpression();

        SwapVisitor visitor = new(leftExpression.Parameters[0], rightExpression.Parameters[0]);
        BinaryExpression? binaryExpression = Expression.OrElse(visitor.Visit(leftExpression.Body), rightExpression.Body);
        Expression<Func<T, bool>>? lambda = Expression.Lambda<Func<T, bool>>(binaryExpression, rightExpression.Parameters);
        return lambda;
    }
}

La pieza que nos falta y que hace la magia es este SwapVisitor:

using System.Linq.Expressions;

namespace BasketballTournaments.SeedWork;

public class SwapVisitor : ExpressionVisitor
{
    private readonly Expression from, to;

    public SwapVisitor(Expression from, Expression to)
    {
        this.from = from;
        this.to = to;
    }

    public override Expression Visit(Expression? node)
    {
        if (node is null)
        {
            throw new ArgumentNullException(nameof(node));
        }

        return node == from ? to : base.Visit(node);
    }
}

Con estas tres clases, ya podemos unir especificaciones, por lo que solucionamos el problema de la escalabilidad.

Modificamos la clase Specificacion<T> para tener los métodos And y Or:

using System.Linq.Expressions;

namespace BasketballTournaments.SeedWork;

public abstract class Specification<T> : ISpecification<T>
{
    public abstract Expression<Func<T, bool>> ToExpression();

    public bool IsSatisfiedBy(T entity)
    {
        Func<T, bool> predicate = ToExpression().Compile();
        return predicate(entity);
    }

    public Specification<T> And(Specification<T> specification)
    {
        return new AndSpecification<T>(this, specification);
    }

    public Specification<T> Or(Specification<T> specification)
    {
        return new OrSpecification<T>(this, specification);
    }
}

Especificaciones vacías

Podríamos en algún momento querer tener una especificación vacía (es decir, que devuelva todos los elementos de la base de datos), para luego, por ejemplo, condicionalmente usar And y Or para construir la expresión final.

La implementación es bastante trivial:

using System.Linq.Expressions;

namespace BasketballTournaments.SeedWork;

public sealed class EmptySpecification<T> : Specification<T>
{
    public override Expression<Func<T, bool>> ToExpression()
    {
        return (T) => true;
    }
}

Implementado las especificaciones concretas

En este caso, vamos a querer poder filtrar a los jugadores por el equipo donde juegan y por su DNI (para ver el ejemplo con un tipo que hayamos creado nosotros mismos y que, por tanto, requiera conversión).

Para ello, creamos un nuevo archivo en la capa de dominio llamado Player.Specifications.cs (igual que habíamos creado el Player.Constraints.cs como una partial class).

Esta nueva partial class quedaría así:

using System.Linq.Expressions;
using BasketballTournaments.SeedWork;

namespace BasketballTournaments.Domain.Players;

public sealed partial class Player
{
    public static SpanishIdSpecification BySpanishId(SpanishId playerId)
    {
        return new SpanishIdSpecification(playerId);
    }

    public static TeamSpecification ByTeam(Guid teamId)
    {
        return new TeamSpecification(teamId);
    }

    public class SpanishIdSpecification : Specification<Player>
    {
        private readonly SpanishId _playerId;

        internal SpanishIdSpecification(SpanishId playerId)
        {
            _playerId = playerId;
        }

        public override Expression<Func<Player, bool>> ToExpression()
        {
            return player => player.IdNumber.Equals(_playerId);
        }
    }

    public class TeamSpecification : Specification<Player>
    {
        private readonly Guid _teamId;

        internal TeamSpecification(Guid teamId)
        {
            _teamId = teamId;
        }

        public override Expression<Func<Player, bool>> ToExpression()
        {
            return player => player.TeamId == _teamId;
        }
    }
}

Con esto, solo permitimos que nos creen estas especificaciones llamando a los métodos Player.BySpanishId y Player.ByTeam por lo que ganamos la encapsulación. Más adelante veremos cómo se utilizan para crear filtros.

Hacer que el repositorio acepte consultas mediante especificaciones

Una vez ya tenemos todo lo de la especificación montado, el siguiente paso será modificar el repositorio para añadir un método que acepte una Specification y devuelva los elementos que coincidan con esa definición.

Vamos a meterlo solo en el repositorio de lectura, puesto que, de momento, para editar o borrar entidades solo nos vamos a referir a ellas por el ID.

La interfaz quedaría así:

using BasketballTournaments.SeedWork;
using FluentResults;

namespace BasketballTournaments.Infrastructure.Shared;

public interface IGenericReadRepository<T> where T : class
{
    public Task<IEnumerable<T>> GetAll(CancellationToken cancellationToken);

    public Task<Result<T>> GetById(object id, CancellationToken cancellationToken);

    public Task<IEnumerable<T>> Get(Specification<T> specification, CancellationToken cancellationToken);
}

Y la implementación:

using BasketballTournaments.Infrastructure.Data;
using BasketballTournaments.Infrastructure.Shared.Errors;
using BasketballTournaments.SeedWork;
using FluentResults;
using Microsoft.EntityFrameworkCore;

namespace BasketballTournaments.Infrastructure.Shared;

public class GenericReadRepository<T> : IGenericReadRepository<T> where T : class
{
    private readonly DbSet<T> _dbSet;

    public GenericReadRepository(ApplicationDbContext context)
    {
        _dbSet = context.Set<T>();
    }

    public async virtual Task<IEnumerable<T>> GetAll(CancellationToken cancellationToken)
    {
        return await _dbSet.AsNoTracking().ToListAsync(cancellationToken: cancellationToken);
    }

    public async virtual Task<Result<T>> GetById(object id, CancellationToken cancellationToken)
    {
        T? result = await _dbSet.FindAsync(new object?[] { id }, cancellationToken: cancellationToken);
        if (result is null)
        {
            return Result.Fail(new EntityNotFoundError());
        }

        return Result.Ok(result);
    }

    public async virtual Task<IEnumerable<T>> Get(Specification<T> specification, CancellationToken cancellationToken)
    {
        return await _dbSet.AsNoTracking().Where(specification.ToExpression()).ToListAsync(cancellationToken: cancellationToken);
    }
}

Implementación de los filtros

Como explicamos antes, vamos a suponer que queremos poder filtrar por dos cosas: por DNI (SpanishId) y por equipo. Por ello, en el ejemplo vamos a explicar cómo filtrar por tipos custom que hemos creado nosotros y por tipos "primitivos" (aunque Guid no sea exactamente un tipo primitivo, para este caso se comporta como uno).

En primer lugar, implementamos el filtro con todo lo que queremos poder pasarle por query y luego iremos resolviendo las dependencias.

using BasketballTournaments.Application.Shared.Converters;
using BasketballTournaments.Application.Shared.Queries;
using static BasketballTournaments.Domain.Players.Player;

namespace BasketballTournaments.Application.Players.Queries;

public sealed class PlayersFilter : IQueryStringFilter
{
    [FilterSpecification(typeof(SpanishIdSpecification), typeof(SpanishIdConverter))]
    public string? PlayerId { get; set; }

    [FilterSpecification(typeof(TeamSpecification))]
    public Guid? TeamId { get; set; }

    public PlayersFilter()
    {
        // Required because of .NET pipeline limitations
    }

    public PlayersFilter(string? playerId, Guid? teamId)
    {
        PlayerId = playerId;
        TeamId = teamId;
    }
}

Como podemos observar, esta clase no tiene mucho misterio, solo tiene los atributos del filtro. Sí que es importante observar que debe tener un constructor público vacío y los setters deben ser públicos para que funcione bien la pipeline de .NET.

Ahora nos faltan un par de cosas, la interfaz IQueryStringFilter para poder usarla luego como tipo genérico (aunque estará vacía):

public interface IQueryStringFilter
{
}

La clase SpanishIdConverter, que sirve para convertir de string (que es lo que nos entrará por la API) a SpanishId automáticamente en el filtro:

using BasketballTournaments.Application.Shared.Queries;
using BasketballTournaments.Domain.Players;
using FluentResults;

namespace BasketballTournaments.Application.Shared.Converters;

public sealed class SpanishIdConverter : ITypeConverter
{
    public bool CanConvertFrom(Type sourceType)
    {
        if (sourceType == typeof(string))
        {
            return true;
        }
        return false;
    }

    public object? ConvertFrom(object value)
    {
        if (value is string valueAsString)
        {
            Result<SpanishId> convertedValue = SpanishId.FromString(valueAsString);
            SpanishId? returnValue = convertedValue.ValueOrDefault;
            return returnValue;
        }
        return null;
    }
}

Esta, a su vez, hace que necesitemos la interfaz ITypeConverter...

namespace BasketballTournaments.Application.Shared.Queries;

public interface ITypeConverter
{
    bool CanConvertFrom(Type sourceType);
    object? ConvertFrom(object value);
}

Y ya solo nos faltaría el atributo:

namespace BasketballTournaments.Application.Shared.Queries;

[AttributeUsage(AttributeTargets.Property)]
public sealed class FilterSpecificationAttribute : Attribute
{
    public Type Specification { get; }
    public Type? ConversionType { get; private set; }

    public FilterSpecificationAttribute(Type specification)
    {
        Specification = specification;
    }

    public FilterSpecificationAttribute(Type specification, Type conversionType)
    {
        Specification = specification;
        ConversionType = conversionType;
    }
}

Con esto tendríamos (casi) todo lo necesario para implementar queries con filtrado opcional. Con esto de hecho podríamos ir directamente e implementar una query y un handler, el problema es que tendríamos que crear las especificaciones a mano y no escala bien (con tres parámetros que pueden venir null o con valor, ¿cuántas especificaciones posibles podríamos construir?), por lo que necesitamos una última helper class para transformar de un filtro a una especificación:

La interfaz (para poder luego mockearla):

using BasketballTournaments.SeedWork;

namespace BasketballTournaments.Application.Shared.Queries;

public interface IFilterService
{
    Specification<T> CreateSpecificationFromFilters<T>(IQueryStringFilter? filters);
}

Y la implementación concreta:

using System.Reflection;
using BasketballTournaments.SeedWork;

namespace BasketballTournaments.Application.Shared.Queries;

public class FilterService : IFilterService
{
    public Specification<T> CreateSpecificationFromFilters<T>(IQueryStringFilter? filters)
    {
        if (filters is null)
        {
            return new EmptySpecification<T>();
        }

        PropertyInfo[] properties = filters.GetType().GetProperties() ?? Array.Empty<PropertyInfo>();
        Specification<T>? specifications = null;

        foreach (PropertyInfo property in properties)
        {
            FilterSpecificationAttribute? attribute = (FilterSpecificationAttribute?)Attribute.GetCustomAttribute(property, typeof(FilterSpecificationAttribute));
            if (attribute is null)
            {
                continue;
            }

            var value = property.GetValue(filters);
            if (value is null)
            {
                continue;
            }

            var valueConverted = ApplyConversion(property.GetValue(filters), attribute.ConversionType);
            var specification = (Specification<T>?)Activator.CreateInstance(attribute.Specification, valueConverted);
            if (specification is not null)
            {
                specifications = specifications is null ? specification : specifications.And(specification);
            }
        }

        return specifications ?? new EmptySpecification<T>();
    }

    private static object? ApplyConversion(object? value, Type? conversionTypeConverter)
    {
        if (conversionTypeConverter is null || value is null)
        {
            return value;
        }

        if (conversionTypeConverter.IsAssignableTo(typeof(ITypeConverter)))
        {
            var converter = (ITypeConverter?)Activator.CreateInstance(conversionTypeConverter!);

            return converter is not null && converter!.CanConvertFrom(value.GetType()) ? converter.ConvertFrom(value) : value;
        }

        return value;
    }
}

Con esto ahora sí, podríamos implementar la query y el handler.

Implementación de Query y Handler para obtener los jugadores filtrados

Una vez tenemos todo listo, podemos implementar una query y un handler que haga uso de todo esto para obtener los jugadores filtrados por DNI y/o equipo.

Por un lado, la query quedará así:

using BasketballTournaments.Application.Players.DTO;
using MediatR;

namespace BasketballTournaments.Application.Players.Queries;

public sealed class GetFilteredPlayerQuery : IRequest<IEnumerable<PlayerDto>>
{
    public PlayersFilter? Filter { get; set; }

    public GetFilteredPlayerQuery()
    {
        // This empty constructor is required to use [FromQuery]
        // If we do not use it [FromQuery], validator will not be fired
        // See: https://github.com/dotnet/aspnetcore/issues/9781
    }

    public GetFilteredPlayerQuery(PlayersFilter? filter)
    {
        Filter = filter;
    }
}

Es importante que el setter sea público, el filtro sea nullable, y que haya un constructor vacío para que funcione la pipeline de .NET. Esto se puede ver en la issue que aparece en el comentario.

Solamente nos queda ver como sería el handler, y es muy sencillo.

using AutoMapper;
using BasketballTournaments.Application.Players.DTO;
using BasketballTournaments.Application.Players.Queries;
using BasketballTournaments.Application.Shared.Queries;
using BasketballTournaments.Domain.Players;
using BasketballTournaments.Infrastructure.Players.Repositories;
using BasketballTournaments.SeedWork;
using MediatR;

namespace BasketballTournaments.Application.Players.Handlers;

public sealed class GetFilteredPlayerHandler : IRequestHandler<GetFilteredPlayerQuery, IEnumerable<PlayerDto>>
{
    private readonly IPlayersReadRepository _repository;
    private readonly IFilterService _filterService;
    private readonly IMapper _mapper;

    public GetFilteredPlayerHandler(IPlayersReadRepository repository, IFilterService filterService, IMapper mapper)
    {
        _repository = repository;
        _filterService = filterService;
        _mapper = mapper;
    }


    public async Task<IEnumerable<PlayerDto>> Handle(GetFilteredPlayerQuery request, CancellationToken cancellationToken)
    {
        Specification<Player> specification = _filterService.CreateSpecificationFromFilters<Player>(request.Filter);
        IEnumerable<Player> queryResult = await _repository.Get(specification, cancellationToken);
        IEnumerable<PlayerDto> returnResult = queryResult.Select(entity => _mapper.Map<PlayerDto>(entity));
        return returnResult;
    }
}

¡Ya está! El handler solamente tendría esas cuatro cosas. En el siguiente artículo veremos cómo implementar los controladores, que son realmente fáciles.