- Published on
Как применять Clean Architecture в ASP.NET CORE - Application
- Authors
- Name
- Николай Маракасов
В прошлой статье я описал, из каких слоев состоит приложение и как направлены зависимости. Сейчас мы детальнее рассмотрим прикладной уровень.
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),
- возможность реализации сквозной функциональности через декораторы,
- разделение технологий для чтения и записи данных,
- улучшенная тестируемость.
В следующей статье мы рассмотрим доменный слой.