C# / .NET Архитектура прод-приложений на .NET: DI, слои, Options и EF Core
0%

Архитектура прод-приложений на .NET: DI, слои, Options и EF Core

Архитектура прод-приложений на .NET

Теперь соберём всё в поддерживаемую систему. Цель архитектуры — не красота ради красоты, а управление зависимостями так, чтобы бизнес-логику можно было менять и тестировать независимо от базы данных, веб-фреймворка и внешних сервисов.

Слои и правило зависимостей

Идея слоистой (а в пределе — гексагональной / «луковой» / Clean) архитектуры одна: зависимости направлены внутрь, к домену. Домен не знает о базе и HTTP; наоборот, инфраструктура зависит от домена.

  • Domain — сущности, value-objects, доменные правила. Ноль зависимостей от фреймворков. Здесь живёт суть системы.
  • Application — сценарии использования (use-cases), оркестрация домена. Определяет порты (интерфейсы вроде IOrderRepository, IPaymentGateway), но не их реализации.
  • Infrastructure — адаптеры: реализации портов через EF Core, HTTP, очереди.
  • API/Presentation — вход: контроллеры или Minimal API, маппинг DTO.

Ключ — инверсия зависимостей (буква D в SOLID): Application объявляет интерфейс, Infrastructure его реализует, а связывает их DI-контейнер в точке входа (composition root). Так домен остаётся чистым и тестируемым без базы.

Не переусердствуйте: для маленького сервиса три проекта (или даже один с папками) — нормально. Гексагональная архитектура окупается на сложной, долгоживущей доменной логике, а не на CRUD из пяти эндпоинтов. Прагматизм важнее догмы.

Dependency Injection в .NET

DI встроен в платформу — Microsoft.Extensions.DependencyInjection. Вы регистрируете сервисы в контейнере, а он создаёт объекты и подставляет их зависимости.

var builder = WebApplication.CreateBuilder(args);

// Регистрация с временами жизни:
builder.Services.AddSingleton<IClock, SystemClock>();          // один на всё приложение
builder.Services.AddScoped<IOrderRepository, OrderRepository>();// один на HTTP-запрос
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>(); // новый на каждый запрос DI
builder.Services.AddScoped<PlaceOrderHandler>();

var app = builder.Build();

Три времени жизни — и понимание их критично:

  • Singleton — один экземпляр на весь процесс. Для stateless-сервисов, кэшей, конфигурации. Должен быть потокобезопасным.
  • Scoped — один на «скоуп» (в вебе — на HTTP-запрос). Типично для DbContext и репозиториев.
  • Transient — новый каждый раз при разрешении. Для лёгких безсостоятельных объектов.

Главная ловушка — captive dependency: если Singleton зависит от Scoped-сервиса, он «захватит» один экземпляр навсегда, что порождает баги (например, живущий вечно DbContext — катастрофа). Встроенный контейнер в Development-режиме проверяет это и падает с понятной ошибкой при ValidateScopes/ValidateOnBuild.

// Включите валидацию — она ловит captive dependencies и незарегистрированное на старте
builder.Host.UseDefaultServiceProvider((ctx, options) =>
{
    options.ValidateScopes = true;
    options.ValidateOnBuild = true;
});

Инъекция — через конструктор (предпочтительно). Избегайте service locator (serviceProvider.GetService<T>() посреди кода) — он прячет зависимости и ломает тестируемость. Классика темы — Mark Seemann, «Dependency Injection Principles, Practices, and Patterns».

Конфигурация и Options pattern

Конфигурация в .NET — многослойная: appsettings.json, appsettings.{Environment}.json, переменные окружения, секреты, аргументы командной строки. Слои перекрывают друг друга (переменные окружения переопределяют файл — это то, что нужно для контейнеров).

Идиоматичный способ доступа — Options pattern: типизированный класс настроек с валидацией, вместо разбросанных IConfiguration["key"].

// 1. Класс настроек
public sealed class SmtpOptions
{
    public const string SectionName = "Smtp";
    [Required] public required string Host { get; init; }
    [Range(1, 65535)] public int Port { get; init; } = 587;
    public required string User { get; init; }
}

// 2. Регистрация с валидацией при старте
builder.Services
    .AddOptions<SmtpOptions>()
    .Bind(builder.Configuration.GetSection(SmtpOptions.SectionName))
    .ValidateDataAnnotations()
    .ValidateOnStart();      // упасть на старте, а не в рантайме при первом использовании

// 3. Потребление через IOptions<T>
public class SmtpEmailSender(IOptions<SmtpOptions> options) : IEmailSender
{
    private readonly SmtpOptions _cfg = options.Value;
    public Task SendAsync(...) { /* используем _cfg.Host, _cfg.Port */ }
}

ValidateOnStart() — важнейшая практика: приложение не поднимется с невалидной конфигурацией (лучше упасть на деплое, чем ночью в проде). Для «горячей» перезагрузки настроек используйте IOptionsMonitor<T>, для scoped — IOptionsSnapshot<T>.

Секреты никогда не храните в appsettings.json в репозитории. Локально — dotnet user-secrets; в проде — переменные окружения, Azure Key Vault, AWS Secrets Manager, HashiCorp Vault.

EF Core: работа с базой данных

Entity Framework Core — основной ORM в .NET. Он мапит классы на таблицы, генерирует SQL и управляет миграциями схемы.

public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder b)
    {
        b.Entity<Order>(e =>
        {
            e.HasKey(o => o.Id);
            e.Property(o => o.Total).HasPrecision(18, 2);
            e.HasIndex(o => o.CustomerId);
        });
    }
}

// Регистрация
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

Миграции

Схема БД версионируется миграциями — код, описывающий изменения, под контролем версий.

dotnet tool install -g dotnet-ef
dotnet ef migrations add AddOrdersTable      # сгенерировать миграцию из модели
dotnet ef database update                    # применить к БД
dotnet ef migrations script                  # SQL-скрипт для прод-деплоя

В проде не вызывайте Database.Migrate() вслепую на старте нескольких реплик одновременно — это гонка. Применяйте миграции отдельным шагом деплоя (job/скриптом), а приложение только проверяет совместимость.

Трекинг и производительность запросов

EF Core по умолчанию отслеживает загруженные сущности (change tracking), чтобы знать, что сохранять при SaveChanges. Для запросов только на чтение это лишняя работа — используйте AsNoTracking():

// Только чтение — без трекинга, быстрее и меньше памяти
var orders = await _db.Orders
    .AsNoTracking()
    .Where(o => o.CustomerId == id)
    .Select(o => new OrderDto(o.Id, o.Total))   // проецируем только нужные колонки
    .ToListAsync(ct);

Три главные ловушки производительности EF Core:

  1. N+1 запросов. Обращение к навигационному свойству в цикле = отдельный запрос на каждую строку. Лечится Include() (eager loading) или проекцией через Select.
  2. Загрузка лишних данных. Тянете всю сущность, когда нужны два поля. Проецируйте в DTO через Select — EF сгенерирует SELECT только нужных колонок.
  3. Client-side evaluation. Если LINQ нельзя перевести в SQL, EF Core (в отличие от старых версий) бросит исключение — это хорошо, но следите за этим. Не суйте C#-методы, которые SQL не понимает, в Where.

Включите логирование SQL в разработке (EnableSensitiveDataLogging только не в проде!) и смотрите, какие запросы реально уходят в базу. Для сложных выборок иногда правильнее сырой SQL (FromSqlRaw) или микро-ORM Dapper. Глубоко про EF Core пишет Shay Rojansky и команда: learn.microsoft.com/ef/core/performance.

Minimal API vs контроллеры: trade-offs

ASP.NET Core предлагает два стиля определения HTTP-эндпоинтов.

Minimal API — лаконичный, быстрый, функциональный стиль:

var app = builder.Build();

app.MapPost("/orders", async (CreateOrderRequest req, PlaceOrderHandler handler,
                              CancellationToken ct) =>
{
    var result = await handler.HandleAsync(req, ct);
    return result.IsSuccess
        ? Results.Created($"/orders/{result.Value.Id}", result.Value)
        : Results.BadRequest(result.Error);
})
.WithName("CreateOrder")
.Produces<OrderDto>(StatusCodes.Status201Created);

app.Run();

Контроллеры — классический MVC-стиль, атрибуты, наследование от ControllerBase:

[ApiController]
[Route("orders")]
public class OrdersController(PlaceOrderHandler handler) : ControllerBase
{
    [HttpPost]
    public async Task<IActionResult> Create(CreateOrderRequest req, CancellationToken ct)
    {
        var result = await handler.HandleAsync(req, ct);
        return result.IsSuccess
            ? CreatedAtAction(nameof(Create), new { id = result.Value.Id }, result.Value)
            : BadRequest(result.Error);
    }
}

Как выбирать:

  • Minimal API — для небольших сервисов, микросервисов, максимальной производительности и стартового веса. С .NET 8+ получил почти весь функционал контроллеров (фильтры, валидация, группы MapGroup).
  • Контроллеры — для крупных API с множеством эндпоинтов, где нужна структура, конвенции, богатые фильтры, привычная организация. Легче навигировать в больших командах.

Оба используют один и тот же конвейер, DI и middleware — выбор в основном организационный. Не смешивайте стили без причины в одном сервисе.

Устойчивость (resilience)

Прод-сервис общается с ненадёжными зависимостями (сеть моргает, БД тормозит). Паттерны устойчивости встроены через Microsoft.Extensions.Resilience / Polly:

// HttpClient с retry, circuit breaker и таймаутом — стандартный набор
builder.Services.AddHttpClient<PaymentClient>()
    .AddStandardResilienceHandler();   // retry + circuit breaker + timeout + rate limiter

Ключевые паттерны:

  • Retry с экспоненциальной задержкой и jitter — переживать временные сбои. Только для идемпотентных операций!
  • Circuit breaker — перестать долбить упавшую зависимость, дать ей восстановиться.
  • Timeout — не ждать вечно; всегда ставьте верхнюю границу.
  • Bulkhead / rate limiter — изолировать и ограничить нагрузку.

Комбинируйте с CancellationToken из статьи про конкурентность. Идемпотентность и таймауты — то, что отличает сервис, переживающий пятничный инцидент, от того, который падает каскадом.

Что дальше

Деплой и наблюдаемость — публикация артефактов, Docker, health checks, логи, метрики и распределённый трейсинг через OpenTelemetry, профилирование производительности.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов