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

Clean Architecture, популяризована Робертом C. Мартіном (Uncle Bob), організовує код так, щоб бізнес-логіка перебувала в центрі застосунку, незалежно від фреймворків і деталей реалізації. Цей архітектурний підхід забезпечує тестованість, підтримуваність і масштабованість застосунків .NET. Посібник демонструє практичну реалізацію з ASP.NET Core.
Застосунки, що змішують бізнес-логіку з кодом інфраструктури, швидко стають важкими у підтримці. Clean Architecture запроваджує сувору сегрегацію, яка дозволяє змінювати технічні деталі без впливу на бізнес-ядро.
Базові принципи Clean Architecture
Clean Architecture спирається на інверсію залежностей: внутрішні шари нічого не знають про зовнішні. Бізнес-домен залишається ізольованим і може еволюціонувати незалежно від технічних рішень, як-от веб-фреймворк чи база даних.
┌─────────────────────────────────────────────────────────┐
│ 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, що забезпечує фізичну сегрегацію відповідальностей.
# 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 та інтерфейси репозиторіїв. Жодних зовнішніх залежностей у цьому шарі бути не повинно.
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 інкапсулює бізнес-правила і захищає внутрішній стан. Зміни мають здійснюватися виключно через бізнес-методи, які перевіряють інваріанти.
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;
}
}namespace CleanArchitecture.Domain.Enums;
// Перелік статусів замовлення
public enum OrderStatus
{
Pending = 0, // Очікує підтвердження
Confirmed = 1, // Підтверджене, готове до відправки
Shipped = 2, // Відправлене
Delivered = 3, // Доставлене
Cancelled = 4 // Скасоване
}Value Objects, як-от Money, Address або Email, інкапсулюють бізнес-поняття без власної ідентичності. Їхня рівність базується на значеннях, а не на посиланнях. Такий підхід підсилює виразність домену.
Інтерфейси Repository у Domain
Інтерфейси репозиторіїв визначаються в Domain, проте їхні реалізації знаходяться в Infrastructure. Цей шаблон дотримується принципу інверсії залежностей.
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);
}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 та інтерфейси зовнішніх сервісів. Він оркеструє взаємодію домену з зовнішнім світом.
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
);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, відповідальний за його обробку.
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
);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'а.
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.");
});
}
}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.
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);
}
}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);
}
}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);
}
}Репозиторії не мають віддавати IQueryable назовні, бо це створює залежність від EF Core у верхніх шарах. Краще використовувати конкретні методи з чіткими параметрами.
Реалізація Unit of Work
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.
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;
}
}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;
}
}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.
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 полегшує юніт-тестування завдяки ізоляції залежностей.
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
- ✅ Ізольовані юніт-тести з мок'ами
Починай практикувати!
Перевір свої знання з нашими симуляторами співбесід та технічними тестами.
Початкові інвестиції в цю архітектуру швидко окупаються в середніх і великих проєктах, де зміни технічних вимог (міграція бази даних, зміна фреймворку) впливають лише на зовнішні шари, а бізнес-ядро залишається недоторканним.
Теги
Поділитися
Пов'язані статті

Питання на співбесіді з C# та .NET: Повний посібник 2026
25 найпоширеніших питань на співбесіді з C# та .NET. LINQ, async/await, dependency injection, Entity Framework та найкращі практики з детальними відповідями.

.NET 8: Створення API з ASP.NET Core
Повний посібник зі створення професійного REST API з .NET 8 та ASP.NET Core. Контролери, Entity Framework Core, валідація та найкращі практики.

.NET 9 Blazor: Full-Stack розробка з Blazor United у 2026 році
.NET 9 Blazor United об'єднує статичний SSR, Server та WebAssembly режими рендерингу в єдиний full-stack фреймворк. Практичний посібник з режимами рендерингу, streaming rendering, dependency injection та production-ready патернами.