MidТеория3 min

Clean Architecture в .NET

Чистая архитектура Роберта Мартина: независимость слоёв, правило зависимостей, четыре слоя (Domain, Application, Infrastructure, API), CQRS и Pipeline Behaviors

Clean Architecture (Чистая архитектура) -- подход Роберта Мартина, при котором бизнес-логика изолирована от фреймворков, баз данных и пользовательского интерфейса. Главная идея: зависимости направлены внутрь -- внешние слои зависят от внутренних, но не наоборот.

Концепция слоёв

┌──────────────────────────────────────┐
│          Frameworks & Drivers        │  <- API, EF Core, Azure SDK
│  ┌──────────────────────────────┐    │
│  │     Interface Adapters       │    │  <- Controllers, Repository impl
│  │  ┌──────────────────────┐    │    │
│  │  │   Application Logic  │    │    │  <- Use Cases, Commands, Queries
│  │  │  ┌──────────────┐    │    │    │
│  │  │  │   Domain      │    │    │    │  <- Entities, Value Objects
│  │  │  └──────────────┘    │    │    │
│  │  └──────────────────────┘    │    │
│  └──────────────────────────────┘    │
└──────────────────────────────────────┘

Внутренние слои ничего не знают о внешних. Domain не знает, что данные хранятся в SQL Server. Application не знает, что запрос пришёл по HTTP. Это делает ядро системы независимым и легко тестируемым.

Четыре слоя в проекте

1. Domain Layer -- ядро

Содержит бизнес-правила, Entity, Value Objects, Aggregates, Domain Events, интерфейсы репозиториев и исключения домена. Не содержит ссылок на EF Core, ASP.NET, Azure SDK или MediatR (за исключением интерфейса INotification для событий).

OrderManagement.Domain/
├── Common/
│   ├── Entity.cs
│   ├── ValueObject.cs
│   ├── IAggregateRoot.cs
│   └── IDomainEvent.cs
├── Entities/
│   ├── Order.cs
│   └── OrderItem.cs
├── ValueObjects/
│   ├── Money.cs
│   └── ShippingAddress.cs
├── Events/
│   ├── OrderCreatedEvent.cs
│   └── OrderConfirmedEvent.cs
├── Exceptions/
│   └── DomainException.cs
└── Interfaces/
    ├── IOrderRepository.cs
    └── IUnitOfWork.cs

2. Application Layer -- сценарии использования

Содержит Commands и Queries (паттерн CQRS), DTO для передачи данных, валидаторы, Pipeline Behaviors и интерфейсы внешних сервисов. Зависит только от Domain Layer.

OrderManagement.Application/
├── Common/
│   ├── Behaviors/
│   │   ├── ValidationBehavior.cs
│   │   └── LoggingBehavior.cs
│   └── Interfaces/
│       └── IEmailService.cs
├── Orders/
│   ├── Commands/
│   │   ├── CreateOrder/
│   │   └── ConfirmOrder/
│   ├── Queries/
│   │   ├── GetOrder/
│   │   └── GetCustomerOrders/
│   └── DTOs/
│       └── OrderDto.cs
└── DependencyInjection.cs

3. Infrastructure Layer -- реализация

Содержит реализацию репозиториев через EF Core, DbContext с конфигурациями, внешние интеграции (email, кэш, очереди). Зависит от Domain и Application Layer.

OrderManagement.Infrastructure/
├── Persistence/
│   ├── OrderDbContext.cs
│   ├── Configurations/
│   │   ├── OrderConfiguration.cs
│   │   └── OrderItemConfiguration.cs
│   └── Interceptors/
│       └── DomainEventDispatcherInterceptor.cs
├── Repositories/
│   └── OrderRepository.cs
└── DependencyInjection.cs

4. API Layer -- точка входа

Содержит контроллеры ASP.NET Core, middleware для обработки ошибок, конфигурацию DI и документацию API. Зависит от Application Layer, а на Infrastructure ссылается только для регистрации зависимостей.

CQRS: разделение чтения и записи

Command Query Responsibility Segregation разделяет операции изменения состояния (Commands) и чтения данных (Queries):

// Command -- changes state, returns ID or nothing
public record CreateOrderCommand(
    string CustomerId,
    string Street,
    string City,
    string PostalCode,
    string Country,
    List<OrderItemDto> Items) : IRequest<Guid>;

// Query -- does not change state, returns data
public record GetOrderQuery(Guid OrderId) : IRequest<OrderDto>;

Команды проходят через валидацию и бизнес-логику. Запросы могут быть оптимизированы отдельно (кэширование, денормализованные представления).

Pipeline Behaviors

Pipeline Behaviors в MediatR -- это цепочка обработчиков, через которую проходит каждая команда или запрос перед попаданием в Handler. Типичные Behaviors: валидация и логирование.

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 ct)
    {
        if (!_validators.Any()) return await next();

        var context = new ValidationContext<TRequest>(request);
        var results = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, ct)));

        var failures = results
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();

        if (failures.Any())
            throw new ValidationException(failures);

        return await next();
    }
}

Если для команды CreateOrderCommand зарегистрирован CreateOrderCommandValidator, валидация сработает автоматически до вызова Handler. При ошибках клиент получит HTTP 400 с детальным описанием проблем.

Dispatch Domain Events

Доменные события диспатчатся после сохранения изменений в базу данных через EF Core interceptor:

public class DomainEventDispatcherInterceptor : SaveChangesInterceptor
{
    private readonly IMediator _mediator;

    public override async ValueTask<int> SavedChangesAsync(
        SaveChangesCompletedEventData eventData,
        int result,
        CancellationToken ct = default)
    {
        if (eventData.Context is null) return result;

        var entities = eventData.Context.ChangeTracker
            .Entries<Entity>()
            .Where(e => e.Entity.DomainEvents.Any())
            .Select(e => e.Entity)
            .ToList();

        var events = entities
            .SelectMany(e => e.DomainEvents)
            .ToList();

        entities.ForEach(e => e.ClearDomainEvents());

        foreach (var domainEvent in events)
            await _mediator.Publish(domainEvent, ct);

        return result;
    }
}

Событие публикуется после успешного SaveChanges, что гарантирует: если сохранение в БД провалилось, обработчики событий не будут вызваны.

Преимущества Clean Architecture

  1. Тестируемость. Domain и Application тестируются без БД и HTTP.
  2. Независимость от фреймворков. Замена EF Core на Dapper или SQL Server на PostgreSQL затрагивает только Infrastructure.
  3. Масштабируемость. Чтение и запись можно масштабировать независимо.
  4. Читаемость. Структура проекта отражает бизнес-логику, а не технические детали.

Проверь себя

Какое главное правило Clean Architecture?

Что такое CQRS и зачем разделять чтение и запись?

Как Pipeline Behaviors помогают с кросс-функциональными задачами?

Когда диспатчатся Domain Events в нашей архитектуре?