Clean Architecture з .NET: практичний посібник

Опанування Clean Architecture у .NET з C#. Знайомство з принципами SOLID, поділом на шари та шаблонами реалізації для зручних в підтримці застосунків.

Посібник з Clean Architecture у .NET та C#

Clean Architecture, популяризована Робертом C. Мартіном (Uncle Bob), організовує код так, щоб бізнес-логіка перебувала в центрі застосунку, незалежно від фреймворків і деталей реалізації. Цей архітектурний підхід забезпечує тестованість, підтримуваність і масштабованість застосунків .NET. Посібник демонструє практичну реалізацію з ASP.NET Core.

Чому Clean Architecture?

Застосунки, що змішують бізнес-логіку з кодом інфраструктури, швидко стають важкими у підтримці. Clean Architecture запроваджує сувору сегрегацію, яка дозволяє змінювати технічні деталі без впливу на бізнес-ядро.

Базові принципи Clean Architecture

Clean Architecture спирається на інверсію залежностей: внутрішні шари нічого не знають про зовнішні. Бізнес-домен залишається ізольованим і може еволюціонувати незалежно від технічних рішень, як-от веб-фреймворк чи база даних.

text
┌─────────────────────────────────────────────────────────┐
│                     Presentation                        │
│  (Controllers, Razor Pages, Blazor, API Endpoints)     │
├─────────────────────────────────────────────────────────┤
│                    Infrastructure                       │
│    (EF Core, External APIs, File System, Email)        │
├─────────────────────────────────────────────────────────┤
│                      Application                        │
│       (Use Cases, Commands, Queries, DTOs)             │
├─────────────────────────────────────────────────────────┤
│                        Domain                           │
│     (Entities, Value Objects, Domain Services)          │
└─────────────────────────────────────────────────────────┘

Залежності завжди спрямовані всередину: Presentation → Infrastructure → Application → Domain. Domain не посилається на жоден інший проєкт.

Структура проєкту .NET у Clean Architecture

Організація проєктів відображає різні шари. Кожний шар відповідає окремому проєкту в розв'язанні Visual Studio, що забезпечує фізичну сегрегацію відповідальностей.

bash
# terminal
# Створення структури розв'язання
dotnet new sln -n CleanArchitecture

# Створення проєктів для кожного шару
dotnet new classlib -n CleanArchitecture.Domain -o src/CleanArchitecture.Domain
dotnet new classlib -n CleanArchitecture.Application -o src/CleanArchitecture.Application
dotnet new classlib -n CleanArchitecture.Infrastructure -o src/CleanArchitecture.Infrastructure
dotnet new webapi -n CleanArchitecture.Api -o src/CleanArchitecture.Api

# Додавання проєктів до розв'язання
dotnet sln add src/CleanArchitecture.Domain
dotnet sln add src/CleanArchitecture.Application
dotnet sln add src/CleanArchitecture.Infrastructure
dotnet sln add src/CleanArchitecture.Api

# Налаштування посилань між проєктами
cd src/CleanArchitecture.Application
dotnet add reference ../CleanArchitecture.Domain

cd ../CleanArchitecture.Infrastructure
dotnet add reference ../CleanArchitecture.Application

cd ../CleanArchitecture.Api
dotnet add reference ../CleanArchitecture.Infrastructure

Така структура гарантує, що Domain залишається незалежним, а залежності відповідають правилу Dependency Rule у Clean Architecture.

Шар Domain: бізнес-ядро

Шар Domain містить бізнес-сутності, Value Objects та інтерфейси репозиторіїв. Жодних зовнішніх залежностей у цьому шарі бути не повинно.

Domain/Entities/Order.cscsharp
namespace CleanArchitecture.Domain.Entities;

// Сутність з ідентичністю та бізнес-життєвим циклом
public class Order
{
    // Унікальний ідентифікатор замовлення
    public Guid Id { get; private set; }

    // Посилання на клієнта (Value Object для email)
    public string CustomerEmail { get; private set; }

    // Колекція позицій (відношення один-до-багатьох)
    private readonly List<OrderItem> _items = new();
    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();

    // Статус замовлення (бізнес-enum)
    public OrderStatus Status { get; private set; }

    // Розрахована загальна сума
    public decimal TotalAmount => _items.Sum(i => i.TotalPrice);

    // Дати відстеження
    public DateTime CreatedAt { get; private set; }
    public DateTime? ShippedAt { get; private set; }

    // Приватний конструктор зобов'язує користуватися factory-методом
    private Order() { }

    // Factory-метод для створення коректного замовлення
    public static Order Create(string customerEmail)
    {
        // Валідація бізнес-правил під час створення
        if (string.IsNullOrWhiteSpace(customerEmail))
            throw new DomainException("Email клієнта є обов'язковим.");

        if (!IsValidEmail(customerEmail))
            throw new DomainException("Неправильний формат email.");

        return new Order
        {
            Id = Guid.NewGuid(),
            CustomerEmail = customerEmail,
            Status = OrderStatus.Pending,
            CreatedAt = DateTime.UtcNow
        };
    }

    // Бізнес-метод: додати позицію
    public void AddItem(Product product, int quantity)
    {
        // Бізнес-правило: відправлене замовлення змінювати не можна
        if (Status == OrderStatus.Shipped)
            throw new DomainException("Не можна змінювати вже відправлене замовлення.");

        if (quantity <= 0)
            throw new DomainException("Кількість має бути додатною.");

        // Перевірка, чи продукт уже доданий
        var existingItem = _items.FirstOrDefault(i => i.ProductId == product.Id);
        if (existingItem != null)
        {
            existingItem.IncreaseQuantity(quantity);
        }
        else
        {
            _items.Add(OrderItem.Create(this, product, quantity));
        }
    }

    // Бізнес-метод: підтвердити замовлення
    public void Confirm()
    {
        if (Status != OrderStatus.Pending)
            throw new DomainException("Підтверджувати можна лише замовлення, що очікують підтвердження.");

        if (!_items.Any())
            throw new DomainException("Замовлення має містити щонайменше одну позицію.");

        Status = OrderStatus.Confirmed;
    }

    // Бізнес-метод: відправити замовлення
    public void Ship()
    {
        if (Status != OrderStatus.Confirmed)
            throw new DomainException("Перед відправкою замовлення має бути підтверджене.");

        Status = OrderStatus.Shipped;
        ShippedAt = DateTime.UtcNow;
    }

    private static bool IsValidEmail(string email) =>
        email.Contains('@') && email.Contains('.');
}

Сутність Order інкапсулює бізнес-правила і захищає внутрішній стан. Зміни мають здійснюватися виключно через бізнес-методи, які перевіряють інваріанти.

Domain/Entities/OrderItem.cscsharp
namespace CleanArchitecture.Domain.Entities;

// Дочірня сутність з власною ідентичністю
public class OrderItem
{
    public Guid Id { get; private set; }
    public Guid OrderId { get; private set; }
    public Guid ProductId { get; private set; }
    public string ProductName { get; private set; }
    public decimal UnitPrice { get; private set; }
    public int Quantity { get; private set; }

    // Розрахунок підсумкової ціни рядка
    public decimal TotalPrice => UnitPrice * Quantity;

    private OrderItem() { }

    // Factory-метод з валідацією
    public static OrderItem Create(Order order, Product product, int quantity)
    {
        return new OrderItem
        {
            Id = Guid.NewGuid(),
            OrderId = order.Id,
            ProductId = product.Id,
            ProductName = product.Name,
            UnitPrice = product.Price,
            Quantity = quantity
        };
    }

    // Метод збільшення кількості
    public void IncreaseQuantity(int additionalQuantity)
    {
        if (additionalQuantity <= 0)
            throw new DomainException("Додаткова кількість має бути додатною.");

        Quantity += additionalQuantity;
    }
}
Domain/Enums/OrderStatus.cscsharp
namespace CleanArchitecture.Domain.Enums;

// Перелік статусів замовлення
public enum OrderStatus
{
    Pending = 0,      // Очікує підтвердження
    Confirmed = 1,    // Підтверджене, готове до відправки
    Shipped = 2,      // Відправлене
    Delivered = 3,    // Доставлене
    Cancelled = 4     // Скасоване
}
Value Objects

Value Objects, як-от Money, Address або Email, інкапсулюють бізнес-поняття без власної ідентичності. Їхня рівність базується на значеннях, а не на посиланнях. Такий підхід підсилює виразність домену.

Інтерфейси Repository у Domain

Інтерфейси репозиторіїв визначаються в Domain, проте їхні реалізації знаходяться в Infrastructure. Цей шаблон дотримується принципу інверсії залежностей.

Domain/Interfaces/IOrderRepository.cscsharp
namespace CleanArchitecture.Domain.Interfaces;

// Інтерфейс репозиторію Order
public interface IOrderRepository
{
    // Отримання за ідентифікатором
    Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);

    // Отримання разом із позиціями
    Task<Order?> GetByIdWithItemsAsync(Guid id, CancellationToken cancellationToken = default);

    // Отримання за email клієнта
    Task<IEnumerable<Order>> GetByCustomerEmailAsync(
        string email,
        CancellationToken cancellationToken = default);

    // Додавання нового замовлення
    Task AddAsync(Order order, CancellationToken cancellationToken = default);

    // Оновлення наявного замовлення
    Task UpdateAsync(Order order, CancellationToken cancellationToken = default);

    // Видалення замовлення
    Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
}
Domain/Interfaces/IUnitOfWork.cscsharp
namespace CleanArchitecture.Domain.Interfaces;

// Шаблон Unit of Work для транзакційного керування
public interface IUnitOfWork : IDisposable
{
    // Репозиторії, доступні через UoW
    IOrderRepository Orders { get; }
    IProductRepository Products { get; }

    // Атомарне збереження всіх змін
    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);

    // Явне керування транзакціями
    Task BeginTransactionAsync(CancellationToken cancellationToken = default);
    Task CommitTransactionAsync(CancellationToken cancellationToken = default);
    Task RollbackTransactionAsync(CancellationToken cancellationToken = default);
}

Шаблон Unit of Work координує операції в кількох репозиторіях у межах однієї транзакції.

Готовий до співбесід з .NET?

Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.

Шар Application: оркестрація Use Case'ів

Шар Application містить прикладну логіку (use cases), DTO та інтерфейси зовнішніх сервісів. Він оркеструє взаємодію домену з зовнішнім світом.

Application/Orders/Commands/CreateOrderCommand.cscsharp
using MediatR;

namespace CleanArchitecture.Application.Orders.Commands;

// Command, що відображає намір створити замовлення
public record CreateOrderCommand(
    string CustomerEmail,
    List<OrderItemDto> Items
) : IRequest<Guid>;

// DTO для позицій замовлення
public record OrderItemDto(
    Guid ProductId,
    int Quantity
);
Application/Orders/Commands/CreateOrderCommandHandler.cscsharp
using MediatR;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;

namespace CleanArchitecture.Application.Orders.Commands;

// Handler, що реалізує use case
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid>
{
    private readonly IUnitOfWork _unitOfWork;

    public CreateOrderCommandHandler(IUnitOfWork unitOfWork)
    {
        _unitOfWork = unitOfWork;
    }

    public async Task<Guid> Handle(
        CreateOrderCommand request,
        CancellationToken cancellationToken)
    {
        // Створення сутності Order через factory-метод
        var order = Order.Create(request.CustomerEmail);

        // Додавання позицій до замовлення
        foreach (var item in request.Items)
        {
            // Отримання продукту з репозиторію
            var product = await _unitOfWork.Products
                .GetByIdAsync(item.ProductId, cancellationToken);

            if (product == null)
                throw new NotFoundException($"Продукт {item.ProductId} не знайдено.");

            // Використання бізнес-методу сутності
            order.AddItem(product, item.Quantity);
        }

        // Збереження через репозиторій
        await _unitOfWork.Orders.AddAsync(order, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return order.Id;
    }
}

MediatR реалізує шаблон Mediator, який відокремлює handler'и від контролерів. Кожен command має єдиний handler, відповідальний за його обробку.

Application/Orders/Queries/GetOrderByIdQuery.cscsharp
using MediatR;

namespace CleanArchitecture.Application.Orders.Queries;

// Query для отримання замовлення за ID
public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDto?>;

// DTO відповіді для замовлення
public record OrderDto(
    Guid Id,
    string CustomerEmail,
    string Status,
    decimal TotalAmount,
    DateTime CreatedAt,
    DateTime? ShippedAt,
    List<OrderItemResponseDto> Items
);

// DTO для позицій у відповіді
public record OrderItemResponseDto(
    Guid Id,
    string ProductName,
    decimal UnitPrice,
    int Quantity,
    decimal TotalPrice
);
Application/Orders/Queries/GetOrderByIdQueryHandler.cscsharp
using MediatR;
using CleanArchitecture.Domain.Interfaces;

namespace CleanArchitecture.Application.Orders.Queries;

public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
    private readonly IOrderRepository _orderRepository;

    public GetOrderByIdQueryHandler(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public async Task<OrderDto?> Handle(
        GetOrderByIdQuery request,
        CancellationToken cancellationToken)
    {
        // Отримання разом із позиціями
        var order = await _orderRepository
            .GetByIdWithItemsAsync(request.OrderId, cancellationToken);

        if (order == null)
            return null;

        // Мапінг до DTO (ручна проєкція)
        return new OrderDto(
            Id: order.Id,
            CustomerEmail: order.CustomerEmail,
            Status: order.Status.ToString(),
            TotalAmount: order.TotalAmount,
            CreatedAt: order.CreatedAt,
            ShippedAt: order.ShippedAt,
            Items: order.Items.Select(i => new OrderItemResponseDto(
                Id: i.Id,
                ProductName: i.ProductName,
                UnitPrice: i.UnitPrice,
                Quantity: i.Quantity,
                TotalPrice: i.TotalPrice
            )).ToList()
        );
    }
}

Розподіл Commands/Queries дотримується шаблону CQRS (Command Query Responsibility Segregation), оптимізуючи читання й записи незалежно одне від одного.

Валідація з FluentValidation

Валідація command'ів відбувається в шарі Application ще до виконання handler'а.

Application/Orders/Validators/CreateOrderCommandValidator.cscsharp
using FluentValidation;
using CleanArchitecture.Application.Orders.Commands;

namespace CleanArchitecture.Application.Orders.Validators;

public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        // Email обов'язковий і має бути коректним
        RuleFor(x => x.CustomerEmail)
            .NotEmpty().WithMessage("Email клієнта є обов'язковим.")
            .EmailAddress().WithMessage("Неправильний формат email.");

        // Має бути щонайменше одна позиція
        RuleFor(x => x.Items)
            .NotEmpty().WithMessage("Замовлення має містити щонайменше одну позицію.");

        // Валідація кожної позиції
        RuleForEach(x => x.Items).ChildRules(item =>
        {
            item.RuleFor(i => i.ProductId)
                .NotEmpty().WithMessage("Ідентифікатор продукту є обов'язковим.");

            item.RuleFor(i => i.Quantity)
                .GreaterThan(0).WithMessage("Кількість має бути додатною.")
                .LessThanOrEqualTo(100).WithMessage("Максимальна кількість: 100.");
        });
    }
}
Application/Common/Behaviors/ValidationBehavior.cscsharp
using FluentValidation;
using MediatR;

namespace CleanArchitecture.Application.Common.Behaviors;

// Pipeline behavior для автоматичної валідації
public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    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())
            return await next();

        // Виконання всіх валідаторів
        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.Any())
            throw new ValidationException(failures);

        return await next();
    }
}

ValidationBehavior запускається автоматично перед кожним handler'ом, гарантуючи, що до бізнес-логіки потрапляють лише валідні command'и.

Шар Infrastructure: технічна реалізація

Шар Infrastructure забезпечує конкретні реалізації інтерфейсів, оголошених у Domain та Application.

Infrastructure/Persistence/AppDbContext.cscsharp
using Microsoft.EntityFrameworkCore;
using CleanArchitecture.Domain.Entities;

namespace CleanArchitecture.Infrastructure.Persistence;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
    {
    }

    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderItem> OrderItems => Set<OrderItem>();
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Застосування конфігурацій з assembly
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }
}
Infrastructure/Persistence/Configurations/OrderConfiguration.cscsharp
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using CleanArchitecture.Domain.Entities;

namespace CleanArchitecture.Infrastructure.Persistence.Configurations;

public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        // Таблиця та первинний ключ
        builder.ToTable("Orders");
        builder.HasKey(o => o.Id);

        // Властивості
        builder.Property(o => o.CustomerEmail)
            .IsRequired()
            .HasMaxLength(256);

        builder.Property(o => o.Status)
            .IsRequired()
            .HasConversion<string>();  // Зберігати як читабельний рядок

        // Зв'язок із OrderItems
        builder.HasMany(o => o.Items)
            .WithOne()
            .HasForeignKey(i => i.OrderId)
            .OnDelete(DeleteBehavior.Cascade);

        // Індекс для пошуку за email
        builder.HasIndex(o => o.CustomerEmail);

        // Доступ до приватного поля _items
        builder.Navigation(o => o.Items)
            .UsePropertyAccessMode(PropertyAccessMode.Field);
    }
}
Infrastructure/Repositories/OrderRepository.cscsharp
using Microsoft.EntityFrameworkCore;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Persistence;

namespace CleanArchitecture.Infrastructure.Repositories;

public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public OrderRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Order?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        return await _context.Orders
            .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
    }

    public async Task<Order?> GetByIdWithItemsAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        // Eager loading позицій
        return await _context.Orders
            .Include(o => o.Items)
            .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
    }

    public async Task<IEnumerable<Order>> GetByCustomerEmailAsync(
        string email,
        CancellationToken cancellationToken = default)
    {
        return await _context.Orders
            .Include(o => o.Items)
            .Where(o => o.CustomerEmail == email)
            .OrderByDescending(o => o.CreatedAt)
            .ToListAsync(cancellationToken);
    }

    public async Task AddAsync(
        Order order,
        CancellationToken cancellationToken = default)
    {
        await _context.Orders.AddAsync(order, cancellationToken);
    }

    public Task UpdateAsync(
        Order order,
        CancellationToken cancellationToken = default)
    {
        // EF Core автоматично відстежує зміни
        _context.Orders.Update(order);
        return Task.CompletedTask;
    }

    public async Task DeleteAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        await _context.Orders
            .Where(o => o.Id == id)
            .ExecuteDeleteAsync(cancellationToken);
    }
}
Стережіться Leaky Abstractions

Репозиторії не мають віддавати IQueryable назовні, бо це створює залежність від EF Core у верхніх шарах. Краще використовувати конкретні методи з чіткими параметрами.

Реалізація Unit of Work

Infrastructure/Persistence/UnitOfWork.cscsharp
using Microsoft.EntityFrameworkCore.Storage;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Repositories;

namespace CleanArchitecture.Infrastructure.Persistence;

public class UnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _context;
    private IDbContextTransaction? _transaction;

    // Lazy завантаження репозиторіїв
    private IOrderRepository? _orderRepository;
    private IProductRepository? _productRepository;

    public UnitOfWork(AppDbContext context)
    {
        _context = context;
    }

    // Створення на запит (lazy initialization)
    public IOrderRepository Orders =>
        _orderRepository ??= new OrderRepository(_context);

    public IProductRepository Products =>
        _productRepository ??= new ProductRepository(_context);

    public async Task<int> SaveChangesAsync(
        CancellationToken cancellationToken = default)
    {
        return await _context.SaveChangesAsync(cancellationToken);
    }

    public async Task BeginTransactionAsync(
        CancellationToken cancellationToken = default)
    {
        _transaction = await _context.Database
            .BeginTransactionAsync(cancellationToken);
    }

    public async Task CommitTransactionAsync(
        CancellationToken cancellationToken = default)
    {
        if (_transaction == null)
            throw new InvalidOperationException("Активних транзакцій немає.");

        await _transaction.CommitAsync(cancellationToken);
        await _transaction.DisposeAsync();
        _transaction = null;
    }

    public async Task RollbackTransactionAsync(
        CancellationToken cancellationToken = default)
    {
        if (_transaction == null)
            throw new InvalidOperationException("Активних транзакцій немає.");

        await _transaction.RollbackAsync(cancellationToken);
        await _transaction.DisposeAsync();
        _transaction = null;
    }

    public void Dispose()
    {
        _transaction?.Dispose();
        _context.Dispose();
    }
}

Налаштування Dependency Injection

Реєстрація сервісів виконується в кожному шарі через extension-методи й оркеструється у Program.cs.

Infrastructure/DependencyInjection.cscsharp
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Persistence;
using CleanArchitecture.Infrastructure.Repositories;

namespace CleanArchitecture.Infrastructure;

public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // Конфігурація Entity Framework
        services.AddDbContext<AppDbContext>(options =>
            options.UseSqlServer(
                configuration.GetConnectionString("DefaultConnection"),
                b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));

        // Реєстрація Unit of Work (Scoped)
        services.AddScoped<IUnitOfWork, UnitOfWork>();

        // Окремі репозиторії за потреби
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddScoped<IProductRepository, ProductRepository>();

        return services;
    }
}
Application/DependencyInjection.cscsharp
using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using CleanArchitecture.Application.Common.Behaviors;

namespace CleanArchitecture.Application;

public static class DependencyInjection
{
    public static IServiceCollection AddApplication(this IServiceCollection services)
    {
        var assembly = typeof(DependencyInjection).Assembly;

        // Реєстрація MediatR з handler'ами
        services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));

        // Реєстрація валідаторів FluentValidation
        services.AddValidatorsFromAssembly(assembly);

        // Pipeline behaviors (порядок виконання має значення)
        services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

        return services;
    }
}
Api/Program.cscsharp
using CleanArchitecture.Application;
using CleanArchitecture.Infrastructure;

var builder = WebApplication.CreateBuilder(args);

// Реєстрація шарів
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);

// Конфігурація API
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

Шар Presentation: API-контролери

Контролери — це прості адаптери, які делегують роботу до MediatR.

Api/Controllers/OrdersController.cscsharp
using MediatR;
using Microsoft.AspNetCore.Mvc;
using CleanArchitecture.Application.Orders.Commands;
using CleanArchitecture.Application.Orders.Queries;

namespace CleanArchitecture.Api.Controllers;

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IMediator _mediator;

    public OrdersController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost]
    [ProducesResponseType(typeof(Guid), StatusCodes.Status201Created)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> Create(
        [FromBody] CreateOrderCommand command,
        CancellationToken cancellationToken)
    {
        // Повна делегація до MediatR
        var orderId = await _mediator.Send(command, cancellationToken);

        return CreatedAtAction(
            nameof(GetById),
            new { id = orderId },
            orderId);
    }

    [HttpGet("{id:guid}")]
    [ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> GetById(
        Guid id,
        CancellationToken cancellationToken)
    {
        var order = await _mediator.Send(
            new GetOrderByIdQuery(id),
            cancellationToken);

        if (order == null)
            return NotFound();

        return Ok(order);
    }

    [HttpPost("{id:guid}/confirm")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Confirm(
        Guid id,
        CancellationToken cancellationToken)
    {
        await _mediator.Send(new ConfirmOrderCommand(id), cancellationToken);
        return NoContent();
    }

    [HttpPost("{id:guid}/ship")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Ship(
        Guid id,
        CancellationToken cancellationToken)
    {
        await _mediator.Send(new ShipOrderCommand(id), cancellationToken);
        return NoContent();
    }
}

Контролери залишаються тонкими і не містять бізнес-логіки, лише HTTP-маппінг.

Юніт-тести шару Application

Clean Architecture полегшує юніт-тестування завдяки ізоляції залежностей.

Tests/Application/CreateOrderCommandHandlerTests.cscsharp
using Moq;
using Xunit;
using CleanArchitecture.Application.Orders.Commands;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;

namespace CleanArchitecture.Tests.Application;

public class CreateOrderCommandHandlerTests
{
    private readonly Mock<IUnitOfWork> _unitOfWorkMock;
    private readonly CreateOrderCommandHandler _handler;

    public CreateOrderCommandHandlerTests()
    {
        _unitOfWorkMock = new Mock<IUnitOfWork>();
        _handler = new CreateOrderCommandHandler(_unitOfWorkMock.Object);
    }

    [Fact]
    public async Task Handle_ValidCommand_CreatesOrderAndReturnsId()
    {
        // Arrange
        var product = new Product { Id = Guid.NewGuid(), Name = "Test", Price = 100 };

        _unitOfWorkMock.Setup(x => x.Products.GetByIdAsync(
            It.IsAny<Guid>(),
            It.IsAny<CancellationToken>()))
            .ReturnsAsync(product);

        _unitOfWorkMock.Setup(x => x.Orders.AddAsync(
            It.IsAny<Order>(),
            It.IsAny<CancellationToken>()))
            .Returns(Task.CompletedTask);

        _unitOfWorkMock.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(1);

        var command = new CreateOrderCommand(
            CustomerEmail: "test@example.com",
            Items: new List<OrderItemDto>
            {
                new(ProductId: product.Id, Quantity: 2)
            });

        // Act
        var result = await _handler.Handle(command, CancellationToken.None);

        // Assert
        Assert.NotEqual(Guid.Empty, result);
        _unitOfWorkMock.Verify(x => x.Orders.AddAsync(
            It.Is<Order>(o => o.CustomerEmail == "test@example.com"),
            It.IsAny<CancellationToken>()), Times.Once);
        _unitOfWorkMock.Verify(x => x.SaveChangesAsync(
            It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task Handle_ProductNotFound_ThrowsNotFoundException()
    {
        // Arrange
        _unitOfWorkMock.Setup(x => x.Products.GetByIdAsync(
            It.IsAny<Guid>(),
            It.IsAny<CancellationToken>()))
            .ReturnsAsync((Product?)null);

        var command = new CreateOrderCommand(
            CustomerEmail: "test@example.com",
            Items: new List<OrderItemDto>
            {
                new(ProductId: Guid.NewGuid(), Quantity: 1)
            });

        // Act & Assert
        await Assert.ThrowsAsync<NotFoundException>(
            () => _handler.Handle(command, CancellationToken.None));
    }
}

Mock'и Moq дозволяють ізолювати handler від його залежностей і тестувати лише оркестраційну логіку.

Висновок

Clean Architecture у .NET надає надійну структуру для корпоративних застосунків. Сувора сегрегація відповідальностей між шарами Domain, Application, Infrastructure та Presentation гарантує підтримуваний і тестований код у довгостроковій перспективі.

Чек-лист Clean Architecture .NET

  • ✅ Ізольований Domain без зовнішніх залежностей
  • ✅ Сутності з інкапсульованою бізнес-логікою
  • ✅ Інтерфейси репозиторіїв у Domain
  • ✅ Use Case'и через Commands/Queries (CQRS)
  • ✅ Валідація з FluentValidation і Pipeline Behaviors
  • ✅ Infrastructure реалізує інтерфейси Domain
  • ✅ Тонкі контролери, що делегують до MediatR
  • ✅ Ізольовані юніт-тести з мок'ами

Починай практикувати!

Перевір свої знання з нашими симуляторами співбесід та технічними тестами.

Початкові інвестиції в цю архітектуру швидко окупаються в середніх і великих проєктах, де зміни технічних вимог (міграція бази даних, зміна фреймворку) впливають лише на зовнішні шари, а бізнес-ядро залишається недоторканним.

Теги

#dotnet
#clean architecture
#csharp
#aspnet core
#design patterns

Поділитися

Пов'язані статті