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

Introducción

En este artículo continuamos con la construcción de la creación de una aplicación web basada en CRUDs para gestionar los torneos de baloncesto de la Comunidad de Madrid.

En este caso, seguiremos con la capa de aplicación, que es donde reside todo lo necesario para procesar las peticiones que nos llegan a través de la API.

Peticiones (Commands y Queries)

Lo primero que nos vamos a encontrar en la capa de aplicación son los mensajes que los usuarios de la API deben mandar para realizar acciones en nuestra aplicación. Estos mensajes, como estamos utilizando CQRS, se dividen en dos tipos: commands (acciones POST, PUT y DELETE) y queries (acciones GET).

Para gestionar qué peticiones van a qué handlers (concepto que veremos más adelante en este artículo), vamos a utilizar el patrón Mediator y, como implementación del mismo, la librería MediatR, que es muy típica para implementar CQRS en .NET. Es cierto que hay detractores de MediatR, porque, por ejemplo, no distingue entre commands y requests, pero sirve de sobra para nuestro caso de uso y está muy extendida.

Commands

Vamos a empezar con los commands. Podemos entenderlos como el cuerpo JSON que nos van a mandar para realizar las diferentes acciones que impliquen escrituras en la base de datos.

Al ser puramente los datos que nos pueden enviar mediante JSON, normalmente solo vamos a querer tipos primitivos, strings, objetos de tipo DateTime, enumerados y Guid.

Crearemos el command para poder crear jugadores, por ejemplo:

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

namespace BasketballTournaments.Application.Players.Commands;

public sealed class CreatePlayerCommand : IRequest<Result<PlayerDto>>
{
    public string IdNumber { get; }

    public string Name { get; }

    public string Surname { get; }

    public int HeightInCentimeters { get; }

    public double WeightInKilograms { get; }

    public Position Position { get; }

    public Guid TeamId { get; }

    public CreatePlayerCommand(string idNumber, string name, string surname, int heightInCentimeters, double weightInKilograms, Position position, Guid teamId)
    {
        IdNumber = idNumber;
        Name = name;
        Surname = surname;
        HeightInCentimeters = heightInCentimeters;
        WeightInKilograms = weightInKilograms;
        Position = position;
        TeamId = teamId;
    }
}

Como vemos, es una clase que solo tiene datos y que implementa IRequest<TResult>, por lo cual es bastante auto explicativa. Le necesitamos pasar todos los datos que necesitemos para crear un jugador, en este caso, como todos son obligatorios, pues le pasamos todos (menos el ID que obviamente se auto-genera).

En este caso, el tipo del resultado va a ser Result<PlayerDto>, porque si todo va bien vamos a devolver el item recién creado (ver la sección de los DTO) y si no, vamos a devolver un error.

Queries

Para obtener los diferentes objetos desde la base de datos, transformados a sus respectivos DTOs, vamos a utilizar el concepto de Query. En realidad, para MediatR, es lo mismo que un Command, por lo que se implementa de forma muy similar. Aquí vamos a implementar el obtener el detalle de un jugador en concreto pasándole el ID. En el capítulo posterior veremos cómo hacer queries más complejas, pero quería dejar esto lo más sencillo posible para comenzar.

Creamos el fichero GetPlayerByIdQuery.cs:

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

namespace BasketballTournaments.Application.Players.Queries;

public sealed class GetPlayerByIdQuery : IRequest<Result<PlayerDto>>
{
    public Guid Id { get; }

    public GetPlayerByIdQuery(Guid id)
    {
        Id = id;
    }
}

Poco misterio, ¿verdad? Simplemente le pasamos el ID y lo importante es que implemente IRequest<TResult>.

Data transfer objects (DTO)

Llamamos DTO a las respuestas que va a enviar nuestra API cuando le hagamos peticiones. Como en el caso anterior, como esto no son más que cuerpos JSON, vamos a emplear los mismos tipos de los que hablamos antes. Por ejemplo, para representar el DTO de la clase Player:

using BasketballTournaments.Domain.Players;

namespace BasketballTournaments.Application.Players.DTO;

public sealed class PlayerDto
{
    public Guid Id { get; }

    public string IdNumber { get; }

    public string Name { get; }

    public string Surname { get; }

    public int HeightInCentimeters { get; }

    public double WeightInKilograms { get; }

    public Position Position { get; }

    public Guid TeamId { get; }

    public PlayerDto(Guid id, string idNumber, string name, string surname, int heightInCentimeters, double weightInKilograms, Position position, Guid teamId)
    {
        Id = id;
        IdNumber = idNumber;
        Name = name;
        Surname = surname;
        HeightInCentimeters = heightInCentimeters;
        WeightInKilograms = weightInKilograms;
        Position = position;
        TeamId = teamId;
    }
}

En este caso sí que incluimos el ID porque vamos a querer devolverlo en las peticiones. Como podemos ver, la clase es muy sencilla y no es más que una clase de datos (incluso podría ser un Record).

Validators

Como aquí estamos definiendo los objetos que van a entrar a nuestro sistema, como cualquier input del usuario tendríamos que validarlo. Para ello, como no queremos reinventar la rueda nuevamente, yo utilizo la librería FluentValidation. Vamos a implementar las validaciones de los commands y queries que hemos desarrollado en este artículo para ver un ejemplo práctico.

En primer lugar, implementamos CreatePlayerCommandValidator.cs:

using BasketballTournaments.Application.Players.Commands;
using BasketballTournaments.Domain.Players;
using FluentResults;
using FluentValidation;

namespace BasketballTournaments.Application.Players.Validators;

public sealed class CreatePlayerCommandValidator : AbstractValidator<CreatePlayerCommand>
{
    public CreatePlayerCommandValidator()
    {
        RuleFor(command => command.IdNumber)
            .NotEmpty()
            .Must(idNumber =>
            {
                Result<SpanishId> conversionResult = SpanishId.FromString(idNumber);
                return conversionResult.IsSuccess;
            });

        RuleFor(command => command.Name)
            .NotEmpty()
            .MaximumLength(Player.MaxLengthName);

        RuleFor(command => command.Surname)
            .NotEmpty()
            .MaximumLength(Player.MaxLengthSurname);

        RuleFor(command => command.HeightInCentimeters)
            .NotNull()
            .GreaterThan(0);

        RuleFor(command => command.WeightInKilograms)
            .NotNull()
            .GreaterThan(0.0);

        RuleFor(command => command.Position)
            .NotNull()
            .IsInEnum();

        RuleFor(command => command.TeamId)
            .NotEmpty();
    }
}

Realmente habría muchísimo que explicar de esta librería, pero básicamente lo más importante es saber la distinción entre NotNull y NotEmpty, y que podemos crear reglas custom con Must. Para más información, lo mejor sería directamente leer la documentación de la librería, que es bastante extensa.

Implementemos también el validador para el GetPlayerByIdQuery, que será mucho más sencillo:

using BasketballTournaments.Application.Players.Queries;
using FluentValidation;

namespace BasketballTournaments.Application.Players.Validators;

public sealed class GetPlayerByIdQueryValidator : AbstractValidator<GetPlayerByIdQuery>
{
    public GetPlayerByIdQueryValidator()
    {
        RuleFor(query => query.Id)
            .NotEmpty();
    }
}

Handlers

Por último, vamos a la chicha, donde realmente se hacen las operaciones y el centro de la "lógica" de nuestras operaciones, o como se suele llamar, nuestros casos de uso. Estos se representan mediante los Handlers y heredan en el caso de MediatR de la clase IRequestHandler<TRequest, TResult>.

También vamos a introducir en esta parte otra librería que me gusta utilizar que es Automapper. Básicamente, esta librería lo que hace es que no tengamos que crear a mano los DTO (lo cual además no debería ser responsabilidad del handler si atendemos al SRP), lo cual está bastante bien porque así, si cambiamos el DTO o la entidad, no tenemos que ir a todos los sitios donde se utilice cambiándolo a mano.

Empecemos por el GetPlayerByIdHandler.cs que será más sencillo:

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

namespace BasketballTournaments.Application.Players.Handlers;

public sealed class GetPlayerByIdHandler : IRequestHandler<GetPlayerByIdQuery, Result<PlayerDto>>
{
    private readonly IPlayersReadRepository _repository;
    private readonly IMapper _mapper;

    public GetPlayerByIdHandler(IPlayersReadRepository repository, IMapper mapper)
    {
        _repository = repository;
        _mapper = mapper;
    }

    public async Task<Result<PlayerDto>> Handle(GetPlayerByIdQuery request, CancellationToken cancellationToken)
    {
        Result<Player> queryResult = await _repository.GetById(request.Id, cancellationToken);
        if (queryResult.IsFailed)
        {
            return Result.Fail(new ItemNotFoundError());
        }

        Player player = queryResult.Value;
        PlayerDto dto = _mapper.Map<PlayerDto>(player);
        return dto;
    }
}

Si nos fijamos, no tiene mucha historia. Intenta obtener el objeto Player con el ID que se le pasa por la request, si no lo encuentra lanza un error (que implementaremos más abajo) y si lo encuentra retorna un PlayerDto.

Vamos a implementar el handler de creación de jugadores:

using AutoMapper;
using BasketballTournaments.Application.Players.Commands;
using BasketballTournaments.Application.Players.DTO;
using BasketballTournaments.Domain.Players;
using BasketballTournaments.Infrastructure.Players.Repositories;
using FluentResults;
using MediatR;

namespace BasketballTournaments.Application.Players.Handlers;

public sealed class CreatePlayerHandler : IRequestHandler<CreatePlayerCommand, Result<PlayerDto>>
{
    private readonly IPlayersWriteRepository _repository;
    private readonly IMapper _mapper;

    public CreatePlayerHandler(IPlayersWriteRepository repository, IMapper mapper)
    {
        _repository = repository;
        _mapper = mapper;
    }

    public async Task<Result<PlayerDto>> Handle(CreatePlayerCommand request, CancellationToken cancellationToken)
    {
        Result<Player> creationResult = Player.Create(
            idNumber: SpanishId.FromString(request.IdNumber.Trim()).Value,
            name: request.Name.Trim(),
            surname: request.Surname.Trim(),
            heightInCentimeters: request.HeightInCentimeters,
            weightInKilograms: request.WeightInKilograms,
            position: request.Position,
            teamId: request.TeamId
        );

        if (creationResult.IsFailed)
        {
            return Result.Fail(new InvalidArgumentError());
        }

        Player itemToInsert = creationResult.Value;
        await _repository.Insert(itemToInsert, cancellationToken);
        PlayerDto dto = _mapper.Map<PlayerDto>(itemToInsert);
        return Result.Ok(dto);

    }
}

Lo más destacable de aquí son los Trim() (y otras operaciones que pudiéramos necesitar hacer antes de persistir los datos, como normalizar strings). En general yo no miro que el SpanishId por ejemplo se pueda crear correctamente, porque ese es trabajo del AbstractValidator que habíamos implementado antes.

Anexo I: Profile (para Automapper)

Para que Automapper pueda funcionar, necesita un pequeño archivo en la capa de aplicación llamado Profiles. También necesita que los nombres sean iguales en el dominio y en el DTO (aunque se puede hacer si tienen nombre distinto, pero requiere de configuración extra) y algo más en el Program.cs (que veremos más tarde cuando lleguemos a la API).

Creamos el archivo AutomapperProfile.cs en una carpeta Shared dentro de la capa de aplicación:

using AutoMapper;
using BasketballTournaments.Application.Players.DTO;
using BasketballTournaments.Domain.Players;

namespace BasketballTournaments.Application.Shared.Profiles;

public sealed class AutomapperProfile : Profile
{
    public AutomapperProfile()
    {
        CreateMap<Player, PlayerDto>();
    }
}

De momento aquí no tenemos mucha cosa, pero tenemos opciones interesantes como ignorar propiedades del archivo source, incluir value-objects relacionados, realizar transformaciones de datos (por ejemplo, hacer que los NULL de un campo opcional en dominio sean strings vacíos)...

Lo mejor, como siempre, es mirar directamente la documentación de Automapper.

Anexo II: Implementación de los errores

Si has seguido todos los pasos, tendrás errores de compilación porque te faltarán los errores que estábamos devolviendo. Lo único importante es que hereden de IError. En mi caso, tienen esta pinta, pero se pueden configurar mucho más según necesidades (lo mejor en este caso sería ver la documentación de FluentResults).

using FluentResults;

namespace BasketballTournaments.Application.Shared.Errors;

public sealed class ItemNotFoundError : IError
{
    public List<IError> Reasons => new List<IError>();

    public string Message => "Item was not found.";

    public Dictionary<string, object> Metadata => new Dictionary<string, object>();
}
using FluentResults;

namespace BasketballTournaments.Application.Shared.Errors;

public sealed class InvalidArgumentError : IError
{
    public List<IError> Reasons => new List<IError>();

    public string Message => "One or more arguments provided were not valid.";

    public Dictionary<string, object> Metadata => new Dictionary<string, object>();
}

Conclusiones

Con este pequeño artículo hemos aprendido lo básico de la capa de aplicación. En el siguiente artículo veremos cosas más avanzadas de esta capa, como por ejemplo utilizar el patrón Specification para generar un filtro por query (para poder pedir jugadores por equipo, por posición o por un rango de alturas, por ejemplo).