Как не утонуть в спагетти: Hexagonal архитектура в C#

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Когда проект превращается в кашу

Вы когда-нибудь открывали 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                

Блоги

Рекомендаций по книгам