Architecture Best Practices

The Modern .NET Host (Microsoft.Extensions)

Комплексне вивчення сучасного .NET Host: Dependency Injection, Configuration, Logging, Resilience та Background Services для production-ready додатків.

The Modern .NET Host (Microsoft.Extensions)

Вступ та Контекст

Уявіть, що ви будуєте мікросервіс для e-commerce платформи. Цей сервіс має:

  • Отримувати замовлення з черги повідомлень
  • Перевіряти наявність товарів через зовнішнє API
  • Обробляти платежі з retry логікою при збоях
  • Логувати всі операції для аналітики та відладки
  • Конфігуруватися залежно від середовища (dev, staging, production)
  • Працювати 24/7 з graceful shutdown при оновленнях

Як організувати такий додаток правильно?

До .NET Core 2.1 розробники писали багато boilerplate коду для:

  • Ручного створення залежностей (new EmailService(new SmtpClient()))
  • Парсингу конфігурацій з різних джерел
  • Налаштування логування через конфігураційні файли
  • Обробки винятків та retry логіки
  • Управління життєвим циклом фонових задач
Проблеми старого підходу:
// ❌ Так писали до .NET Core
public class OrderService
{
    private readonly EmailService _emailService;
    private readonly PaymentGateway _paymentGateway;

    public OrderService()
    {
        // Tight coupling - важко тестувати
        var smtpClient = new SmtpClient("smtp.gmail.com");
        _emailService = new EmailService(smtpClient);

        // Hard-coded залежності
        var httpClient = new HttpClient();
        _paymentGateway = new PaymentGateway(httpClient);
    }
}
Наслідки:
  • Tight coupling: Неможливо замінити залежності (наприклад, для тестів)
  • Складність тестування: Кожен тест створює реальні HTTP/SMTP підключення
  • Відсутність lifecycle management: Хто відповідає за Dispose?
  • Дублювання коду: Кожен сервіс має власну логіку ініціалізації

Рішення: Modern .NET Host — це комплексна інфраструктура на базі Microsoft.Extensions.* пакетів, яка надає:

  1. Dependency Injection: Автоматичне управління залежностями та їх lifecycle
  2. Configuration System: Універсальне читання налаштувань з різних джерел
  3. Logging Infrastructure: Структуроване логування з різними providers
  4. Resilience Patterns: Retry, Circuit Breaker, Timeout через Polly
  5. Background Services: Managed lifecycle для фонових задач
Loading diagram...
graph TD
    A[Application] --> B[Generic Host]
    B --> C[Dependency Injection]
    B --> D[Configuration System]
    B --> E[Logging Infrastructure]
    B --> F[Hosted Services]

    C --> C1[IServiceCollection]
    C --> C2[Lifetimes]
    C --> C3[Scopes]

    D --> D1[JSON Files]
    D --> D2[Environment Vars]
    D --> D3[User Secrets]

    E --> E1[ILogger]
    E --> E2[Serilog]
    E --> E3[Structured Logging]

    F --> F1[IHostedService]
    F --> F2[BackgroundService]

    style B fill:#f59e0b,stroke:#b45309,color:#ffffff
    style C fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style D fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style E fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style F fill:#3b82f6,stroke:#1d4ed8,color:#ffffff

Що таке .NET Host?

Host у .NET — це, по суті, NuGet пакет або бібліотека, яку можна встановити у будь-який проєкт, включаючи звичайні консольні програми. Це не якась магічна абстракція, а конкретна інфраструктурна бібліотека з неймспейсу Microsoft.Extensions.Hosting.

dotnet add package Microsoft.Extensions.Hosting

Головне завдання Хоста — побудова інфраструктури навколо вашої програми, перетворення простих консольних застосунків на довготривалі сервіси (long-running services).

Приклади довготривалих сервісів:

  • 🌐 Веб-застосунки (ASP.NET Core)
  • ⚙️ Фонові Worker Services (обробка черг, планувальники)
  • 🔄 Мікросервіси з постійним lifecycle
  • 📡 gRPC сервери, SignalR Hubs

Консольна програма виконується і завершується. Хост-застосунок працює постійно, днями, місяцями або роками, і не має завершуватися без команди.

Відмінності між звичайними та хост-застосунками

ХарактеристикаЗвичайне Консольне ЗастосуванняЗастосування на Базі Хоста (Host Application)
Тривалість РоботиШвидко завершується (секунди, хвилини)Довготривалі служби (дні, місяці, роки); не вимикаються
ПризначенняОдноразові завдання, утиліти, скриптиВеб-застосунки, постійні фонові служби
АрхітектураПроста, мало інфраструктуриСкладна: DI, Logging, Configuration з коробки
Lifecycle ManagementMain() виконується і завершуєтьсяКерований старт, graceful shutdown, restart policies
Dependency InjectionЗазвичай відсутнє або ручнеВбудований DI контейнер
ConfigurationРучний парсинг аргументів або файлівУніверсальна система з множинних джерел
LoggingConsole.WriteLine або сторонні бібліотекиStructured logging з ILogger
ПрикладиCLI утиліти, міграції БД, скриптиASP.NET Core, gRPC, Worker Services

Ключова відмінність: Застосунки на базі Хоста вимагають автоматичного перезапуску у разі збою, оскільки вони мають працювати постійно (наприклад, веб-додаток не може бути просто вимкнений).

Інфраструктура, яку надає Host

Для великих довготривалих застосунків необхідна комплексна інфраструктура. Хост надає готову реалізацію складних функцій:

Dependency Injection Container
Автоматичне управління залежностями з підтримкою різних lifetimes (Transient, Scoped, Singleton).
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
builder.Services.AddScoped<IOrderService, OrderService>();
Configuration System
Універсальне читання налаштувань з JSON, Environment Variables, User Secrets, Command Line.
var connectionString = builder.Configuration["ConnectionStrings:DefaultConnection"];
Logging Infrastructure
Structured logging з підтримкою множинних providers (Console, File, Seq, Application Insights).
builder.Services.AddLogging(config =>
{
    config.AddConsole();
    config.AddDebug();
});
Lifecycle Management
Контроль над стартом, зупинкою та graceful shutdown застосунку через IHostApplicationLifetime.
lifetime.ApplicationStarted.Register(() => Console.WriteLine("App started"));
lifetime.ApplicationStopping.Register(() => Console.WriteLine("App stopping..."));
Background Services
Managed виконання фонових задач через IHostedService та BackgroundService.
builder.Services.AddHostedService<EmailQueueProcessor>();
Health Checks
Моніторинг стану застосунку та його залежностей (БД, API, черги).
builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>()
    .AddUrlCheck("https://api.example.com");

Управління життєвим циклом застосунку

Управління життєвим циклом включає:

  1. Підготовчі роботи перед запуском: Сидінг бази даних, завантаження кешів, валідація конфігурацій
  2. Graceful shutdown: Завершення поточних операцій перед вимкненням (доопрацювання повідомлень з черги, завершення HTTP requests)

Для управління життєвим циклом використовується інтерфейс IHostApplicationLifetime, який доступний через DI:

public class LifecycleService
{
    private readonly IHostApplicationLifetime _lifetime;
    private readonly ILogger<LifecycleService> _logger;

    public LifecycleService(IHostApplicationLifetime lifetime, ILogger<LifecycleService> logger)
    {
        _lifetime = lifetime;
        _logger = logger;

        // Підписка на події життєвого циклу через CancellationToken
        _lifetime.ApplicationStarted.Register(OnStarted);
        _lifetime.ApplicationStopping.Register(OnStopping);
        _lifetime.ApplicationStopped.Register(OnStopped);
    }

    private void OnStarted()
    {
        _logger.LogInformation("Application has started successfully");
        // Тут можна виконати post-startup завдання: warming up caches, health check
    }

    private void OnStopping()
    {
        _logger.LogWarning("Application is stopping... Finishing current work");
        // Graceful shutdown: завершити активні операції, flush логів
    }

    private void OnStopped()
    {
        _logger.LogInformation("Application has stopped");
        // Cleanup: закрити з'єднання, зберегти стан
    }

    public void RequestShutdown()
    {
        _logger.LogWarning("Shutdown requested programmatically");
        _lifetime.StopApplication(); // Програмна зупинка всього застосунку
    }
}

IHostApplicationLifetime надає:

  • ApplicationStarted: Спрацьовує, коли програма успішно запустилася та готова приймати запити
  • ApplicationStopping: Спрацьовує, коли програма отримує сигнал на зупинку (Ctrl+C, SIGTERM)
  • ApplicationStopped: Спрацьовує, коли програма повністю зупинилася
  • StopApplication(): Метод для програмної зупинки застосунку

IHostedServices та фонове виконання завдань

IHostedService — це інтерфейс для реалізації фонових задач, які запускаються разом з хостом та працюють паралельно з основною логікою.

public interface IHostedService
{
    Task StartAsync(CancellationToken cancellationToken);
    Task StopAsync(CancellationToken cancellationToken);
}

Важливо: Не можна виконувати тривалу блокуючу операцію безпосередньо у StartAsync! Якщо це зробити, застосунок не зможе повністю запуститися, оскільки заблокується в очікуванні завершення StartAsync.

// ❌ НЕПРАВИЛЬНО - блокує запуск Host
public class BadHostedService : IHostedService
{
    public async Task StartAsync(CancellationToken cancellationToken)
    {
        // Блокуємо Host!
        while (!cancellationToken.IsCancellationRequested)
        {
            await ProcessQueueAsync();
            await Task.Delay(1000);
        }
    }

    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

Рішення: BackgroundService (рекомендована практика)

BackgroundService — абстрактний клас, що сам реалізує IHostedService і вимагає реалізації лише одного методу — ExecuteAsync. Він автоматично запускає логіку ExecuteAsync як окрему фонову задачу, що дозволяє Хосту продовжити запуск.

// ✅ ПРАВИЛЬНО - не блокує запуск Host
public class GoodHostedService : BackgroundService
{
    private readonly ILogger<GoodHostedService> _logger;

    public GoodHostedService(ILogger<GoodHostedService> logger)
    {
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("Background service started");

        // ExecuteAsync виконується у фоновому Task - не блокує Host
        while (!stoppingToken.IsCancellationRequested)
        {
            await ProcessQueueAsync();
            await Task.Delay(1000, stoppingToken);
        }

        _logger.LogInformation("Background service stopped");
    }
}

// Реєстрація
builder.Services.AddHostedService<GoodHostedService>();

Як це працює:

  1. Host викликає StartAsync() на BackgroundService
  2. StartAsync() створює фоновий Task для ExecuteAsync() і одразу повертається
  3. Host продовжує запуск, не чекаючи завершення ExecuteAsync()
  4. ExecuteAsync() працює паралельно з основною логікою (наприклад, веб-сервером)
  5. При зупинці Host викликає StopAsync(), який сигналізує CancellationToken
Best Practice: Завжди використовуйте BackgroundService замість прямої імплементації IHostedService для фонових задач. IHostedService резервуйте для випадків, коли потрібен контроль над самим процесом старту/зупинки.

Еволюція .NET Host

Етап 1: .NET Framework (до 2016)

Кожен тип додатку мав власну інфраструктуру:

  • ASP.NET: Global.asax, Web.config
  • Console: Ручна ініціалізація всього
  • Windows Services: ServiceBase, складний setup

Проблема: Немає єдиного підходу, багато дублювання.

Етап 2: ASP.NET Core 1.x - 2.0 (2016-2017)

Введення WebHost для веб-додатків:

WebHost.CreateDefaultBuilder(args)
    .UseStartup<Startup>()
    .Build()
    .Run();

Переваги: DI, Configuration, Logging з коробки. Недолік: Тільки для веб-додатків.

Етап 3: .NET Core 2.1+ Generic Host (2018)

IHost — універсальна інфраструктура для будь-яких додатків:

Host.CreateDefaultBuilder(args)
    .ConfigureServices((context, services) =>
    {
        services.AddHostedService<Worker>();
    })
    .Build()
    .Run();

Революція: Консольні додатки, Windows Services, Worker Services — всі використовують єдину інфраструктуру.

Етап 4: .NET 6+ Minimal APIs (2021)

Спрощений синтаксис з top-level statements:

var builder = WebApplication.CreateBuilder(args);

// Configure services
builder.Services.AddSingleton<IEmailService, EmailService>();

var app = builder.Build();

app.Run();

Переваги: Менше boilerplate, але та сама потужність.

Цілі цього розділу

Після вивчення матеріалу ви зможете:

  • Проектувати DI архітектуру: Правильно використовувати Transient, Scoped, Singleton lifetimes
  • Уникати DI anti-patterns: Captive Dependency, Service Locator, Circular Dependencies
  • Керувати конфігураціями: Options Pattern, Hot Reload, Environment-specific settings
  • Логувати професійно: Structured Logging, Serilog, High-performance LoggerMessage
  • Додавати Resilience: Polly patterns (Retry, Circuit Breaker, Timeout)
  • Створювати фонові сервіси: IHostedService з graceful shutdown

Передумови

Переконайтеся, що розумієте:


Частина 1: Dependency Injection

1.1. Що таке Dependency Injection та навіщо він потрібен?

Dependency Injection (DI) — це патерн проектування, який реалізує Inversion of Control (IoC): замість того, щоб клас сам створював свої залежності, вони надаються ззовні.

Якщо ваш код створює залежності через new, він їх контролює. Якщо залежності приходять ззовні — контроль інвертовано.

Проблема без DI

// ❌ Tight coupling - залежності створюються всередині класу
public class OrderService
{
    private readonly EmailService _emailService;
    private readonly PaymentProcessor _paymentProcessor;
    private readonly ILogger _logger;

    public OrderService()
    {
        // Hard-coded dependencies
        _emailService = new EmailService(new SmtpClient("smtp.gmail.com", 587));
        _paymentProcessor = new PaymentProcessor(new HttpClient());
        _logger = LogManager.GetLogger("OrderService");
    }

    public void ProcessOrder(Order order)
    {
        _paymentProcessor.Charge(order.Total);
        _emailService.SendConfirmation(order.CustomerEmail);
        _logger.LogInformation("Order processed: {OrderId}", order.Id);
    }
}

Проблеми цього коду:

  1. Tight Coupling: OrderService жорстко зв'язаний з EmailService, PaymentProcessor
  2. Неможливо тестувати: Як замокати SmtpClient або HttpClient?
  3. Порушення SRP: Клас відповідає і за бізнес-логіку, і за створення залежностей
  4. Відсутність lifecycle management: Хто викликає Dispose() на HttpClient?
  5. Конфігурація hard-coded: Зміна SMTP сервера потребує перекомпіляції

Вирішення через DI

// ✅ Loose coupling - залежності отримуємо через конструктор
public class OrderService
{
    private readonly IEmailService _emailService;
    private readonly IPaymentProcessor _paymentProcessor;
    private readonly ILogger<OrderService> _logger;

    // Constructor Injection - єдиний спосіб отримати залежності
    public OrderService(
        IEmailService emailService,
        IPaymentProcessor paymentProcessor,
        ILogger<OrderService> logger)
    {
        _emailService = emailService;
        _paymentProcessor = paymentProcessor;
        _logger = logger;
    }

    public void ProcessOrder(Order order)
    {
        _paymentProcessor.Charge(order.Total);
        _emailService.SendConfirmation(order.CustomerEmail);
        _logger.LogInformation("Order processed: {OrderId}", order.Id);
    }
}

// Реєстрація в DI Container
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<IEmailService, EmailService>();
builder.Services.AddScoped<IPaymentProcessor, PaymentProcessor>();
builder.Services.AddScoped<OrderService>();

var app = builder.Build();

Переваги DI підходу:

  • Loose Coupling: OrderService залежить від абстракцій (інтерфейсів)
  • Testability: Легко замокати IEmailService у тестах
  • Flexibility: Можна замінити імплементацію без зміни OrderService
  • Lifecycle Management: DI Container автоматично керує створенням та Dispose
  • Configuration: Налаштування виносяться в конфігурацію

1.2. DI Container та IServiceCollection

DI Container (також IoC Container) — це об'єкт, що відповідає за:

  1. Реєстрацію сервісів та їх імплементацій
  2. Створення інстансів з урахуванням залежностей
  3. Управління lifecycle (створення, переіспользування, disposal)

У .NET це IServiceCollection для реєстрації та IServiceProvider для resolve.

Базова архітектура DI

Loading diagram...
graph LR
    A[Application Startup] --> B[IServiceCollection]
    B -->|Register| C[Service Descriptors]
    C --> D[BuildServiceProvider]
    D --> E[IServiceProvider]
    E -->|Resolve| F[Service Instances]

    style B fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style E fill:#f59e0b,stroke:#b45309,color:#ffffff
    style F fill:#10b981,stroke:#059669,color:#ffffff

Реєстрація сервісів

var builder = WebApplication.CreateBuilder(args);

// IServiceCollection - колекція ServiceDescriptor'ів
var services = builder.Services;

// Різні способи реєстрації:

// 1. Interface → Implementation
services.AddSingleton<IEmailService, EmailService>();

// 2. Concrete type (Interface = Implementation)
services.AddScoped<OrderService>();

// 3. Factory method
services.AddTransient<IPaymentProcessor>(sp =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    var apiKey = config["PaymentGateway:ApiKey"];
    return new StripePaymentProcessor(apiKey);
});

// 4. Existing instance (only for singletons)
var cache = new MemoryCache(new MemoryCacheOptions());
services.AddSingleton<IMemoryCache>(cache);

// 5. Generic types
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));

Пояснення:

  • Рядок 9: Найпоширеніший спосіб — мапимо інтерфейс на конкретний клас
  • Рядок 12: Якщо немає інтерфейсу, реєструємо конкретний тип напряму
  • Рядок 15-19: Factory method дозволяє складну логіку створення з доступом до інших сервісів
  • Рядок 22-23: Реєстрація готового інстансу (рідко використовується)
  • Рядок 26: Open generic types для generic репозиторіїв

Resolve сервісів

var app = builder.Build();

// IServiceProvider - resolver для отримання сервісів
var serviceProvider = app.Services;

// 1. GetRequiredService - кидає exception якщо не знайдено
var emailService = serviceProvider.GetRequiredService<IEmailService>();

// 2. GetService - повертає null якщо не знайдено
var optionalService = serviceProvider.GetService<IOptionalService>();

// 3. GetServices - повертає всі реєстрації (для multiple implementations)
var handlers = serviceProvider.GetServices<INotificationHandler>();

// 4. Constructor Injection (preferred way)
public class MyController : ControllerBase
{
    private readonly IEmailService _emailService;

    public MyController(IEmailService emailService)
    {
        _emailService = emailService;
    }
}

Best Practices:

  • Constructor Injection завжди preferred: Сервіси отримуйте через конструктор
  • GetRequiredService для обов'язкових: Fail-fast якщо залежність відсутня
  • Уникайте прямих викликів GetService: Це Service Locator anti-pattern (про це нижче)

1.3. Service Lifetimes

Service Lifetime визначає, коли створюється інстанс сервісу та скільки часу він живе.

Три основні lifetimes

LifetimeОписКоли створюєтьсяКоли Dispose
TransientНовий інстанс при кожному запитіКожен раз при resolveОдразу після використання
ScopedОдин інстанс на scope (зазвичай HTTP request)Початок scopeКінець scope
SingletonОдин інстанс на весь час життя додаткуПерший resolve (або при startup)Shutdown додатку

Transient Lifetime

services.AddTransient<IGuidService, GuidService>();

public class GuidService : IGuidService
{
    private readonly Guid _instanceId = Guid.NewGuid();

    public Guid GetInstanceId() => _instanceId;
}

// Usage
public class MyController : ControllerBase
{
    private readonly IGuidService _guidService1;
    private readonly IGuidService _guidService2;

    public MyController(IGuidService guidService1, IGuidService guidService2)
    {
        _guidService1 = guidService1;
        _guidService2 = guidService2;
    }

    [HttpGet("transient")]
    public IActionResult Test()
    {
        // ❗ _guidService1 та _guidService2 — це РІЗНІ інстанси
        return Ok(new
        {
            service1 = _guidService1.GetInstanceId(),
            service2 = _guidService2.GetInstanceId()
        });
        // Output: {"service1": "abc-123", "service2": "def-456"}
    }
}

Коли використовувати Transient:

  • ✅ Stateless сервіси без внутрішнього стану
  • ✅ Легкі об'єкти, які швидко створюються
  • ✅ Сервіси, що не тримають ресурси (connections, files)

Приклади: Validators, Mappers, Utilities, DTOs processors.

Scoped Lifetime

services.AddScoped<IGuidService, GuidService>();

// В ASP.NET Core - один scope = один HTTP request
public class MyController : ControllerBase
{
    private readonly IGuidService _guidService1;
    private readonly IGuidService _guidService2;

    public MyController(IGuidService guidService1, IGuidService guidService2)
    {
        _guidService1 = guidService1;
        _guidService2 = guidService2;
    }

    [HttpGet("scoped")]
    public IActionResult Test()
    {
        // ✅ _guidService1 та _guidService2 — це ОДИН інстанс (в межах request)
        return Ok(new
        {
            service1 = _guidService1.GetInstanceId(),
            service2 = _guidService2.GetInstanceId()
        });
        // Output: {"service1": "abc-123", "service2": "abc-123"}
    }
}

Scope в різних типах додатків:

Тип додаткуScope
ASP.NET CoreHTTP Request
Worker ServiceManual scope через CreateScope()
Console AppManual scope
Blazor ServerSignalR Connection (Circuit)

Коли використовувати Scoped:

  • DbContext (EF Core): Один контекст на request
  • ✅ Сервіси з request-specific state (current user, request ID)
  • ✅ Unit of Work pattern

Приклади: DbContext, HttpContext, Repository per request.

Singleton Lifetime

services.AddSingleton<IGuidService, GuidService>();

// Єдиний інстанс для всього додатку
public class RequestController : ControllerBase
{
    private readonly IGuidService _guidService;

    public RequestController(IGuidService guidService)
    {
        _guidService = guidService;
    }

    [HttpGet("test")]
    public IActionResult Test()
    {
        return Ok(new { instanceId = _guidService.GetInstanceId() });
        // Завжди повертає той самий GUID незалежно від кількості запитів!
    }
}

Коли використовувати Singleton:

  • Thread-safe сервіси без mutable state
  • ✅ Configuration objects, Caches
  • ✅ Expensive-to-create об'єкти (connection pools)
  • ✅ Background services, Hosted services

Приклади: IMemoryCache, ILogger, IHttpClientFactory, Background workers.

КРИТИЧНО: Singleton сервіси повинні бути thread-safe! Вони використовуються паралельно з різних потоків.

Порівняння Lifetimes: Діаграма

Loading diagram...
sequenceDiagram
    participant App
    participant Container
    participant T as Transient
    participant Sc as Scoped
    participant Si as Singleton

    App->>Container: Request 1
    Container->>T: Create T1
    Container->>Sc: Create Sc1
    Container->>Si: Create Si1 (first time)
    App->>App: Process Request 1
    Note over T: T1 Disposed
    Note over Sc: Sc1 Disposed

    App->>Container: Request 2
    Container->>T: Create T2
    Container->>Sc: Create Sc2
    Container-->>Si: Reuse Si1
    App->>App: Process Request 2
    Note over T: T2 Disposed
    Note over Sc: Sc2 Disposed
    Note over Si: Si1 still alive

1.4. Scopes в DI

Scope — це логічна межа, в якій scoped сервіси мають один і той же інстанс.

Автоматичні Scopes (ASP.NET Core)

// ASP.NET Core автоматично створює scope для кожного HTTP request
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<AppDbContext>(); // EF Core DbContext

var app = builder.Build();

app.MapGet("/order/{id}", async (int id, IOrderService orderService) =>
{
    // orderService та AppDbContext всередині неї — одні і ті ж для цього request
    var order = await orderService.GetOrderAsync(id);
    return Results.Ok(order);
}); // Scope завершується тут, всі Scoped сервіси Dispose

app.Run();

Manual Scopes (Console/Worker)

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddScoped<IDataProcessor, DataProcessor>();
builder.Services.AddScoped<AppDbContext>();

var host = builder.Build();

// Manual scope creation
using (var scope = host.Services.CreateScope())
{
    var serviceProvider = scope.ServiceProvider;

    var processor = serviceProvider.GetRequiredService<IDataProcessor>();
    await processor.ProcessAsync();

    // Scope завершується тут — всі Scoped сервіси Dispose
}

// Новий scope
using (var scope = host.Services.CreateScope())
{
    var processor = scope.ServiceProvider.GetRequiredService<IDataProcessor>();
    await processor.ProcessAsync();
    // Новий інстанс IDataProcessor та AppDbContext!
}

Навіщо потрібні Manual Scopes:

  • Background tasks, що обробляють дані батчами
  • Long-running processes з періодичним оновленням DbContext
  • Console додатки, що імітують "request-like" логіку
EF Core Best Practice: Завжди використовуйте Scoped lifetime для DbContext. Один контекст на операцію/request.

1.5. DI Anti-patterns та Best Practices

Anti-pattern 1: Captive Dependency

Captive Dependency — коли сервіс з коротшим lifetime захоплюється сервісом з довшим lifetime.

// ❌ ANTI-PATTERN: Singleton captures Scoped
services.AddSingleton<CacheService>(); // Довгий lifetime
services.AddScoped<AppDbContext>();    // Короткий lifetime

public class CacheService
{
    private readonly AppDbContext _dbContext;

    // ❌ DbContext (Scoped) injection into Singleton
    public CacheService(AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<User> GetUserAsync(int id)
    {
        // ❌ Використовуємо той самий DbContext для всіх requests!
        return await _dbContext.Users.FindAsync(id);
    }
}

Проблеми:

  • 🐛 Stale data: DbContext не оновлюється між requests
  • 🐛 Threading issues: DbContext не thread-safe, але Singleton використовується паралельно
  • 🐛 Memory leaks: DbContext не Dispose між requests

Рішення 1: Змінити Lifetime

// ✅ Обидва Scoped
services.AddScoped<CacheService>();
services.AddScoped<AppDbContext>();

Рішення 2: IServiceScopeFactory

services.AddSingleton<CacheService>();
services.AddScoped<AppDbContext>();

public class CacheService
{
    private readonly IServiceScopeFactory _scopeFactory;

    // ✅ Inject IServiceScopeFactory замість DbContext
    public CacheService(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    public async Task<User> GetUserAsync(int id)
    {
        // ✅ Створюємо новий scope кожного разу
        using var scope = _scopeFactory.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();

        return await dbContext.Users.FindAsync(id);
    }
}

Validation:

// Включення перевірки в Development
builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = true; // Кидає exception при Captive Dependency
    options.ValidateOnBuild = true;
});

Anti-pattern 2: Service Locator

Service Locator — коли сервіси витягуються з IServiceProvider напряму замість Constructor Injection.

// ❌ ANTI-PATTERN: Service Locator
public class OrderService
{
    private readonly IServiceProvider _serviceProvider;

    public OrderService(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public async Task ProcessOrderAsync(Order order)
    {
        // ❌ Витягуємо залежності вручну
        var emailService = _serviceProvider.GetRequiredService<IEmailService>();
        var paymentProcessor = _serviceProvider.GetRequiredService<IPaymentProcessor>();

        await paymentProcessor.ChargeAsync(order.Total);
        await emailService.SendConfirmationAsync(order.CustomerEmail);
    }
}

Проблеми:

  • Hidden dependencies: Неясно які залежності потрібні (не видно в конструкторі)
  • Runtime failures: Помилки виявляються під час виконання, а не компіляції
  • Важко тестувати: Складно замокати IServiceProvider
  • Порушує SRP: Клас відповідає за resolve залежностей

Рішення: Constructor Injection

// ✅ Explicit dependencies
public class OrderService
{
    private readonly IEmailService _emailService;
    private readonly IPaymentProcessor _paymentProcessor;

    public OrderService(
        IEmailService emailService,
        IPaymentProcessor paymentProcessor)
    {
        _emailService = emailService;
        _paymentProcessor = paymentProcessor;
    }

    public async Task ProcessOrderAsync(Order order)
    {
        await _paymentProcessor.ChargeAsync(order.Total);
        await _emailService.SendConfirmationAsync(order.CustomerEmail);
    }
}
Виняток: IServiceProvider допустимий у фабриках або generic factory patterns, де потрібно динамічно створювати різні типи:
public class NotificationHandlerFactory
{
    private readonly IServiceProvider _serviceProvider;

    public NotificationHandlerFactory(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public INotificationHandler Create(NotificationType type)
    {
        return type switch
        {
            NotificationType.Email => _serviceProvider.GetRequiredService<EmailHandler>(),
            NotificationType.Sms => _serviceProvider.GetRequiredService<SmsHandler>(),
            _ => throw new ArgumentException("Unknown type")
        };
    }
}

Anti-pattern 3: Circular Dependencies

Circular Dependencies — коли два або більше сервісів залежать один від одного.

// ❌ ANTI-PATTERN: Circular dependency
public class OrderService
{
    private readonly IInventoryService _inventoryService;

    public OrderService(IInventoryService inventoryService)
    {
        _inventoryService = inventoryService;
    }
}

public class InventoryService : IInventoryService
{
    private readonly OrderService _orderService;

    // ❌ InventoryService залежить від OrderService, який залежить від IInventoryService
    public InventoryService(OrderService orderService)
    {
        _orderService = orderService;
    }
}

// Runtime error:
// System.InvalidOperationException: A circular dependency was detected

Рішення 1: Рефакторинг архітектури

Виділіть спільну логіку в окремий сервіс:

// ✅ Введіть проміжний сервіс
public interface IOrderValidator
{
    bool ValidateStock(int productId, int quantity);
}

public class OrderService
{
    private readonly IOrderValidator _validator;

    public OrderService(IOrderValidator validator)
    {
        _validator = validator;
    }
}

public class InventoryService : IInventoryService
{
    private readonly IOrderValidator _validator;

    public InventoryService(IOrderValidator validator)
    {
        _validator = validator;
    }
}

Рішення 2: Lazy Injection

// ✅ Використання Lazy<T> для отримання залежності лише коли потрібно
public class OrderService
{
    private readonly Lazy<IInventoryService> _inventoryService;

    public OrderService(Lazy<IInventoryService> inventoryService)
    {
        _inventoryService = inventoryService;
    }

    public void ProcessOrder()
    {
        // Сервіс створюється тут, а не в конструкторі
        var inventory = _inventoryService.Value;
    }
}

Best Practices для DI

✅ Constructor Injection Only
Завжди використовуйте Constructor Injection. Уникайте Property/Method Injection.
// ✅ Good
public class MyService
{
    private readonly IDependency _dependency;

    public MyService(IDependency dependency)
    {
        _dependency = dependency;
    }
}

// ❌ Bad - Property Injection
public class MyService
{
    public IDependency Dependency { get; set; }
}
✅ Залежте від Abstractions
Dependency Inversion Principle: залежте від інтерфейсів, а не конкретних класів.
// ✅ Good - залежить від інтерфейсу
public class OrderService
{
    private readonly IEmailService _emailService;

    public OrderService(IEmailService emailService) { }
}

// ❌ Bad - залежить від конкретного класу
public class OrderService
{
    private readonly SmtpEmailService _emailService;

    public OrderService(SmtpEmailService emailService) { }
}
✅ Перевіряйте Captive Dependencies
Включайте ValidateScopes в Development:
builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = Environment.IsDevelopment();
    options.ValidateOnBuild = true;
});
✅ Мінімізуйте Dependencies
Якщо конструктор має >5 залежностей — це code smell. Розділіть клас або використайте Facade.
// ❌ Too many dependencies
public class OrderService(
    IEmailService emailService,
    IPaymentService paymentService,
    IInventoryService inventoryService,
    IShippingService shippingService,
    INotificationService notificationService,
    ILoggingService loggingService,
    ICacheService cacheService)
{
}

// ✅ Розділіть на менші сервіси або використайте Facade
public class OrderFacade(
    IOrderProcessingService orderProcessing,
    IOrderNotificationService orderNotification)
{
}
✅ Dispose керується Container'ом
Не викликайте Dispose() вручну на сервісах з DI. Container це робить автоматично.
// ❌ Bad
public class MyService
{
    public void DoWork(IServiceProvider sp)
    {
        var dbContext = sp.GetRequiredService<AppDbContext>();
        dbContext.Dispose(); // ❌ НЕ робіть це!
    }
}

// ✅ Good - Container dispose автоматично
using (var scope = sp.CreateScope())
{
    var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    // dbContext.Dispose() викличеться автоматично
}

Частина 2: Configuration System

2.1. IConfiguration та Configuration Providers

Configuration System у .NET надає єдиний API для читання налаштувань з різних джерел.

Архітектура Configuration

Loading diagram...
graph TD
    A[IConfiguration] --> B[JSON Files]
    A --> C[Environment Variables]
    A --> D[User Secrets]
    A --> E[Command Line Args]
    A --> F[Custom Providers]

    B --> G[appsettings.json]
    B --> H[appsettings.Development.json]

    style A fill:#f59e0b,stroke:#b45309,color:#ffffff
    style B fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style C fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style D fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style E fill:#3b82f6,stroke:#1d4ed8,color:#ffffff

Default Configuration Sources

var builder = WebApplication.CreateBuilder(args);

// builder.Configuration автоматично завантажує (в порядку пріоритету):
// 1. appsettings.json
// 2. appsettings.{Environment}.json
// 3. User Secrets (лише в Development)
// 4. Environment Variables
// 5. Command Line Arguments

var config = builder.Configuration;

// Простий доступ до значень
var connectionString = config["ConnectionStrings:DefaultConnection"];
var logLevel = config["Logging:LogLevel:Default"];

// Hierarchical access
var smtpHost = config["EmailSettings:Smtp:Host"];

appsettings.json

{
    "ConnectionStrings": {
        "DefaultConnection": "Server=localhost;Database=MyApp;Trusted_Connection=True;"
    },
    "Logging": {
        "LogLevel": {
            "Default": "Information",
            "Microsoft.AspNetCore": "Warning"
        }
    },
    "EmailSettings": {
        "Smtp": {
            "Host": "smtp.gmail.com",
            "Port": 587,
            "EnableSsl": true
        },
        "From": "noreply@myapp.com"
    },
    "FeatureFlags": {
        "EnableNewCheckout": false,
        "MaxOrderItems": 100
    }
}

Environment-specific Configurations

// appsettings.Development.json
{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=MyApp_Dev;"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Debug" // Більш детальне логування в Dev
    }
  },
  "EmailSettings": {
    "Smtp": {
      "Host": "localhost", // Використовуємо локальний SMTP для тестів
      "Port": 25
    }
  }
}

// appsettings.Production.json
{
  "ConnectionStrings": {
    "DefaultConnection": "${DATABASE_CONNECTION_STRING}" // Підставляється з Environment Variable
  },
  "Logging": {
    "LogLevel": {
      "Default": "Warning" // Мінімум логів в prod
    }
  }
}

2.2. User Secrets (Development Only)

User Secrets — це механізм зберігання чутливих даних поза репозиторієм для Development.

Ініціалізація User Secrets

# CLI команда для ініціалізації
dotnet user-secrets init

# Додавання секрету
dotnet user-secrets set "EmailSettings:Smtp:Password" "my-secret-password"
dotnet user-secrets set "PaymentGateway:ApiKey" "sk_test_123456"

# Перегляд всіх секретів
dotnet user-secrets list

Секрети зберігаються в:

  • Windows: %APPDATA%\Microsoft\UserSecrets\<user_secrets_id>\secrets.json
  • macOS/Linux: ~/.microsoft/usersecrets/<user_secrets_id>/secrets.json
// secrets.json
{
    "EmailSettings:Smtp:Password": "my-secret-password",
    "PaymentGateway:ApiKey": "sk_test_123456"
}

Використання User Secrets

var builder = WebApplication.CreateBuilder(args);

// User Secrets автоматично завантажуються в Development
var config = builder.Configuration;

var smtpPassword = config["EmailSettings:Smtp:Password"]; // Читається з User Secrets
var apiKey = config["PaymentGateway:ApiKey"];

// Для production використовуйте Environment Variables або Azure Key Vault
ВАЖЛИВО: User Secrets — це НЕ encryption! Вони зберігаються у plain text, але поза проєктом. Для production використовуйте Azure Key Vault, AWS Secrets Manager або подібні.

2.3. Environment Variables

Environment Variables мають вищий пріоритет за JSON файли та переозначають їх.

Формат Environment Variables

# Hierarchical keys замінюють : на __ (подвійне підкреслення)
export ConnectionStrings__DefaultConnection="Server=prod-db;Database=MyApp;"
export EmailSettings__Smtp__Password="prod-password"
export Logging__LogLevel__Default="Warning"

# Простіший синтаксис (для non-hierarchical)
export ASPNETCORE_ENVIRONMENT="Production"
export ASPNETCORE_URLS="http://+:5000"

Використання в коді

var builder = WebApplication.CreateBuilder(args);

// Читання Environment Variables
var environment = builder.Environment.EnvironmentName; // "Development", "Staging", "Production"

// З IConfiguration (автоматично завантажено)
var connectionString = builder.Configuration["ConnectionStrings:DefaultConnection"];
// Якщо є Environment Variable, вона переозначить appsettings.json

Docker / Kubernetes приклад

# docker-compose.yml
version: '3.8'
services:
    api:
        image: myapp:latest
        environment:
            - ASPNETCORE_ENVIRONMENT=Production
            - ConnectionStrings__DefaultConnection=Server=postgres;Database=myapp;
            - EmailSettings__Smtp__Host=smtp.sendgrid.net
            - EmailSettings__Smtp__Password=${SMTP_PASSWORD} # З .env файлу

2.4. Options Pattern

Options Pattern — це спосіб strongly-typed доступу до конфігурацій через POCO класи.

Проблема без Options Pattern

// ❌ Stringly-typed - легко зробити помилку
public class EmailService
{
    private readonly IConfiguration _configuration;

    public EmailService(IConfiguration configuration)
    {
        _configuration = configuration;
    }

    public async Task SendEmailAsync(string to, string subject, string body)
    {
        var host = _configuration["EmailSetings:Smtp:Host"]; // ❌ Typo: "Setings"
        var port = int.Parse(_configuration["EmailSettings:Smtp:Port"]);
        var enableSsl = bool.Parse(_configuration["EmailSettings:Smtp:EnableSsl"]);

        // ... SMTP logic
    }
}

Проблеми:

  • ❌ Typos не виявляються на compile-time
  • ❌ Багато boilerplate для парсингу
  • ❌ Немає IntelliSense

Рішення: Options Pattern

Крок 1: Створіть POCO клас

public class EmailSettings
{
    public const string SectionName = "EmailSettings";

    public SmtpSettings Smtp { get; set; } = null!;
    public string From { get; set; } = string.Empty;
}

public class SmtpSettings
{
    public string Host { get; set; } = string.Empty;
    public int Port { get; set; }
    public bool EnableSsl { get; set; }
    public string? Username { get; set; }
    public string? Password { get; set; }
}

Крок 2: Зареєструйте в DI

var builder = WebApplication.CreateBuilder(args);

// Bind configuration section to POCO
builder.Services.Configure<EmailSettings>(
    builder.Configuration.GetSection(EmailSettings.SectionName));

var app = builder.Build();

Крок 3: Inject через IOptions

public class EmailService
{
    private readonly EmailSettings _settings;

    // ✅ Strongly-typed, compile-time safe
    public EmailService(IOptions<EmailSettings> options)
    {
        _settings = options.Value;
    }

    public async Task SendEmailAsync(string to, string subject, string body)
    {
        var host = _settings.Smtp.Host; // IntelliSense працює!
        var port = _settings.Smtp.Port;

        using var client = new SmtpClient(host, port)
        {
            EnableSsl = _settings.Smtp.EnableSsl,
            Credentials = new NetworkCredential(_settings.Smtp.Username, _settings.Smtp.Password)
        };

        // ... send email
    }
}

IOptions vs IOptionsSnapshot vs IOptionsMonitor

ІнтерфейсLifetimeHot ReloadNamed OptionsКоли використовувати
IOptionsSingleton❌ Ні✅ ТакСтатичні налаштування, що не змінюються
IOptionsSnapshotScoped✅ Так✅ ТакНалаштування, що можуть змінюватися per-request
IOptionsMonitorSingleton✅ Так✅ ТакРеакція на зміни в real-time

IOptionsSnapshot Приклад

services.Configure<FeatureFlags>(
    builder.Configuration.GetSection("FeatureFlags"));

// IOptionsSnapshot перечитує конфігурацію кожен request
public class CheckoutController : ControllerBase
{
    private readonly FeatureFlags _featureFlags;

    public CheckoutController(IOptionsSnapshot<FeatureFlags> options)
    {
        _featureFlags = options.Value; // Свіже значення для цього request
    }

    [HttpPost]
    public IActionResult ProcessCheckout()
    {
        if (_featureFlags.EnableNewCheckout)
        {
            return ProcessNewCheckout();
        }

        return ProcessLegacyCheckout();
    }
}

IOptionsMonitor Приклад (Hot Reload)

services.Configure<CacheSettings>(
    builder.Configuration.GetSection("CacheSettings"));

public class CacheService
{
    private readonly IOptionsMonitor<CacheSettings> _optionsMonitor;

    public CacheService(IOptionsMonitor<CacheSettings> optionsMonitor)
    {
        _optionsMonitor = optionsMonitor;

        // Реагуємо на зміни конфігурації
        _optionsMonitor.OnChange(settings =>
        {
            Console.WriteLine($"Cache TTL changed to: {settings.DefaultTtlMinutes}");
            ReconfigureCache(settings);
        });
    }

    public string GetValue(string key)
    {
        // Завжди актуальні налаштування
        var settings = _optionsMonitor.CurrentValue;
        return _cache.Get(key, settings.DefaultTtlMinutes);
    }
}

Validation в Options Pattern

public class EmailSettings
{
    public SmtpSettings Smtp { get; set; } = null!;
    public string From { get; set; } = string.Empty;
}

// Validation через Data Annotations
using System.ComponentModel.DataAnnotations;

public class SmtpSettings
{
    [Required]
    [RegularExpression(@"^[\w\.-]+$")]
    public string Host { get; set; } = string.Empty;

    [Range(1, 65535)]
    public int Port { get; set; }

    public bool EnableSsl { get; set; }
}

// Реєстрація з validation
builder.Services.AddOptions<EmailSettings>()
    .Bind(builder.Configuration.GetSection(EmailSettings.SectionName))
    .ValidateDataAnnotations() // Автоматична валідація через Data Annotations
    .ValidateOnStart(); // Валідація при старті додатку

Складніша валідація:

builder.Services.AddOptions<EmailSettings>()
    .Bind(builder.Configuration.GetSection(EmailSettings.SectionName))
    .Validate(settings =>
    {
        // Custom validation logic
        if (settings.Smtp.EnableSsl && settings.Smtp.Port == 25)
        {
            return false; // Port 25 не підтримує SSL
        }

        if (string.IsNullOrEmpty(settings.Smtp.Password) && settings.Smtp.EnableSsl)
        {
            return false; // SSL потребує password
        }

        return true;
    }, "Invalid SMTP configuration")
    .ValidateOnStart();

Частина 3: Logging

3.1. ILogger та Microsoft.Extensions.Logging

ILogger — це абстракція для логування, що дозволяє писати логи незалежно від конкретної імплементації.

Базовий Logging

public class OrderService
{
    private readonly ILogger<OrderService> _logger;

    public OrderService(ILogger<OrderService> logger)
    {
        _logger = logger;
    }

    public async Task<Order> CreateOrderAsync(CreateOrderRequest request)
    {
        _logger.LogInformation("Creating order for user {UserId}", request.UserId);

        try
        {
            var order = new Order { UserId = request.UserId, Total = request.Total };
            await _repository.AddAsync(order);

            _logger.LogInformation("Order {OrderId} created successfully", order.Id);
            return order;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to create order for user {UserId}", request.UserId);
            throw;
        }
    }
}

Log Levels

LevelЗначенняКоли використовувати
Trace0Найдетальніша інформація для debugging
Debug1Development debugging, не для production
Information2Загальний flow додатку
Warning3Незвичайні події, які не є помилками
Error4Помилки, що не зупиняють роботу додатку
Critical5Критичні збої, що потребують негайної уваги
_logger.LogTrace("Entering method ProcessOrder");
_logger.LogDebug("Cache miss for key {CacheKey}", key);
_logger.LogInformation("User {UserId} logged in", userId);
_logger.LogWarning("Retry attempt {Attempt} for order {OrderId}", attempt, orderId);
_logger.LogError(exception, "Payment failed for order {OrderId}", orderId);
_logger.LogCritical(exception, "Database connection lost!");

Structured Logging

Structured Logging — це логування з додатковими властивостями, а не просто текстом.

// ❌ String interpolation - втрачається структура
_logger.LogInformation($"User {userId} placed order {orderId} for ${total}");

// ✅ Structured logging - зберігає properties
_logger.LogInformation(
    "User {UserId} placed order {OrderId} for {Total:C}",
    userId,
    orderId,
    total);

У Serilog це виглядає як JSON:

{
    "@t": "2024-12-12T10:30:00.123Z",
    "@mt": "User {UserId} placed order {OrderId} for {Total}",
    "@l": "Information",
    "UserId": 12345,
    "OrderId": "ORD-98765",
    "Total": 149.99
}

Переваги:

  • ✅ Легко фільтрувати логи: UserId == 12345
  • ✅ Агрегація: "Скільки замовлень зробив користувач X?"
  • ✅ Корекляція: Знайти всі логи для конкретного OrderId

3.2. Serilog Integration

Serilog — найпопулярніша бібліотека structured logging для .NET.

Установка

dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
dotnet add package Serilog.Sinks.Seq  # Optional: для централізованого логування

Базова конфігурація

using Serilog;

var builder = WebApplication.CreateBuilder(args);

// Замінюємо стандартний logger на Serilog
builder.Host.UseSerilog((context, services, configuration) => configuration
    .ReadFrom.Configuration(context.Configuration)
    .ReadFrom.Services(services)
    .Enrich.FromLogContext()
    .WriteTo.Console()
    .WriteTo.File(
        path: "logs/log-.txt",
        rollingInterval: RollingInterval.Day,
        retainedFileCountLimit: 30));

var app = builder.Build();

// Логування HTTP requests
app.UseSerilogRequestLogging();

app.Run();

appsettings.json конфігурація

{
    "Serilog": {
        "Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"],
        "MinimumLevel": {
            "Default": "Information",
            "Override": {
                "Microsoft": "Warning",
                "Microsoft.Hosting.Lifetime": "Information",
                "Microsoft.EntityFrameworkCore": "Warning"
            }
        },
        "WriteTo": [
            { "Name": "Console" },
            {
                "Name": "File",
                "Args": {
                    "path": "logs/log-.txt",
                    "rollingInterval": "Day",
                    "retainedFileCountLimit": 30,
                    "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
                }
            }
        ],
        "Enrich": ["FromLogContext", "WithMachineName", "WithThreadId"]
    }
}

Serilog Enrichers

Enrichers додають додаткові властивості до кожного log event.

builder.Host.UseSerilog((context, services, configuration) => configuration
    .ReadFrom.Configuration(context.Configuration)
    .Enrich.WithProperty("Application", "MyECommerceApp")
    .Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName)
    .Enrich.FromLogContext()
    .Enrich.WithMachineName()
    .Enrich.WithThreadId()
    .Enrich.WithCorrelationId() // Потребує додатковий пакет
    .WriteTo.Console()
    .WriteTo.Seq("http://localhost:5341")); // Централізоване логування

LogContext для додавання контексту

public class OrderController : ControllerBase
{
    private readonly IOrderService _orderService;

    [HttpPost]
    public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
    {
        // Додаємо UserId до всіх логів у цьому scope
        using (LogContext.PushProperty("UserId", request.UserId))
        using (LogContext.PushProperty("CorrelationId", HttpContext.TraceIdentifier))
        {
            _logger.LogInformation("Processing order creation");

            var order = await _orderService.CreateOrderAsync(request);
            // Всі логи всередині OrderService матимуть UserId та CorrelationId!

            return Ok(order);
        }
    }
}

3.3. High-Performance Logging з LoggerMessage

LoggerMessage — це source generator для створення high-performance logging methods.

Проблема з звичайним логуванням

// ❌ Повільно: створюється FormattedLogValues, boxing для параметрів
_logger.LogInformation("Processing order {OrderId} for user {UserId}", orderId, userId);

Кожен виклик LogInformation:

  1. Створює масив object[] для параметрів
  2. Boxing для value types (int, Guid)
  3. Створює FormattedLogValues для форматування
  4. Allocations у heap

Рішення: LoggerMessage Source Generator (.NET 6+)

public partial class OrderService
{
    private readonly ILogger<OrderService> _logger;

    public OrderService(ILogger<OrderService> logger)
    {
        _logger = logger;
    }

    // ✅ Compile-time generated, zero allocations
    [LoggerMessage(
        EventId = 1000,
        Level = LogLevel.Information,
        Message = "Processing order {OrderId} for user {UserId}")]
    private partial void LogProcessingOrder(Guid orderId, int userId);

    [LoggerMessage(
        EventId = 1001,
        Level = LogLevel.Error,
        Message = "Failed to process order {OrderId}")]
    private partial void LogOrderFailed(Guid orderId, Exception exception);

    [LoggerMessage(
        EventId = 1002,
        Level = LogLevel.Warning,
        Message = "Retry attempt {Attempt} for order {OrderId}")]
    private partial void LogRetryAttempt(int attempt, Guid orderId);

    public async Task ProcessOrderAsync(Guid orderId, int userId)
    {
        LogProcessingOrder(orderId, userId);

        try
        {
            // Business logic
        }
        catch (Exception ex)
        {
            LogOrderFailed(orderId, ex);
            throw;
        }
    }
}

Згенерований код (приблизно):

private void LogProcessingOrder(Guid orderId, int userId)
{
    if (_logger.IsEnabled(LogLevel.Information))
    {
        _logger.Log(
            LogLevel.Information,
            new EventId(1000),
            new LogValues(orderId, userId),
            null,
            (state, ex) => $"Processing order {state.OrderId} for user {state.UserId}");
    }
}

Переваги:

  • ~5-10x швидше за звичайні LogInformation
  • Zero allocations для value types
  • Compile-time перевірка параметрів
  • EventId для фільтрації логів
Коли використовувати LoggerMessage:
  • High-traffic endpoints (>1000 RPS)
  • Логування в hot paths (loops)
  • Мікросервіси з великим навантаженням

Частина 4: Resilience з Polly

4.1. Навіщо потрібна Resilience?

У розподілених системах все може і буде падати:

  • API недоступний
  • База даних перевантажена
  • Мережа нестабільна
  • Third-party сервіси повертають 503

Resilience Patterns допомагають системі витримувати та відновлюватися після збоїв.

Установка Polly

dotnet add package Polly
dotnet add package Microsoft.Extensions.Http.Polly

4.2. Retry Pattern

Retry — повторити операцію при тимчасовому збої.

Базовий Retry

using Polly;
using Polly.Retry;

// Створення Retry pipeline
var retryPipeline = new ResiliencePipelineBuilder()
    .AddRetry(new RetryStrategyOptions
    {
        ShouldHandle = new PredicateBuilder()
            .Handle<HttpRequestException>()
            .Handle<TimeoutException>(),
        MaxRetryAttempts = 3,
        Delay = TimeSpan.FromSeconds(1),
        BackoffType = DelayBackoffType.Exponential, // 1s, 2s, 4s
        OnRetry = args =>
        {
            Console.WriteLine($"Retry {args.AttemptNumber} after {args.RetryDelay}");
            return ValueTask.CompletedTask;
        }
    })
    .Build();

// Використання
var result = await retryPipeline.ExecuteAsync(async ct =>
{
    return await httpClient.GetStringAsync("https://api.example.com/data", ct);
}, cancellationToken);

Retry з HttpClientFactory

builder.Services.AddHttpClient("external-api", client =>
{
    client.BaseAddress = new Uri("https://api.example.com");
    client.Timeout = TimeSpan.FromSeconds(10);
})
.AddResilienceHandler("retry-policy", builder =>
{
    builder.AddRetry(new RetryStrategyOptions
    {
        MaxRetryAttempts = 3,
        Delay = TimeSpan.FromSeconds(1),
        BackoffType = DelayBackoffType.Exponential,
        ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
            .Handle<HttpRequestException>()
            .HandleResult(response =>
                response.StatusCode == System.Net.HttpStatusCode.TooManyRequests ||
                (int)response.StatusCode >= 500)
    });
});

// Використання
public class ExternalApiService
{
    private readonly HttpClient _httpClient;

    public ExternalApiService(IHttpClientFactory httpClientFactory)
    {
        _httpClient = httpClientFactory.CreateClient("external-api");
    }

    public async Task<string> GetDataAsync()
    {
        // Retry автоматично застосовується!
        return await _httpClient.GetStringAsync("/data");
    }
}

4.3. Circuit Breaker Pattern

Circuit Breaker — тимчасово блокує виклики до сервісу, що падає, щоб не перевантажувати його.

Loading diagram...
stateDiagram-v2
    [*] --> Closed: Початковий стан
    Closed --> Open: Failure threshold досягнуто
    Open --> HalfOpen: Break duration минула
    HalfOpen --> Closed: Success threshold досягнуто
    HalfOpen --> Open: Failure detected

    note right of Closed: Requests проходять нормально
    note right of Open: Requests блокуються (fail fast)
    note right of HalfOpen: Тестові requests для перевірки

Circuit Breaker конфігурація

builder.Services.AddHttpClient("payment-gateway")
    .AddResilienceHandler("circuit-breaker", builder =>
    {
        builder.AddCircuitBreaker(new CircuitBreakerStrategyOptions
        {
            FailureRatio = 0.5,          // 50% помилок
            SamplingDuration = TimeSpan.FromSeconds(10), // За 10 секунд
            MinimumThroughput = 5,       // Мінімум 5 запитів
            BreakDuration = TimeSpan.FromSeconds(30),    // Відкрити на 30 сек

            ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
                .Handle<HttpRequestException>()
                .HandleResult(r => (int)r.StatusCode >= 500),

            OnOpened = args =>
            {
                Console.WriteLine($"Circuit breaker opened at {DateTime.Now}");
                return ValueTask.CompletedTask;
            },
            OnClosed = args =>
            {
                Console.WriteLine($"Circuit breaker closed at {DateTime.Now}");
                return ValueTask.CompletedTask;
            },
            OnHalfOpened = args =>
            {
                Console.WriteLine("Circuit breaker half-opened, testing...");
                return ValueTask.CompletedTask;
            }
        });
    });

Як це працює:

  1. Closed: Всі запити проходять. Рахуються failures.
  2. Open: Якщо failure ratio > 50% за 10 сек → Circuit відкривається. Всі запити одразу failяться з BrokenCircuitException.
  3. HalfOpen: Після 30 сек Circuit пропускає тестовий запит. Якщо успішний → Closed, якщо ні → Open знову.

4.4. Timeout Pattern

Timeout — обмежує час виконання операції.

builder.Services.AddHttpClient("slow-service")
    .AddResilienceHandler("timeout", builder =>
    {
        builder.AddTimeout(new TimeoutStrategyOptions
        {
            Timeout = TimeSpan.FromSeconds(5),
            OnTimeout = args =>
            {
                Console.WriteLine($"Timeout after {args.Timeout} for attempt {args.AttemptNumber}");
                return ValueTask.CompletedTask;
            }
        });
    });

4.5. Комбінування Policies

Best Practice: Комбінуйте Retry, Circuit Breaker та Timeout.

builder.Services.AddHttpClient("resilient-client")
    .AddResilienceHandler("resilience-pipeline", builder =>
    {
        // 1. Outer timeout - загальний ліміт для всіх спроб
        builder.AddTimeout(TimeSpan.FromSeconds(30));

        // 2. Retry з exponential backoff
        builder.AddRetry(new RetryStrategyOptions
        {
            MaxRetryAttempts = 3,
            Delay = TimeSpan.FromSeconds(1),
            BackoffType = DelayBackoffType.Exponential,
            ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
                .Handle<HttpRequestException>()
                .Handle<TimeoutRejectedException>() // Retry при timeout
                .HandleResult(r => (int)r.StatusCode >= 500)
        });

        // 3. Circuit Breaker для fail-fast
        builder.AddCircuitBreaker(new CircuitBreakerStrategyOptions
        {
            FailureRatio = 0.5,
            SamplingDuration = TimeSpan.FromSeconds(10),
            MinimumThroughput = 3,
            BreakDuration = TimeSpan.FromSeconds(30)
        });

        // 4. Inner timeout - per attempt
        builder.AddTimeout(TimeSpan.FromSeconds(5));
    });

Порядок виконання:

Request → Outer Timeout (30s) → Retry → Circuit Breaker → Inner Timeout (5s) → HTTP Call

Частина 5: Background Services

5.1. IHostedService та BackgroundService

IHostedService — це інтерфейс для long-running задач, що запускаються разом з додатком.

IHostedService Interface

public interface IHostedService
{
    Task StartAsync(CancellationToken cancellationToken);
    Task StopAsync(CancellationToken cancellationToken);
}

BackgroundService Base Class

public abstract class BackgroundService : IHostedService
{
    protected abstract Task ExecuteAsync(CancellationToken stoppingToken);

    public virtual Task StartAsync(CancellationToken cancellationToken)
    {
        // Запускає ExecuteAsync у фоновому Task
    }

    public virtual Task StopAsync(CancellationToken cancellationToken)
    {
        // Чекає завершення ExecuteAsync з graceful shutdown
    }
}

5.2. Простий Background Service

public class EmailQueueProcessor : BackgroundService
{
    private readonly ILogger<EmailQueueProcessor> _logger;
    private readonly IServiceScopeFactory _scopeFactory;

    public EmailQueueProcessor(
        ILogger<EmailQueueProcessor> logger,
        IServiceScopeFactory scopeFactory)
    {
        _logger = logger;
        _scopeFactory = scopeFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("Email Queue Processor started");

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                // Створюємо scope для Scoped dependencies
                using var scope = _scopeFactory.CreateScope();
                var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();

                await emailService.ProcessQueueAsync(stoppingToken);

                // Чекаємо перед наступною ітерацією
                await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
            }
            catch (OperationCanceledException)
            {
                // Graceful shutdown
                _logger.LogInformation("Email Queue Processor stopping");
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error processing email queue");
                // Продовжуємо роботу навіть при помилці
                await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
            }
        }

        _logger.LogInformation("Email Queue Processor stopped");
    }
}

// Реєстрація
builder.Services.AddHostedService<EmailQueueProcessor>();

5.3. Background Service з PeriodicTimer

public class DataSyncService : BackgroundService
{
    private readonly ILogger<DataSyncService> _logger;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly TimeSpan _period = TimeSpan.FromMinutes(5);

    public DataSyncService(
        ILogger<DataSyncService> logger,
        IServiceScopeFactory scopeFactory)
    {
        _logger = logger;
        _scopeFactory = scopeFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(_period);

        while (!stoppingToken.IsCancellationRequested &&
               await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                _logger.LogInformation("Starting data sync at {Time}", DateTime.UtcNow);

                using var scope = _scopeFactory.CreateScope();
                var syncService = scope.ServiceProvider.GetRequiredService<ISyncService>();

                await syncService.SyncDataAsync(stoppingToken);

                _logger.LogInformation("Data sync completed successfully");
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Data sync failed");
            }
        }
    }
}

5.4. Graceful Shutdown

public class OrderProcessorService : BackgroundService
{
    private readonly ILogger<OrderProcessorService> _logger;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("Order Processor started");

        // Реєструємо callback для graceful shutdown
        stoppingToken.Register(() =>
            _logger.LogInformation("Graceful shutdown initiated"));

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await ProcessNextOrderAsync(stoppingToken);
            }
            catch (OperationCanceledException)
            {
                // Нормальне завершення
                break;
            }
        }

        _logger.LogInformation("Order Processor stopped gracefully");
    }

    public override async Task StopAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("Stop requested, finishing current work...");

        // Базовий StopAsync чекає завершення ExecuteAsync
        await base.StopAsync(cancellationToken);

        _logger.LogInformation("All work completed, service stopped");
    }
}

Конфігурація Shutdown Timeout:

builder.Host.ConfigureServices(services =>
{
    services.Configure<HostOptions>(options =>
    {
        options.ShutdownTimeout = TimeSpan.FromSeconds(30); // Максимальний час на graceful shutdown
    });
});

Частина 6: Практичні сценарії

6.1. E-Commerce Order Processing Service

Комплексний приклад Worker Service з DI, Configuration, Logging, Resilience та Background processing.

// Program.cs
using Serilog;

var builder = Host.CreateApplicationBuilder(args);

// Serilog
builder.Host.UseSerilog((context, services, configuration) => configuration
    .ReadFrom.Configuration(context.Configuration)
    .Enrich.FromLogContext()
    .WriteTo.Console()
    .WriteTo.File("logs/order-processor-.txt", rollingInterval: RollingInterval.Day));

// Configuration Options
builder.Services.Configure<OrderProcessorSettings>(
    builder.Configuration.GetSection("OrderProcessor"));
builder.Services.Configure<PaymentGatewaySettings>(
    builder.Configuration.GetSection("PaymentGateway"));

// DI Services
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IInventoryService, InventoryService>();
builder.Services.AddScoped<IPaymentService, PaymentService>();
builder.Services.AddScoped<INotificationService, NotificationService>();

// HTTP Clients з Resilience
builder.Services.AddHttpClient<IPaymentService, PaymentService>()
    .AddResilienceHandler("payment-resilience", resilienceBuilder =>
    {
        resilienceBuilder
            .AddTimeout(TimeSpan.FromSeconds(30))
            .AddRetry(new RetryStrategyOptions
            {
                MaxRetryAttempts = 3,
                Delay = TimeSpan.FromSeconds(1),
                BackoffType = DelayBackoffType.Exponential
            })
            .AddCircuitBreaker(new CircuitBreakerStrategyOptions
            {
                FailureRatio = 0.5,
                SamplingDuration = TimeSpan.FromSeconds(10),
                MinimumThroughput = 3,
                BreakDuration = TimeSpan.FromSeconds(30)
            });
    });

// Background Service
builder.Services.AddHostedService<OrderProcessorWorker>();

var host = builder.Build();
await host.RunAsync();

// OrderProcessorWorker.cs
public class OrderProcessorWorker : BackgroundService
{
    private readonly ILogger<OrderProcessorWorker> _logger;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly IOptions<OrderProcessorSettings> _settings;

    public OrderProcessorWorker(
        ILogger<OrderProcessorWorker> logger,
        IServiceScopeFactory scopeFactory,
        IOptions<OrderProcessorSettings> settings)
    {
        _logger = logger;
        _scopeFactory = scopeFactory;
        _settings = settings;
    }

    [LoggerMessage(
        EventId = 1000,
        Level = LogLevel.Information,
        Message = "Processing order batch of {Count} orders")]
    private partial void LogProcessingBatch(int count);

    [LoggerMessage(
        EventId = 1001,
        Level = LogLevel.Information,
        Message = "Order {OrderId} processed successfully in {ElapsedMs}ms")]
    private partial void LogOrderProcessed(Guid orderId, long elapsedMs);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("Order Processor started");

        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_settings.Value.PoolingIntervalSeconds));

        while (!stoppingToken.IsCancellationRequested &&
               await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                await ProcessPendingOrdersAsync(stoppingToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to process orders batch");
            }
        }

        _logger.LogInformation("Order Processor stopped");
    }

    private async Task ProcessPendingOrdersAsync(CancellationToken cancellationToken)
    {
        using var scope = _scopeFactory.CreateScope();
        var orderRepository = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
        var inventoryService = scope.ServiceProvider.GetRequiredService<IInventoryService>();
        var paymentService = scope.ServiceProvider.GetRequiredService<IPaymentService>();
        var notificationService = scope.ServiceProvider.GetRequiredService<INotificationService>();

        var pendingOrders = await orderRepository.GetPendingOrdersAsync(
            _settings.Value.BatchSize,
            cancellationToken);

        if (pendingOrders.Count == 0)
            return;

        LogProcessingBatch(pendingOrders.Count);

        foreach (var order in pendingOrders)
        {
            var stopwatch = Stopwatch.StartNew();

            using (LogContext.PushProperty("OrderId", order.Id))
            using (LogContext.PushProperty("UserId", order.UserId))
            {
                try
                {
                    // 1. Reserve Inventory
                    await inventoryService.ReserveAsync(order, cancellationToken);

                    // 2. Process Payment (with resilience)
                    await paymentService.ChargeAsync(order, cancellationToken);

                    // 3. Update Order Status
                    order.Status = OrderStatus.Completed;
                    await orderRepository.UpdateAsync(order, cancellationToken);

                    // 4. Send Notification
                    await notificationService.SendOrderConfirmationAsync(order, cancellationToken);

                    stopwatch.Stop();
                    LogOrderProcessed(order.Id, stopwatch.ElapsedMilliseconds);
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Failed to process order {OrderId}", order.Id);

                    order.Status = OrderStatus.Failed;
                    order.FailureReason = ex.Message;
                    await orderRepository.UpdateAsync(order, cancellationToken);
                }
            }
        }
    }
}

Резюме

Dependency Injection

Ключові концепції:

  • IServiceCollection для реєстрації
  • Три lifetimes: Transient, Scoped, Singleton
  • Constructor Injection preferred
  • Уникайте Captive Dependency, Service Locator, Circular Dependencies

Коли використовувати:

  • Transient: Stateless, lightweight сервіси
  • Scoped: DbContext, per-request state
  • Singleton: Caches, Loggers, Expensive objects

Configuration

Providers:

  • appsettings.json (base + environment-specific)
  • Environment Variables
  • User Secrets (dev only)
  • Command Line Arguments

Options Pattern:

  • IOptions<T>: Static configuration
  • IOptionsSnapshot<T>: Per-request reload
  • IOptionsMonitor<T>: Hot reload with OnChange

Logging

ILogger: Standard abstraction

Serilog: Structured logging

  • Enrichers для додаткового контексту
  • LogContext для scoped properties
  • Multiple sinks (Console, File, Seq)

LoggerMessage: High-performance, zero-allocation logging для hot paths

Resilience (Polly)

Patterns:

  • Retry: Exponential backoff при transient failures
  • Circuit Breaker: Fail-fast при багатьох помилках
  • Timeout: Обмеження часу виконання

Best Practice: Комбінуйте patterns: Outer Timeout → Retry → Circuit Breaker → Inner Timeout

Background Services

IHostedService: Lifecycle management

BackgroundService: Base class для long-running tasks

Best Practices:

  • IServiceScopeFactory для Scoped dependencies
  • PeriodicTimer для періодичних задач
  • Graceful Shutdown з CancellationToken
  • HostOptions.ShutdownTimeout

Практичні завдання

Завдання 1: DI Container Setup

Створіть консольний додаток з:

  • Generic Host
  • 3 сервіси (Transient, Scoped, Singleton)
  • Демонстрація різниці між lifetimes
  • Валідація Captive Dependency

Завдання 2: Configuration з Options Pattern

Реалізуйте:

  • appsettings.json з hierarchical config
  • Environment-specific overrides
  • Options Pattern з валідацією
  • User Secrets для API keys

Завдання 3: Structured Logging

Налаштуйте Serilog з:

  • Console та File sinks
  • Enrichers (Machine, Thread, Custom)
  • LogContext для correlation ID
  • LoggerMessage для high-performance логування

Завдання 4: Resilient HTTP Client

Створіть HTTP client з:

  • Retry (3 спроби, exponential backoff)
  • Circuit Breaker (50% failure ratio, 30s break)
  • Timeout (5s per attempt, 30s total)
  • Логування всіх resilience events

Завдання 5: Background Worker Service

Реалізуйте Worker Service, що:

  • Обробляє черга задач кожні 30 секунд
  • Використовує Scoped DbContext
  • Логує всі операції через Serilog
  • Graceful shutdown з завершенням поточної роботи

Додаткові ресурси

Наступна тема: Clean ArchitectureУ наступному розділі ми розглянемо, як побудувати maintainable архітектуру через Clean Architecture principles, Domain-Driven Design та CQRS pattern.