Когда проект превращается в кашу
Вы когда-нибудь открывали C# проект, написанный год назад, и с ужасом понимали, что ничего нельзя изменить без риска всё сломать? Бизнес-логика плотно сплетена с Entity Framework, контроллеры ASP.NET хранят секреты базы данных, а unit-тесты превращаются в адскую интеграцию с реальной БД. Знакомо?
Это классический симптом отсутствия архитектуры. Хорошая новость: есть проверенный рецепт — Hexagonal архитектура (она же Ports & Adapters). Этот паттерн, придуманный Алистером Кокберном, не просто модный термин. Это спасение для средних и крупных проектов на C#. Давайте разберемся, как он работает, и сразу напишем код.
Суть Hexagonal архитектуры: ядро и периферия
Представьте себе шестиугольник (гексагон). В центре — ваше приложение с чистой бизнес-логикой, написанное на чистом C# без внешних зависимостей. По краям — порты (интерфейсы), через которые ядро общается с внешним миром. Адаптеры — это конкретные реализации: контроллеры ASP.NET, репозитории EF Core, RabbitMQ клиенты.
Главное правило: ядро ничего не знает о внешнем мире. Оно знает только про свои порты. Это позволяет менять базу данных, UI или очереди сообщений без изменения бизнес-логики.
Зачем это C# разработчику?
- Тестируемость: Бизнес-логику можно тестировать без БД, HTTP и файловой системы.
- Гибкость: Хотите заменить PostgreSQL на MongoDB? Меняете только адаптер.
- Читаемость: Код становится похожим на спецификацию требований, а не на клубок зависимостей.
- Соблюдение SOLID: Особенно принцип инверсии зависимостей (Dependency Inversion).
Структура проекта на C#
В Hexagonal архитектуре обычно выделяют три основных слоя. Для C# это удобно оформить в виде отдельных проектов в одном решении:
MyApp.sln
├── MyApp.Domain // Ядро: сущности, value objects, порты (интерфейсы)
├── MyApp.Application // Use cases (сервисы приложения)
├── MyApp.Infrastructure // Адаптеры: EF Core, внешние API, логирование
└── MyApp.Api // Входной адаптер: ASP.NET Controllers / Minimal API
Обратите внимание: Domain и Application — это чистые библиотеки классов без ссылок на Microsoft.AspNetCore или EntityFramework. Только System и System.Threading.Tasks.
Пишем код: реальный пример
Допустим, мы делаем систему заказов. Нужно создать заказ и сохранить его.
Шаг 1. Ядро (Domain)
Здесь живут сущности и порты — интерфейсы, которые определяют, как ядро общается с миром.
// MyApp.Domain/Entities/Order.cs
public class Order
{
public Guid Id { get; private set; }
public string CustomerName { get; private set; }
public decimal TotalAmount { get; private set; }
public DateTime CreatedAt { get; private set; }
public Order(string customerName, decimal totalAmount)
{
Id = Guid.NewGuid();
CustomerName = customerName;
TotalAmount = totalAmount;
CreatedAt = DateTime.UtcNow;
}
}
// MyApp.Domain/Ports/IOrderRepository.cs (Порт выхода)
public interface IOrderRepository
{
Task<Order> GetByIdAsync(Guid id);
Task AddAsync(Order order);
}
// MyApp.Domain/Ports/IOrderNotifier.cs (Порт выхода — уведомления)
public interface IOrderNotifier
{
Task NotifyOrderCreatedAsync(Order order);
}
Обратите внимание: IOrderRepository и IOrderNotifier — это порты. Ядро определяет, что оно хочет, но не как это реализовано.
Шаг 2. Сценарии использования (Application)
Здесь мы описываем бизнес-операции. Это «дирижеры», которые дергают порты.
// MyApp.Application/UseCases/CreateOrderUseCase.cs
public class CreateOrderUseCase
{
private readonly IOrderRepository _orderRepository;
private readonly IOrderNotifier _orderNotifier;
public CreateOrderUseCase(IOrderRepository orderRepository, IOrderNotifier orderNotifier)
{
_orderRepository = orderRepository;
_orderNotifier = orderNotifier;
}
public async Task<Guid> ExecuteAsync(string customerName, decimal totalAmount)
{
// 1. Создаем сущность (бизнес-правила внутри конструктора)
var order = new Order(customerName, totalAmount);
// 2. Сохраняем через порт
await _orderRepository.AddAsync(order);
// 3. Уведомляем через порт
await _orderNotifier.NotifyOrderCreatedAsync(order);
return order.Id;
}
}
Класс CreateOrderUseCase ничего не знает про базу данных или email. Он просто вызывает интерфейсы. Это делает его идеальным для unit-тестов.
Шаг 3. Адаптеры (Infrastructure)
Теперь реализуем порты. Здесь уже можно использовать Entity Framework, SendGrid и т.д.
// MyApp.Infrastructure/Repositories/OrderRepository.cs
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _context;
public OrderRepository(AppDbContext context)
{
_context = context;
}
public async Task AddAsync(Order order)
{
await _context.Orders.AddAsync(order);
await _context.SaveChangesAsync();
}
public async Task<Order> GetByIdAsync(Guid id)
{
return await _context.Orders.FindAsync(id);
}
}
// MyApp.Infrastructure/Notifiers/EmailOrderNotifier.cs
public class EmailOrderNotifier : IOrderNotifier
{
public async Task NotifyOrderCreatedAsync(Order order)
{
// Здесь код отправки email через SMTP или SendGrid
await Task.CompletedTask; // Заглушка
}
}
Шаг 4. Входной адаптер (API)
Контроллер — это тоже адаптер. Он принимает HTTP-запрос и вызывает use case.
// MyApp.Api/Controllers/OrdersController.cs
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly CreateOrderUseCase _createOrderUseCase;
public OrdersController(CreateOrderUseCase createOrderUseCase)
{
_createOrderUseCase = createOrderUseCase;
}
[HttpPost]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
{
var orderId = await _createOrderUseCase.ExecuteAsync(request.CustomerName, request.TotalAmount);
return Ok(new { OrderId = orderId });
}
}
public record CreateOrderRequest(string CustomerName, decimal TotalAmount);
Тестирование — главный бонус
Так как ядро изолировано, тесты пишутся легко и быстро. Никаких WebApplicationFactory или in-memory баз данных для проверки логики.
[Test]
public