Published on

Как применять Clean Architecture в ASP.NET CORE - Application

Authors
  • avatar
    Name
    Николай Маракасов
    Twitter
    @Twitter

В прошлой статье я описал, из каких слоев состоит приложение и как направлены зависимости. Сейчас мы детальнее рассмотрим прикладной уровень.

Application

На этом уровне мы реализуем пользовательские сценарии. В нашем примере мы будем обрабатывать заказы — реализация исключительно учебная.

Пример структуры

Итак, для заказов я создаю папку Orders, внутри которой находятся наши сценарии использования, такие как создание заказа, завершение и запрос заказов по фильтрам. Для каждого сценария — своя директория. Цель — сгруппировать тесно связанный код вместе и изолировать его от других.

Для реализации этого уровня я использую библиотеку MediatR, которая помогает нам реализовать пользовательские сценарии на основе принципа единой ответственности (SRP), а также добиться принципа открытости/закрытости (OCP).

MediatR — простая реализация паттерна «Медиатор» в .NET.

Рассмотрим директорию Complete, которая реализует пользовательский сценарий завершения заказа. Внутри расположены три файла. Первый — CompleteOrderCommand, реализующий паттерн Parameter Object — это контейнер с данными, который отправляет клиент нашего приложения (также известный как DTO).

public record CompleteOrderCommand(Guid Id) : IRequest;

Далее идёт CompleteOrderCommandValidator — в этом классе сосредоточена логика валидации нашей команды. Для валидации я использую библиотеку FluentValidation.

public class CompleteOrderCommandValidator : AbstractValidator<CompleteOrderCommand>
{
    public CompleteOrderCommandValidator()
    {
        RuleFor(o => o.Id).NotEmpty();
    }
}

И, наконец, обработчик нашего сценария — CompleteOrderCommandHandler.

public sealed class CompleteOrderCommandHandler(IOrderRepository repository) : IRequestHandler<CompleteOrderCommand>
{
    public async Task Handle(CompleteOrderCommand request, CancellationToken cancellationToken)
    {
        var order = await repository.GetById(request.Id, cancellationToken);
        
        if (order is null)
            throw new OrderNotFoundException($"Order not found, Id: {request.Id}");

        order.Complete();
        
        await repository.Update(order, cancellationToken);
    }
}

В обработчике мы используем репозиторий для извлечения заказа из хранилища. Затем вызываем метод у объекта order, переводя его в статус «завершён», и сохраняем изменения в хранилище.

Как можно заметить, мы не выполняем валидацию в обработчике. Следуя принципу OCP, мы создаём декоратор (в MediatR он называется behavior). Этот декоратор оборачивает все пользовательские сценарии, реализуя сквозную функциональность (Cross-cutting concerns).

public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
    {
        if (_validators.Any())
        {
            var context = new ValidationContext<TRequest>(request);
            var validationResults = await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
            var failures = validationResults.SelectMany(r => r.Errors).Where(f => f != null).ToList();

            if (failures.Count != 0)
            {
                throw new ValidationException(failures);
            }
        }

        return await next();
    }
}

Регистрация в DI:

builder.Services.AddValidatorsFromAssemblyContaining<CompleteOrderCommand>();
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssemblyContaining<CompleteOrderCommand>();
    cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});

Аналогичным образом можно реализовать логирование, замеры производительности, транзакции и другие аспекты.

Рассмотрим сценарий, когда мы возвращаем данные клиенту — GetByFilter.

public record GetOrdersByFilterQuery(int Status, int Take, int Skip) : IRequest<GetOrdersByFilterResponse>;

Для возврата данных нам необходимо определить возвращаемый тип:

public sealed class GetOrdersByFilterResponse
{
    public IEnumerable<OrderDto> Orders { get; set; }
    public int Count { get; set; }
    public sealed record OrderDto(Guid Id, int Status);
}

Теперь обработчик:

public sealed class GetOrdersByFilterQueryHandler(IOrderReadRepository orderReadRepository) : IRequestHandler<GetOrdersByFilterQuery, GetOrdersByFilterResponse>
{
    public Task<GetOrdersByFilterResponse> Handle(GetOrdersByFilterQuery request, CancellationToken cancellationToken)
    {
        return orderReadRepository.GetByFilter(request, cancellationToken);
    }
}

Так как слой Application не зависит от фреймворков и доступа к данным, мы используем абстракции. Это может показаться избыточным, но таким образом мы не допускаем проникновения инфраструктуры на этот уровень.

В архитектуре приложения мы используем CQRS, но также применяем CQS для разделения уровня доступа к данным, разделяя репозитории для команд и запросов.

Для GetByFilter я использую отдельный репозиторий IOrderReadRepository. В отличие от репозитория для команд, он чаще подвергается изменениям и использует DTO вместо доменных объектов. Интерфейс располагается в папке Orders, тогда как репозитории для команд находятся в доменном слое. Реализация этих интерфейсов находится в инфраструктуре.

Отойдём от пользовательских сценариев и рассмотрим интерфейс IUserService. Он предоставляет информацию о пользователе, который выполняет команду.

public interface IUserService
{
    public string Id { get; }
    public string Name { get; }
}

Этот интерфейс достаточно прост — он возвращает идентификатор и имя текущего пользователя. Основной вопрос — где должна находиться его реализация? В отличие от предыдущих интерфейсов, находящихся в инфраструктуре, IUserService извлекает информацию из контекста запроса, поэтому его реализацию следует размещать в API-слое.

Заключение

В этой статье я рассмотрел основной подход к структурированию прикладного уровня с использованием библиотеки MediatR для разделения логики приложения на пользовательские сценарии и FluentValidation для валидации. Такая организация кода даёт ряд преимуществ:

  • структура проекта сразу показывает, о чём наше приложение,
  • изменение одного сценария не влияет на другие (SRP),
  • возможность реализации сквозной функциональности через декораторы,
  • разделение технологий для чтения и записи данных,
  • улучшенная тестируемость.

В следующей статье мы рассмотрим доменный слой.