Архитектура прод-приложений на .NET
Теперь соберём всё в поддерживаемую систему. Цель архитектуры — не красота ради красоты, а управление зависимостями так, чтобы бизнес-логику можно было менять и тестировать независимо от базы данных, веб-фреймворка и внешних сервисов.
Слои и правило зависимостей
Идея слоистой (а в пределе — гексагональной / «луковой» / Clean) архитектуры одна: зависимости направлены внутрь, к домену. Домен не знает о базе и HTTP; наоборот, инфраструктура зависит от домена.
(ASP.NET Core, DTO, эндпоинты)"] APP["Application
(use-cases, оркестрация, интерфейсы портов)"] DOM["Domain
(сущности, value-objects, правила)"] INF["Infrastructure
(EF Core, HTTP-клиенты, брокеры)"] API --> APP APP --> DOM INF --> APP INF --> DOM
- 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:
- N+1 запросов. Обращение к навигационному свойству в цикле = отдельный запрос на
каждую строку. Лечится
Include()(eager loading) или проекцией черезSelect. - Загрузка лишних данных. Тянете всю сущность, когда нужны два поля. Проецируйте в
DTO через
Select— EF сгенерируетSELECTтолько нужных колонок. - 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, профилирование производительности.