04Архитектура Приложений
Принципы проектирования
SOLID Principles
Single Responsibility Principle (SRP)
Определение: Класс должен иметь только одну причину для изменения. Одна ответственность = одна причина изменения.
Ключевое различие: SRP ≠ Separation of Concerns. SoC — это архитектурный принцип разделения системы на части с разными задачами. SRP — это принцип на уровне класса/модуля.
Плохой пример — God Class
// Нарушает SRP: отвечает за бизнес-логику, валидацию, логирование, отправку email, сохранение в БД
public class OrderService
{
public void ProcessOrder(Order order)
{
// 1. Валидация
if (order.Items.Count == 0) throw new ArgumentException("Order is empty");
if (order.TotalAmount <= 0) throw new ArgumentException("Invalid amount");
// 2. Бизнес-логика расчёта
decimal tax = order.TotalAmount * 0.2m;
decimal discount = order.TotalAmount > 1000 ? order.TotalAmount * 0.1m : 0;
order.FinalAmount = order.TotalAmount + tax - discount;
// 3. Логирование
Console.WriteLine($"Processing order {order.Id} at {DateTime.UtcNow}");
// 4. Сохранение в БД
using var connection = new SqlConnection("...");
connection.Execute("INSERT INTO Orders ...", order);
// 5. Отправка email
var client = new SmtpClient("smtp.example.com");
client.Send("orders@example.com", order.CustomerEmail, "Order confirmed", "...");
// 6. Обновление инвентаря
foreach (var item in order.Items)
{
connection.Execute("UPDATE Products SET Stock = Stock - @qty", new { item.ProductId, item.Quantity });
}
}
}Рефакторинг с применением SRP
// 1. Валидация — отдельный класс
public interface IOrderValidator
{
ValidationResult Validate(Order order);
}
public class OrderValidator : IOrderValidator
{
public ValidationResult Validate(Order order)
{
if (order.Items.Count == 0) return ValidationResult.Fail("Order is empty");
if (order.TotalAmount <= 0) return ValidationResult.Fail("Invalid amount");
return ValidationResult.Success();
}
}
// 2. Расчёт стоимости — отдельный класс
public interface IOrderCalculator
{
decimal CalculateFinalAmount(Order order);
}
public class OrderCalculator : IOrderCalculator
{
private const decimal TaxRate = 0.2m;
private const decimal DiscountThreshold = 1000m;
private const decimal DiscountRate = 0.1m;
public decimal CalculateFinalAmount(Order order)
{
var tax = order.TotalAmount * TaxRate;
var discount = order.TotalAmount > DiscountThreshold ? order.TotalAmount * DiscountRate : 0;
return order.TotalAmount + tax - discount;
}
}
// 3. Репозиторий — отдельный класс
public interface IOrderRepository
{
Task SaveAsync(Order order, CancellationToken ct = default);
Task UpdateInventoryAsync(IEnumerable<OrderItem> items, CancellationToken ct = default);
}
// 4. Уведомления — отдельный класс
public interface INotificationService
{
Task SendOrderConfirmationAsync(Order order, CancellationToken ct = default);
}
// 5. Координатор — тонкий класс, который только оркестрирует
public class OrderProcessor
{
private readonly IOrderValidator _validator;
private readonly IOrderCalculator _calculator;
private readonly IOrderRepository _repository;
private readonly INotificationService _notification;
public OrderProcessor(
IOrderValidator validator,
IOrderCalculator calculator,
IOrderRepository repository,
INotificationService notification)
{
_validator = validator;
_calculator = calculator;
_repository = repository;
_notification = notification;
}
public async Task ProcessOrderAsync(Order order, CancellationToken ct = default)
{
var validation = _validator.Validate(order);
if (!validation.IsSuccess) throw new ValidationException(validation.Error);
order.FinalAmount = _calculator.CalculateFinalAmount(order);
await _repository.SaveAsync(order, ct);
await _repository.UpdateInventoryAsync(order.Items, ct);
await _notification.SendOrderConfirmationAsync(order, ct);
}
}Преимущества:
- Каждый класс можно тестировать изолированно
- Легко заменить одну реализацию (например, email на SMS)
- Изменения в логике расчёта не затрагивают валидацию
- Параллельная разработка разными членами команды
Open/Closed Principle (OCP)
Определение: Сущности должны быть открыты для расширения, но закрыты для модификации.
Плохой пример — Switch/If-Else на стероидах
// Нарушает OCP: каждое новое условие требует модификации этого класса
public class DiscountCalculator
{
public decimal CalculateDiscount(Order order, string customerType)
{
return customerType switch
{
"Regular" => order.TotalAmount > 1000 ? order.TotalAmount * 0.05m : 0,
"Premium" => order.TotalAmount > 500 ? order.TotalAmount * 0.1m : order.TotalAmount * 0.05m,
"VIP" => order.TotalAmount * 0.15m,
"Wholesale" => order.TotalAmount * 0.2m,
_ => throw new ArgumentException($"Unknown customer type: {customerType}")
};
}
}Рефакторинг с OCP — Strategy Pattern
// Абстракция стратегии
public interface IDiscountStrategy
{
decimal CalculateDiscount(Order order);
string CustomerType { get; }
}
// Конкретные стратегии
public class RegularDiscount : IDiscountStrategy
{
public string CustomerType => "Regular";
public decimal CalculateDiscount(Order order) =>
order.TotalAmount > 1000 ? order.TotalAmount * 0.05m : 0;
}
public class PremiumDiscount : IDiscountStrategy
{
public string CustomerType => "Premium";
public decimal CalculateDiscount(Order order) =>
order.TotalAmount > 500 ? order.TotalAmount * 0.1m : order.TotalAmount * 0.05m;
}
public class VIPDiscount : IDiscountStrategy
{
public string CustomerType => "VIP";
public decimal CalculateDiscount(Order order) => order.TotalAmount * 0.15m;
}
// Контекст, который использует стратегии
public class DiscountCalculator
{
private readonly IReadOnlyDictionary<string, IDiscountStrategy> _strategies;
public DiscountCalculator(IEnumerable<IDiscountStrategy> strategies)
{
_strategies = strategies.ToDictionary(s => s.CustomerType);
}
public decimal CalculateDiscount(Order order, string customerType)
{
if (!_strategies.TryGetValue(customerType, out var strategy))
throw new ArgumentException($"Unknown customer type: {customerType}");
return strategy.CalculateDiscount(order);
}
}
// Регистрация в DI — добавление новой стратегии НЕ требует изменения DiscountCalculator
builder.Services.AddSingleton<IDiscountStrategy, RegularDiscount>();
builder.Services.AddSingleton<IDiscountStrategy, PremiumDiscount>();
builder.Services.AddSingleton<IDiscountStrategy, VIPDiscount>();
// Новая стратегия — просто добавляем ещё одну регистрацию
builder.Services.AddSingleton<IDiscountStrategy, WholesaleDiscount>();Liskov Substitution Principle (LSP)
Определение: Объекты базового типа должны быть заменяемы объектами производного типа без изменения корректности программы.
Формально: Если S — подтип T, то объекты типа T в программе могут быть заменены объектами типа S без изменения желательных свойств программы.
Нарушение LSP — классический пример с квадратом и прямоугольником
public class Rectangle
{
public virtual double Width { get; set; }
public virtual double Height { get; set; }
public double Area => Width * Height;
}
// Нарушает LSP: изменяет поведение унаследованных свойств
public class Square : Rectangle
{
private double _side;
public override double Width
{
get => _side;
set => _side = value;
}
public override double Height
{
get => _side;
set => _side = value;
}
}
// Клиентский код ломается при подстановке Square
public class AreaCalculator
{
public double CalculateArea(Rectangle rect)
{
rect.Width = 5;
rect.Height = 10;
return rect.Area; // Ожидаем 50, но для Square получим 100!
}
}Правильное решение
// Общий интерфейс только для чтения
public interface IShape
{
double Area { get; }
}
// Изменяемый прямоугольник
public class Rectangle : IShape
{
public double Width { get; set; }
public double Height { get; set; }
public double Area => Width * Height;
}
// Квадрат — отдельная сущность
public class Square : IShape
{
public double Side { get; set; }
public double Area => Side * Side;
}
// Или: Value Objects без наследования
public readonly record struct Rectangle(double Width, double Height)
{
public double Area => Width * Height;
}
public readonly record struct Square(double Side)
{
public double Area => Side * Side;
}Нарушение LSP в .NET — NotSupportedException
// Нарушает LSP: ReadOnlyCollection выбрасывает NotSupportedException
// на методах Add/Remove, которые есть в базовом IList<T>
var readOnly = new ReadOnlyCollection<int>(new List<int> { 1, 2, 3 });
IList<int> list = readOnly;
list.Add(4); // NotSupportedException!Правило: Если производный класс выбрасывает NotSupportedException или NotImplementedException на методы базового — это почти всегда нарушение LSP.
Interface Segregation Principle (ISP)
Определение: Клиенты не должны зависеть от интерфейсов, которые они не используют. Лучше много маленьких интерфейсов, чем один большой.
Нарушение ISP — Fat Interface
// Нарушает ISP: принуждает реализовывать все методы
public interface IRepository<T>
{
Task<T> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync();
Task AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(T entity);
Task<int> CountAsync();
Task<bool> ExistsAsync(int id);
Task<IEnumerable<T>> SearchAsync(string query);
Task ImportAsync(IEnumerable<T> entities);
Task ExportAsync(Stream output);
}
// Read-only репозиторий вынужден реализовывать методы модификации
public class ReadOnlyProductRepository : IRepository<Product>
{
public Task<Product> GetByIdAsync(int id) => ...
public Task<IEnumerable<Product>> GetAllAsync() => ...
// Не поддерживаемые операции
public Task AddAsync(Product entity) => throw new NotSupportedException();
public Task UpdateAsync(Product entity) => throw new NotSupportedException();
public Task DeleteAsync(Product entity) => throw new NotSupportedException();
public Task ImportAsync(IEnumerable<Product> entities) => throw new NotSupportedException();
// ...
}Правильное решение — сегрегация интерфейсов
// Базовый интерфейс только для чтения
public interface IReadableRepository<T>
{
Task<T> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync();
Task<int> CountAsync();
Task<bool> ExistsAsync(int id);
Task<IEnumerable<T>> SearchAsync(string query);
}
// Интерфейс для записи
public interface IWritableRepository<T>
{
Task AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(T entity);
}
// Интерфейс для массовых операций
public interface IBulkRepository<T>
{
Task ImportAsync(IEnumerable<T> entities);
Task ExportAsync(Stream output);
}
// Полный репозиторий комбинирует интерфейсы
public interface IRepository<T> :
IReadableRepository<T>,
IWritableRepository<T>
// Read-only — только то, что нужно
public interface IReadOnlyRepository<T> : IReadableRepository<T>Dependency Inversion Principle (DIP)
Определение:
- Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций.
- Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Нарушение DIP — прямая зависимость от конкретных классов
// Нарушает DIP: OrderService напрямую зависит от SqlOrderRepository и SmtpEmailSender
public class OrderService
{
private readonly SqlOrderRepository _repository; // конкретная реализация
private readonly SmtpEmailSender _emailSender; // конкретная реализация
public OrderService()
{
_repository = new SqlOrderRepository("connectionString");
_emailSender = new SmtpEmailSender("smtp.example.com");
}
public async Task ProcessOrderAsync(Order order)
{
await _repository.SaveAsync(order);
await _emailSender.SendAsync(order.CustomerEmail, "Order confirmed");
}
}Правильное решение — зависимость от абстракций
// Абстракции (интерфейсы)
public interface IOrderRepository
{
Task SaveAsync(Order order, CancellationToken ct = default);
}
public interface IEmailSender
{
Task SendAsync(string to, string subject, CancellationToken ct = default);
}
// Зависимость от абстракций через constructor injection
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly IEmailSender _emailSender;
public OrderService(IOrderRepository repository, IEmailSender emailSender)
{
_repository = repository;
_emailSender = emailSender;
}
public async Task ProcessOrderAsync(Order order, CancellationToken ct = default)
{
await _repository.SaveAsync(order, ct);
await _emailSender.SendAsync(order.CustomerEmail, "Order confirmed", ct);
}
}
// Конкретные реализации зависят от абстракций
public class SqlOrderRepository : IOrderRepository { ... }
public class SmtpEmailSender : IEmailSender { ... }
// Регистрация в DI
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();DRY, KISS, YAGNI, Boy Scout Rule
DRY (Don't Repeat Yourself)
Принцип: Каждая часть знания должна иметь единственное, однозначное, авторитетное представление в системе.
Важно: DRY — не про удаление дублирования кода, а про устранение дублирования знания.
// Нарушение DRY: знание о формате даты дублируется
public class OrderController
{
public string FormatDate(DateTime date) => date.ToString("dd.MM.yyyy HH:mm");
}
public class InvoiceController
{
public string FormatDate(DateTime date) => date.ToString("dd.MM.yyyy HH:mm");
}
// Решение: единый источник знания
public static class DateTimeFormatters
{
public const string DefaultFormat = "dd.MM.yyyy HH:mm";
public static string FormatDefault(DateTime date) => date.ToString(DefaultFormat);
}KISS (Keep It Simple, Stupid)
Принцип: Простота должна быть ключевой целью дизайна. Избегайте ненужной сложности.
// Избыточно сложно
public class StringUtilities
{
public static string Transform(string input)
{
if (input == null) throw new ArgumentNullException();
if (input.Length == 0) return input;
var builder = new StringBuilder();
for (int i = 0; i < input.Length; i++)
{
builder.Append(char.ToUpper(input[i]));
}
return builder.ToString();
}
}
// KISS
public static class StringUtilities
{
public static string ToUpperSafe(string input) => input?.ToUpper() ?? string.Empty;
}YAGNI (You Ain't Gonna Need It)
Принцип: Не добавляйте функциональность, пока она не понадобится.
// YAGNI violation: абстракция для "будущих" баз данных, которые никогда не появятся
public interface IDatabase
{
void Connect();
void Disconnect();
void ExecuteQuery(string sql);
void ExecuteStoredProcedure(string name, params object[] parameters);
void BeginTransaction();
void CommitTransaction();
void RollbackTransaction();
// ... ещё 20 методов для "будущей поддержки" Oracle, MongoDB, etc.
}
// Реализуется только для SQL Server
public class SqlServerDatabase : IDatabase { ... }Boy Scout Rule
Принцип: Всегда оставляйте код чище, чем он был до вас.
- Увидели дублирование? Вынесите в метод.
- Увидели плохое имя? Переименуйте.
- Увидели длинный метод? Разбейте.
- Увидели магическое число? Создайте константу.
GRASP Patterns
Information Expert
Принцип: Назначайте ответственность тому классу, который обладает необходимой информацией.
// Плохо: контроллер делает вычисления, хотя информация в Order
public class OrderController
{
public decimal CalculateTotal(Order order)
{
decimal total = 0;
foreach (var item in order.Items)
{
total += item.Price * item.Quantity;
}
return total;
}
}
// Хорошо: Order знает свои items и может посчитать total
public class Order
{
public IReadOnlyList<OrderItem> Items { get; }
public decimal CalculateTotal() =>
Items.Sum(item => item.Price * item.Quantity);
}Creator
Принцип: Назначайте классу B ответственность за создание экземпляра класса A, если:
- B содержит или агрегирует A
- B записывает A
- B использует A
- B имеет инициализирующие данные для A
// Order создаёт OrderItem — он содержит items и имеет данные для инициализации
public class Order
{
private readonly List<OrderItem> _items = new();
public IReadOnlyList<OrderItem> Items => _items;
public void AddItem(Product product, int quantity)
{
var item = new OrderItem(product, quantity); // Creator
_items.Add(item);
}
}High Cohesion / Low Coupling
Cohesion (связность): Насколько тесно связаны методы класса с его основной ответственностью.
Coupling (зацепление): Насколько сильно класс зависит от других классов.
// Низкая связность, высокое зацепление
public class UserService
{
public void RegisterUser(User user) { ... }
public void SendEmail(string to, string body) { ... }
public void GeneratePdfReport(ReportData data) { ... }
public void ConnectToDatabase(string connectionString) { ... }
public void LogToFile(string message) { ... }
}
// Зависит от: EmailService, PdfGenerator, Database, Logger — высокое зацепление
// Высокая связность, низкое зацепление
public class UserService
{
private readonly IUserRepository _repository;
private readonly IEmailService _email;
public UserService(IUserRepository repository, IEmailService email)
{
_repository = repository;
_email = email;
}
public async Task RegisterUserAsync(User user)
{
await _repository.SaveAsync(user);
await _email.SendWelcomeEmailAsync(user.Email);
}
}
// Зависит только от абстракций, методы связаны одной задачей — управление пользователямиComposition over Inheritance
Принцип: Предпочитайте композицию (включение объектов) наследованию (is-a relationship).
Проблемы наследования:
- Хрупкий базовый класс (Fragile Base Class)
- Жёсткая связь на этапе компиляции
- Невозможность изменения поведения в runtime
- Нарушение инкапсуляции (базовый класс видит internals производного)
// Наследование — жёстко, сложно расширять
public class Bird
{
public virtual void Fly() { }
public virtual void Swim() { }
}
public class Penguin : Bird
{
public override void Fly() => throw new InvalidOperationException("Penguins can't fly!");
}
// Композиция — гибко, легко расширять
public interface IFlyBehavior { void Fly(); }
public interface ISwimBehavior { void Swim(); }
public class NoFly : IFlyBehavior { public void Fly() { } }
public class CanFly : IFlyBehavior { public void Fly() => Console.WriteLine("Flying"); }
public class CanSwim : ISwimBehavior { public void Swim() => Console.WriteLine("Swimming"); }
public class Bird
{
private readonly IFlyBehavior _flyBehavior;
private readonly ISwimBehavior _swimBehavior;
public Bird(IFlyBehavior fly, ISwimBehavior swim)
{
_flyBehavior = fly;
_swimBehavior = swim;
}
public void Fly() => _flyBehavior.Fly();
public void Swim() => _swimBehavior.Swim();
}
var penguin = new Bird(new NoFly(), new CanSwim());
var eagle = new Bird(new CanFly(), new CanSwim());Практика
Задание 1: Refactor God Class
Рефакторинг класса PaymentProcessor, который нарушает все принципы SOLID:
public class PaymentProcessor
{
public void ProcessPayment(Payment payment)
{
// Валидация + расчёт + логирование + БД + email + кэш + аналитика
// ... 200+ строк кода
}
}Задача: Разделить на классы с единственной ответственностью, применить DI, покрыть unit-тестами.
Задание 2: DI Container Lifecycle
Реализовать простой DI container с поддержкой:
- Transient: новый экземпляр при каждом запросе
- Scoped: один экземпляр на scope
- Singleton: один экземпляр на всё время жизни
Задание 3: Unit Tests
Написать тесты, доказывающие:
- Каждый класс после рефакторинга работает корректно
- Стратегии взаимозаменяемы (LSP)
- Новые стратегии добавляются без изменения существующего кода (OCP)
Паттерны проектирования (GoF + .NET-specific)
Creational Patterns
Singleton
Назначение: Гарантирует, что класс имеет только один экземпляр, и предоставляет глобальную точку доступа.
Thread-safe реализация в .NET
// Вариант 1: Lazy<T> — рекомендуемый подход
public sealed class Configuration
{
private static readonly Lazy<Configuration> _instance =
new(() => new Configuration(), LazyThreadSafetyMode.ExecutionAndPublication);
public static Configuration Instance => _instance.Value;
private readonly Dictionary<string, string> _settings = new();
private Configuration()
{
// Загрузка настроек из файла, БД, etc.
_settings["MaxRetries"] = "3";
_settings["Timeout"] = "30";
}
public string GetSetting(string key) =>
_settings.TryGetValue(key, out var value) ? value : null;
}
// Вариант 2: Double-check locking
public sealed class DatabaseConnectionPool
{
private static DatabaseConnectionPool _instance;
private static readonly object _lock = new();
public static DatabaseConnectionPool Instance
{
get
{
if (_instance == null)
{
lock (_lock)
{
if (_instance == null)
{
_instance = new DatabaseConnectionPool();
}
}
}
return _instance;
}
}
private DatabaseConnectionPool() { }
}Важно: В .NET DI контейнере Singleton регистрируется через AddSingleton<T>() — это предпочтительнее ручного паттерна.
Factory Method
Назначение: Определяет интерфейс для создания объекта, но позволяет подклассам решить, какой класс инстанцировать.
// Продукт
public interface IPaymentGateway
{
Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request, CancellationToken ct = default);
}
// Конкретные продукты
public class StripeGateway : IPaymentGateway { ... }
public class PayPalGateway : IPaymentGateway { ... }
public class SquareGateway : IPaymentGateway { ... }
// Creator с Factory Method
public abstract class PaymentGatewayFactory
{
public abstract IPaymentGateway CreateGateway();
public IPaymentGateway GetConfiguredGateway()
{
var gateway = CreateGateway();
// Общая логика настройки для всех gateway
ConfigureGateway(gateway);
return gateway;
}
protected virtual void ConfigureGateway(IPaymentGateway gateway) { }
}
// Concrete Creators
public class StripeFactory : PaymentGatewayFactory
{
private readonly string _apiKey;
public StripeFactory(string apiKey) => _apiKey = apiKey;
public override IPaymentGateway CreateGateway() => new StripeGateway(_apiKey);
}
public class PayPalFactory : PaymentGatewayFactory
{
private readonly string _clientId;
private readonly string _clientSecret;
public PayPalFactory(string clientId, string clientSecret)
{
_clientId = clientId;
_clientSecret = clientSecret;
}
public override IPaymentGateway CreateGateway() => new PayPalGateway(_clientId, _clientSecret);
}Abstract Factory
Назначение: Предоставляет интерфейс для создания семейств связанных объектов без указания их конкретных классов.
// Семейство продуктов — UI элементы
public interface IButton { void Render(); }
public interface ICheckbox { void Render(); }
public interface ITextBox { void Render(); }
// Factory interface
public interface IUIFactory
{
IButton CreateButton();
ICheckbox CreateCheckbox();
ITextBox CreateTextBox();
}
// Concrete factories — Windows
public class WindowsUIFactory : IUIFactory
{
public IButton CreateButton() => new WindowsButton();
public ICheckbox CreateCheckbox() => new WindowsCheckbox();
public ITextBox CreateTextBox() => new WindowsTextBox();
}
// Concrete factories — Web
public class WebUIFactory : IUIFactory
{
public IButton CreateButton() => new HtmlButton();
public ICheckbox CreateCheckbox() => new HtmlCheckbox();
public ITextBox CreateTextBox() => new HtmlTextBox();
}
// Client — работает с абстрактной фабрикой
public class Dialog
{
private readonly IUIFactory _factory;
public Dialog(IUIFactory factory) => _factory = factory;
public void Render()
{
_factory.CreateButton().Render();
_factory.CreateCheckbox().Render();
_factory.CreateTextBox().Render();
}
}Builder
Назначение: Отделяет конструирование сложного объекта от его представления.
// Продукт
public class HttpRequest
{
public string Method { get; init; }
public Uri Uri { get; init; }
public Dictionary<string, string> Headers { get; } = new();
public HttpContent Content { get; init; }
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
public bool FollowRedirects { get; init; } = true;
}
// Builder
public class HttpRequestBuilder
{
private string _method = "GET";
private Uri _uri;
private readonly Dictionary<string, string> _headers = new();
private HttpContent _content;
private TimeSpan _timeout = TimeSpan.FromSeconds(30);
private bool _followRedirects = true;
public HttpRequestBuilder WithMethod(string method)
{
_method = method;
return this;
}
public HttpRequestBuilder WithUri(string uri)
{
_uri = new Uri(uri);
return this;
}
public HttpRequestBuilder WithHeader(string name, string value)
{
_headers[name] = value;
return this;
}
public HttpRequestBuilder WithContent(HttpContent content)
{
_content = content;
return this;
}
public HttpRequestBuilder WithTimeout(TimeSpan timeout)
{
_timeout = timeout;
return this;
}
public HttpRequestBuilder NoRedirects()
{
_followRedirects = false;
return this;
}
public HttpRequest Build()
{
if (_uri == null) throw new InvalidOperationException("Uri is required");
var request = new HttpRequest
{
Method = _method,
Uri = _uri,
Content = _content,
Timeout = _timeout,
FollowRedirects = _followRedirects
};
foreach (var (key, value) in _headers)
request.Headers[key] = value;
return request;
}
}
// Fluent API использование
var request = new HttpRequestBuilder()
.WithMethod("POST")
.WithUri("https://api.example.com/orders")
.WithHeader("Authorization", "Bearer token")
.WithHeader("Content-Type", "application/json")
.WithContent(new StringContent("{\"item\": 1}"))
.WithTimeout(TimeSpan.FromSeconds(60))
.Build();Prototype
Назначение: Создание новых объектов путём копирования существующего прототипа.
public abstract class Document : ICloneable
{
public string Title { get; set; }
public string Author { get; set; }
public List<string> Tags { get; set; } = new();
public abstract Document Clone();
}
public class Report : Document
{
public string ReportType { get; set; }
public Dictionary<string, object> Data { get; set; } = new();
public override Document Clone()
{
// Deep clone
return new Report
{
Title = this.Title,
Author = this.Author,
Tags = new List<string>(this.Tags),
ReportType = this.ReportType,
Data = new Dictionary<string, object>(this.Data)
};
}
}
// Использование
var template = new Report
{
Title = "Monthly Report",
Author = "System",
Tags = new List<string> { "monthly", "auto" },
ReportType = "Financial"
};
var januaryReport = (Report)template.Clone();
januaryReport.Title = "January Report";
januaryReport.Data["Month"] = "January";
var februaryReport = (Report)template.Clone();
februaryReport.Title = "February Report";
februaryReport.Data["Month"] = "February";Object Pool
Назначение: Переиспользование дорогостоящих объектов вместо создания новых.
public class ExpensiveResource
{
public int Id { get; }
public DateTime CreatedAt { get; }
public bool IsInUse { get; set; }
public ExpensiveResource(int id)
{
Id = id;
CreatedAt = DateTime.UtcNow;
Console.WriteLine($"Resource {id} created");
}
public void Reset()
{
IsInUse = false;
Console.WriteLine($"Resource {id} reset");
}
}
public class ObjectPool<T> where T : class
{
private readonly ConcurrentBag<T> _available = new();
private readonly Func<T> _factory;
private readonly Action<T> _resetAction;
private int _totalCreated;
public ObjectPool(Func<T> factory, Action<T> resetAction, int initialSize = 0)
{
_factory = factory;
_resetAction = resetAction;
for (int i = 0; i < initialSize; i++)
{
_available.Add(CreateNew());
}
}
public T Get()
{
if (_available.TryTake(out var item))
{
return item;
}
return CreateNew();
}
public void Return(T item)
{
_resetAction(item);
_available.Add(item);
}
private T CreateNew()
{
Interlocked.Increment(ref _totalCreated);
return _factory();
}
}
// Использование
var pool = new ObjectPool<ExpensiveResource>(
() => new ExpensiveResource(Interlocked.Increment(ref _counter)),
r => r.Reset(),
initialSize: 5
);
var resource = pool.Get();
try
{
// Используем resource
}
finally
{
pool.Return(resource);
}Structural Patterns
Adapter
Назначение: Преобразует интерфейс одного класса в интерфейс, ожидаемый клиентом.
// Существующий legacy сервис
public class LegacyPaymentProcessor
{
public bool MakePayment(string cardNumber, decimal amount, string currency)
{
// Legacy API
return true;
}
}
// Целевой интерфейс
public interface IPaymentService
{
Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct = default);
}
// Адаптер
public class LegacyPaymentAdapter : IPaymentService
{
private readonly LegacyPaymentProcessor _legacy;
public LegacyPaymentAdapter(LegacyPaymentProcessor legacy) => _legacy = legacy;
public Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct = default)
{
bool success = _legacy.MakePayment(
request.CardNumber,
request.Amount,
request.Currency
);
return Task.FromResult(new PaymentResult
{
Success = success,
TransactionId = Guid.NewGuid().ToString(),
Amount = request.Amount
});
}
}Decorator
Назначение: Динамически добавляет объекту новую функциональность, оборачивая его.
// Базовый интерфейс
public interface IEmailService
{
Task SendAsync(string to, string subject, string body, CancellationToken ct = default);
}
// Базовая реализация
public class SmtpEmailService : IEmailService
{
public async Task SendAsync(string to, string subject, string body, CancellationToken ct = default)
{
// Отправка через SMTP
await Task.Delay(100, ct);
Console.WriteLine($"Email sent to {to}");
}
}
// Decorator: логирование
public class LoggingEmailDecorator : IEmailService
{
private readonly IEmailService _inner;
private readonly ILogger<LoggingEmailDecorator> _logger;
public LoggingEmailDecorator(IEmailService inner, ILogger<LoggingEmailDecorator> logger)
{
_inner = inner;
_logger = logger;
}
public async Task SendAsync(string to, string subject, string body, CancellationToken ct = default)
{
_logger.LogInformation("Sending email to {To} with subject {Subject}", to, subject);
try
{
await _inner.SendAsync(to, subject, body, ct);
_logger.LogInformation("Email sent successfully to {To}", to);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send email to {To}", to);
throw;
}
}
}
// Decorator: retry
public class RetryEmailDecorator : IEmailService
{
private readonly IEmailService _inner;
private readonly int _maxRetries;
public RetryEmailDecorator(IEmailService inner, int maxRetries = 3)
{
_inner = inner;
_maxRetries = maxRetries;
}
public async Task SendAsync(string to, string subject, string body, CancellationToken ct = default)
{
for (int i = 0; i < _maxRetries; i++)
{
try
{
await _inner.SendAsync(to, subject, body, ct);
return;
}
catch when (i < _maxRetries - 1)
{
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, i)), ct);
}
}
}
}
// Decorator: queue (асинхронная отправка)
public class QueuedEmailDecorator : IEmailService
{
private readonly IEmailService _inner;
private readonly Channel<EmailMessage> _queue;
public QueuedEmailDecorator(IEmailService inner)
{
_inner = inner;
_queue = Channel.CreateBounded<EmailMessage>(1000);
_ = ProcessQueueAsync();
}
public Task SendAsync(string to, string subject, string body, CancellationToken ct = default)
{
var message = new EmailMessage(to, subject, body);
return _queue.Writer.WriteAsync(message, ct).AsTask();
}
private async Task ProcessQueueAsync()
{
await foreach (var message in _queue.Reader.ReadAllAsync())
{
await _inner.SendAsync(message.To, message.Subject, message.Body);
}
}
}
// Регистрация цепочки декораторов в DI
builder.Services.AddSingleton<IEmailService, SmtpEmailService>();
builder.Services.Decorate<IEmailService, LoggingEmailDecorator>();
builder.Services.Decorate<IEmailService, RetryEmailDecorator>();
// Scrutor package для DecorateFacade
Назначение: Предоставляет унифицированный интерфейс к набору интерфейсов в подсистеме.
// Подсистема: множество сложных классов
public class InventoryService { public bool CheckStock(int productId) => true; }
public class PricingService { public decimal GetPrice(int productId) => 100m; }
public class ShippingService { public decimal CalculateShipping(Address address) => 10m; }
public class TaxService { public decimal CalculateTax(decimal amount) => amount * 0.2m; }
public class NotificationService { public Task SendConfirmationAsync(string email) => Task.CompletedTask; }
// Facade — простой интерфейс для клиента
public class OrderFacade
{
private readonly InventoryService _inventory;
private readonly PricingService _pricing;
private readonly ShippingService _shipping;
private readonly TaxService _tax;
private readonly NotificationService _notification;
public OrderFacade(
InventoryService inventory,
PricingService pricing,
ShippingService shipping,
TaxService tax,
NotificationService notification)
{
_inventory = inventory;
_pricing = pricing;
_shipping = shipping;
_tax = tax;
_notification = notification;
}
public async Task<OrderResult> PlaceOrderAsync(OrderRequest request, CancellationToken ct = default)
{
if (!_inventory.CheckStock(request.ProductId))
throw new InvalidOperationException("Product out of stock");
var price = _pricing.GetPrice(request.ProductId);
var shipping = _shipping.CalculateShipping(request.Address);
var tax = _tax.CalculateTax(price);
var total = price + shipping + tax;
await _notification.SendConfirmationAsync(request.CustomerEmail);
return new OrderResult
{
ProductId = request.ProductId,
TotalAmount = total,
Tax = tax,
Shipping = shipping
};
}
}Proxy
Назначение: Предоставляет суррогат или заглушку для другого объекта для контроля доступа.
// Реальный сервис
public interface IImageService
{
byte[] LoadImage(string url);
}
public class RemoteImageService : IImageService
{
public byte[] LoadImage(string url)
{
// Дорогостоящая операция — загрузка из сети
using var client = new HttpClient();
return client.GetByteArrayAsync(url).Result;
}
}
// Virtual Proxy — ленивая загрузка
public class LazyImageProxy : IImageService
{
private readonly string _url;
private IImageService _realService;
private byte[] _cachedImage;
public LazyImageProxy(string url) => _url = url;
public byte[] LoadImage(string url)
{
if (_cachedImage == null)
{
_realService ??= new RemoteImageService();
_cachedImage = _realService.LoadImage(_url);
}
return _cachedImage;
}
}
// Protection Proxy — контроль доступа
public class ProtectedImageService : IImageService
{
private readonly IImageService _inner;
private readonly IAuthorizationService _auth;
public ProtectedImageService(IImageService inner, IAuthorizationService auth)
{
_inner = inner;
_auth = auth;
}
public byte[] LoadImage(string url)
{
if (!_auth.IsAuthorized("ImageAccess"))
throw new UnauthorizedAccessException("Access denied");
return _inner.LoadImage(url);
}
}Behavioral Patterns
Strategy
Назначение: Определяет семейство алгоритмов, инкапсулирует каждый и делает их взаимозаменяемыми.
// Стратегия
public interface ICompressionStrategy
{
byte[] Compress(byte[] data);
byte[] Decompress(byte[] data);
string AlgorithmName { get; }
}
// Конкретные стратегии
public class GzipCompression : ICompressionStrategy
{
public string AlgorithmName => "GZIP";
public byte[] Compress(byte[] data)
{
using var output = new MemoryStream();
using var gzip = new GZipStream(output, CompressionLevel.Optimal);
gzip.Write(data, 0, data.Length);
return output.ToArray();
}
public byte[] Decompress(byte[] data)
{
using var input = new MemoryStream(data);
using var gzip = new GZipStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();
gzip.CopyTo(output);
return output.ToArray();
}
}
public class BrotliCompression : ICompressionStrategy
{
public string AlgorithmName => "BROTLI";
public byte[] Compress(byte[] data)
{
using var output = new MemoryStream();
using var brotli = new BrotliStream(output, CompressionLevel.Optimal);
brotli.Write(data, 0, data.Length);
return output.ToArray();
}
public byte[] Decompress(byte[] data)
{
using var input = new MemoryStream(data);
using var brotli = new BrotliStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();
brotli.CopyTo(output);
return output.ToArray();
}
}
// Контекст
public class CompressionService
{
private readonly ICompressionStrategy _strategy;
public CompressionService(ICompressionStrategy strategy) => _strategy = strategy;
public byte[] Compress(byte[] data) => _strategy.Compress(data);
public byte[] Decompress(byte[] data) => _strategy.Decompress(data);
public string Algorithm => _strategy.AlgorithmName;
}Observer
Назначение: Определяет зависимость "один ко многим" между объектами так, что при изменении состояния одного все зависимые уведомляются.
// .NET встроенный подход — events
public class StockPrice
{
public string Symbol { get; }
public decimal Price { get; private set; }
public event EventHandler<PriceChangedEventArgs> PriceChanged;
public StockPrice(string symbol) => Symbol = symbol;
public void UpdatePrice(decimal newPrice)
{
if (Price != newPrice)
{
var oldPrice = Price;
Price = newPrice;
PriceChanged?.Invoke(this, new PriceChangedEventArgs(Symbol, oldPrice, newPrice));
}
}
}
public class PriceChangedEventArgs : EventArgs
{
public string Symbol { get; }
public decimal OldPrice { get; }
public decimal NewPrice { get; }
public PriceChangedEventArgs(string symbol, decimal oldPrice, decimal newPrice)
{
Symbol = symbol;
OldPrice = oldPrice;
NewPrice = newPrice;
}
}
// Подписчики
public class PriceAlertObserver
{
private readonly decimal _threshold;
public PriceAlertObserver(decimal threshold) => _threshold = threshold;
public void OnPriceChanged(object sender, PriceChangedEventArgs e)
{
if (e.NewPrice >= _threshold)
{
Console.WriteLine($"ALERT: {e.Symbol} reached {e.NewPrice} (threshold: {_threshold})");
}
}
}
public class LoggerObserver
{
public void OnPriceChanged(object sender, PriceChangedEventArgs e)
{
Console.WriteLine($"[{DateTime.UtcNow:HH:mm:ss}] {e.Symbol}: {e.OldPrice} -> {e.NewPrice}");
}
}
// Использование
var stock = new StockPrice("AAPL");
stock.PriceChanged += new PriceAlertObserver(150).OnPriceChanged;
stock.PriceChanged += new LoggerObserver().OnPriceChanged;
stock.UpdatePrice(145);
stock.UpdatePrice(152); // Trigger alertCommand
Назначение: Инкапсулирует запрос как объект, позволяя параметризовать клиентов с разными запросами.
// Команда
public interface ICommand
{
void Execute();
void Undo();
}
// Конкретные команды
public class CreateOrderCommand : ICommand
{
private readonly IOrderRepository _repository;
private readonly Order _order;
public CreateOrderCommand(IOrderRepository repository, Order order)
{
_repository = repository;
_order = order;
}
public void Execute() => _repository.Add(_order);
public void Undo() => _repository.Delete(_order.Id);
}
public class UpdateOrderStatusCommand : ICommand
{
private readonly IOrderRepository _repository;
private readonly int _orderId;
private readonly OrderStatus _newStatus;
private OrderStatus _oldStatus;
public UpdateOrderStatusCommand(IOrderRepository repository, int orderId, OrderStatus newStatus)
{
_repository = repository;
_orderId = orderId;
_newStatus = newStatus;
}
public void Execute()
{
var order = _repository.GetById(_orderId);
_oldStatus = order.Status;
order.Status = _newStatus;
_repository.Update(order);
}
public void Undo()
{
var order = _repository.GetById(_orderId);
order.Status = _oldStatus;
_repository.Update(order);
}
}
// Invoker с undo/redo
public class CommandHistory
{
private readonly Stack<ICommand> _undoStack = new();
private readonly Stack<ICommand> _redoStack = new();
public void Execute(ICommand command)
{
command.Execute();
_undoStack.Push(command);
_redoStack.Clear();
}
public bool CanUndo => _undoStack.Count > 0;
public bool CanRedo => _redoStack.Count > 0;
public void Undo()
{
if (!CanUndo) return;
var command = _undoStack.Pop();
command.Undo();
_redoStack.Push(command);
}
public void Redo()
{
if (!CanRedo) return;
var command = _redoStack.Pop();
command.Execute();
_undoStack.Push(command);
}
}Chain of Responsibility
Назначение: Позволяет передавать запрос по цепочке обработчиков.
// Обработчик
public abstract class Handler
{
protected Handler _next;
public Handler SetNext(Handler handler)
{
_next = handler;
return handler;
}
public abstract Task<ValidationResult> HandleAsync(Request request, CancellationToken ct = default);
}
// Конкретные обработчики
public class AuthenticationHandler : Handler
{
public override async Task<ValidationResult> HandleAsync(Request request, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(request.Token))
return ValidationResult.Fail("Authentication required");
if (!IsValidToken(request.Token))
return ValidationResult.Fail("Invalid token");
return _next != null ? await _next.HandleAsync(request, ct) : ValidationResult.Success();
}
private bool IsValidToken(string token) => token.StartsWith("Bearer ");
}
public class AuthorizationHandler : Handler
{
private readonly HashSet<string> _adminEndpoints = new() { "/admin", "/settings" };
public override async Task<ValidationResult> HandleAsync(Request request, CancellationToken ct = default)
{
if (_adminEndpoints.Contains(request.Path) && !request.Roles.Contains("Admin"))
return ValidationResult.Fail("Admin access required");
return _next != null ? await _next.HandleAsync(request, ct) : ValidationResult.Success();
}
}
public class RateLimitHandler : Handler
{
private readonly Dictionary<string, Queue<DateTime>> _requests = new();
private readonly int _maxRequestsPerMinute = 60;
public override async Task<ValidationResult> HandleAsync(Request request, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
if (!_requests.TryGetValue(request.ClientId, out var timestamps))
{
timestamps = new Queue<DateTime>();
_requests[request.ClientId] = timestamps;
}
// Очистка старых запросов
while (timestamps.Count > 0 && timestamps.Peek() < now.AddMinutes(-1))
timestamps.Dequeue();
if (timestamps.Count >= _maxRequestsPerMinute)
return ValidationResult.Fail("Rate limit exceeded");
timestamps.Enqueue(now);
return _next != null ? await _next.HandleAsync(request, ct) : ValidationResult.Success();
}
}
// Построение цепочки
var chain = new AuthenticationHandler()
.SetNext(new AuthorizationHandler())
.SetNext(new RateLimitHandler());
var result = await chain.HandleAsync(request);Mediator
Назначение: Определяет объект, инкапсулирующий то, как взаимодействует набор объектов.
// Mediator интерфейс
public interface IChatMediator
{
void SendMessage(string message, User sender);
void AddUser(User user);
}
// Concrete Mediator
public class ChatRoom : IChatMediator
{
private readonly List<User> _users = new();
public void AddUser(User user)
{
_users.Add(user);
user.SetMediator(this);
}
public void SendMessage(string message, User sender)
{
foreach (var user in _users.Where(u => u != sender))
{
user.ReceiveMessage(message, sender.Name);
}
}
}
// Colleague
public abstract class User
{
public string Name { get; }
protected IChatMediator _mediator;
protected User(string name) => Name = name;
public void SetMediator(IChatMediator mediator) => _mediator = mediator;
public abstract void SendMessage(string message);
public abstract void ReceiveMessage(string message, string from);
}
public class ConcreteUser : User
{
public ConcreteUser(string name) : base(name) { }
public override void SendMessage(string message) => _mediator.SendMessage(message, this);
public override void ReceiveMessage(string message, string from) =>
Console.WriteLine($"[{Name}] received from {from}: {message}");
}
// Использование
var chatRoom = new ChatRoom();
var alice = new ConcreteUser("Alice");
var bob = new ConcreteUser("Bob");
var charlie = new ConcreteUser("Charlie");
chatRoom.AddUser(alice);
chatRoom.AddUser(bob);
chatRoom.AddUser(charlie);
alice.SendMessage("Hello everyone!");Specification
Назначение: Инкапсулирует бизнес-правило, которое можно переиспользовать и комбинировать.
// Базовая спецификация
public interface ISpecification<T>
{
bool IsSatisfiedBy(T entity);
Expression<Func<T, bool>> ToExpression();
}
public abstract class BaseSpecification<T> : ISpecification<T>
{
public abstract Expression<Func<T, bool>> ToExpression();
public bool IsSatisfiedBy(T entity) => ToExpression().Compile()(entity);
}
// Конкретные спецификации
public class OrderTotalSpecification : BaseSpecification<Order>
{
private readonly decimal _minAmount;
private readonly decimal _maxAmount;
public OrderTotalSpecification(decimal minAmount, decimal maxAmount)
{
_minAmount = minAmount;
_maxAmount = maxAmount;
}
public override Expression<Func<Order, bool>> ToExpression() =>
o => o.TotalAmount >= _minAmount && o.TotalAmount <= _maxAmount;
}
public class ActiveCustomerSpecification : BaseSpecification<Order>
{
public override Expression<Func<Order, bool>> ToExpression() =>
o => o.Customer.IsActive && !o.Customer.IsBanned;
}
// Комбинирование спецификаций
public static class SpecificationExtensions
{
public static ISpecification<T> And<T>(this ISpecification<T> left, ISpecification<T> right) =>
new AndSpecification<T>(left, right);
public static ISpecification<T> Or<T>(this ISpecification<T> left, ISpecification<T> right) =>
new OrSpecification<T>(left, right);
public static ISpecification<T> Not<T>(this ISpecification<T> spec) =>
new NotSpecification<T>(spec);
}
public class AndSpecification<T> : BaseSpecification<T>
{
private readonly ISpecification<T> _left;
private readonly ISpecification<T> _right;
public AndSpecification(ISpecification<T> left, ISpecification<T> right)
{
_left = left;
_right = right;
}
public override Expression<Func<T, bool>> ToExpression()
{
var leftExpr = _left.ToExpression();
var rightExpr = _right.ToExpression();
var parameter = leftExpr.Parameters[0];
var body = Expression.AndAlso(leftExpr.Body,
new ParameterReplacer(rightExpr.Parameters[0], parameter).Visit(rightExpr.Body));
return Expression.Lambda<Func<T, bool>>(body, parameter);
}
}
// Использование
var spec = new OrderTotalSpecification(100, 10000)
.And(new ActiveCustomerSpecification());
var orders = dbContext.Orders.Where(spec.ToExpression()).ToList();.NET-specific Patterns
Repository
// Generic repository base
public interface IRepository<TEntity, TKey> where TEntity : class
{
Task<TEntity> GetByIdAsync(TKey id, CancellationToken ct = default);
Task<IReadOnlyList<TEntity>> GetAllAsync(CancellationToken ct = default);
Task AddAsync(TEntity entity, CancellationToken ct = default);
Task UpdateAsync(TEntity entity, CancellationToken ct = default);
Task DeleteAsync(TKey id, CancellationToken ct = default);
}
public abstract class EfRepository<TEntity, TKey> : IRepository<TEntity, TKey>
where TEntity : class
{
protected readonly DbContext _context;
protected readonly DbSet<TEntity> _dbSet;
protected EfRepository(DbContext context)
{
_context = context;
_dbSet = context.Set<TEntity>();
}
public virtual async Task<TEntity> GetByIdAsync(TKey id, CancellationToken ct = default) =>
await _dbSet.FindAsync(new object[] { id }, cancellationToken: ct);
public virtual async Task<IReadOnlyList<TEntity>> GetAllAsync(CancellationToken ct = default) =>
await _dbSet.ToListAsync(ct);
public virtual async Task AddAsync(TEntity entity, CancellationToken ct = default)
{
await _dbSet.AddAsync(entity, ct);
}
public virtual Task UpdateAsync(TEntity entity, CancellationToken ct = default)
{
_dbSet.Update(entity);
return Task.CompletedTask;
}
public virtual async Task DeleteAsync(TKey id, CancellationToken ct = default)
{
var entity = await GetByIdAsync(id, ct);
if (entity != null) _dbSet.Remove(entity);
}
}Unit of Work
public interface IUnitOfWork : IDisposable
{
IOrderRepository Orders { get; }
IProductRepository Products { get; }
Task<int> SaveChangesAsync(CancellationToken ct = default);
Task BeginTransactionAsync(CancellationToken ct = default);
Task CommitTransactionAsync(CancellationToken ct = default);
Task RollbackTransactionAsync(CancellationToken ct = default);
}
public class EfUnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
private IDbContextTransaction _transaction;
public IOrderRepository Orders { get; }
public IProductRepository Products { get; }
public EfUnitOfWork(AppDbContext context)
{
_context = context;
Orders = new EfOrderRepository(context);
Products = new EfProductRepository(context);
}
public async Task<int> SaveChangesAsync(CancellationToken ct = default) =>
await _context.SaveChangesAsync(ct);
public async Task BeginTransactionAsync(CancellationToken ct = default) =>
_transaction = await _context.Database.BeginTransactionAsync(ct);
public async Task CommitTransactionAsync(CancellationToken ct = default)
{
await _transaction.CommitAsync(ct);
await _transaction.DisposeAsync();
_transaction = null;
}
public async Task RollbackTransactionAsync(CancellationToken ct = default)
{
await _transaction.RollbackAsync(ct);
await _transaction.DisposeAsync();
_transaction = null;
}
public void Dispose()
{
_transaction?.Dispose();
_context.Dispose();
}
}Практика
Задание 1: Strategy для Payment Processing
Реализовать систему обработки платежей с разными провайдерами (Stripe, PayPal, Crypto). Каждый провайдер — отдельная стратегия. Добавить фабрику для выбора стратегии на основе типа платежа.
Задание 2: Decorator Chain для HTTP Middleware
Создать цепочку декораторов для HTTP клиента: Logging → Retry → Circuit Breaker → Caching → Base HttpClient.
Задание 3: Command с Undo/Redo
Реализовать текстовый редактор с командами: InsertText, DeleteText, FormatText. Каждая команда поддерживает Undo/Redo через CommandHistory.
Dependency Injection в .NET
IServiceCollection и IServiceProvider — Internals
Как работает DI Container в .NET
.NET built-in DI — это conforming container минимальной функциональности. Он не является полноценным IoC контейнером как Autofac или DryIoc, но покрывает 95% use cases.
IServiceCollection (регистрация)
↓
BuildServiceProvider()
↓
IServiceProvider (резолвинг)
↓
GetService<T>() / GetRequiredService<T>()
↓
Создание графа зависимостейServiceDescriptor — структура регистрации
// Каждая регистрация — это ServiceDescriptor
public class ServiceDescriptor
{
public Type ServiceType { get; } // Интерфейс/абстракция
public Type? ImplementationType { get; } // Конкретная реализация
public object? ImplementationInstance { get; } // Готовый экземпляр (singleton)
public Func<IServiceProvider, object>? ImplementationFactory { get; } // Factory
public ServiceLifetime Lifetime { get; } // Transient/Scoped/Singleton
}
// Примеры создания ServiceDescriptor
var descriptor1 = ServiceDescriptor.Transient<IEmailSender, SmtpEmailSender>();
var descriptor2 = ServiceDescriptor.Scoped<IOrderRepository, EfOrderRepository>();
var descriptor3 = ServiceDescriptor.Singleton<ICacheService>(new MemoryCacheService());
var descriptor4 = ServiceDescriptor.Transient<ILogger>(sp =>
new Logger(sp.GetRequiredService<ILoggerFactory>().CreateLogger("Default")));Internal процесс резолвинга
// Упрощённая схема работы IServiceProvider
internal class ServiceProvider
{
private readonly Service[] _services;
private readonly ConcurrentDictionary<Type, object> _singletonCache = new();
private readonly AsyncLocal<ScopedServiceProvider> _currentScope = new();
public object GetService(Type serviceType)
{
// 1. Проверяем singleton cache
// 2. Создаём новый экземпляр в зависимости от lifetime
// 3. Рекурсивно резолвим зависимости конструктора
// 4. Возвращаем экземпляр
}
}Ключевые моменты:
- Singleton создаётся лениво при первом запросе (если не используется
AddSingleton(instance)) - Scoped создаётся один раз на
IServiceScope - Transient создаётся при каждом запросе
- Граф зависимостей строится рекурсивно — все зависимости резолвятся до создания root объекта
Service Lifetimes
Transient
Создаётся каждый раз при запросе из контейнера.
builder.Services.AddTransient<ITransientService, TransientService>();
// Каждый вызов GetService возвращает новый экземпляр
var s1 = provider.GetRequiredService<ITransientService>();
var s2 = provider.GetRequiredService<ITransientService>();
// s1 != s2 — разные объектыКогда использовать:
- Лёгкие stateless сервисы
- Сервисы, которые не хранят состояние
- Когда каждый пользователь/запрос должен получить свой экземпляр
Когда НЕ использовать:
- Дорогие для создания объекты (подключение к БД, HTTP клиент)
- Сервисы, которые должны разделять состояние
Scoped
Один экземпляр на scope (в ASP.NET — один на HTTP запрос).
builder.Services.AddScoped<IScopedService, ScopedService>();
// В рамках одного scope — один экземпляр
using var scope = provider.CreateScope();
var s1 = scope.ServiceProvider.GetRequiredService<IScopedService>();
var s2 = scope.ServiceProvider.GetRequiredService<IScopedService>();
// s1 == s2 — один объект
// В другом scope — другой экземпляр
using var scope2 = provider.CreateScope();
var s3 = scope2.ServiceProvider.GetRequiredService<IScopedService>();
// s1 != s3 — разные объектыКогда использовать:
- DbContext (один на запрос — единый Unit of Work)
- Сервисы, которые хранят состояние запроса
- Сервисы, работающие с данными текущего пользователя
Singleton
Один экземпляр на всё время жизни приложения.
builder.Services.AddSingleton<ISingletonService, SingletonService>();
// Всегда один экземпляр
var s1 = provider.GetRequiredService<ISingletonService>();
var s2 = provider.GetRequiredService<ISingletonService>();
// s1 == s2 — один объект на всё приложениеКогда использовать:
- Кэши
- Конфигурация
- Сервисы без состояния, дорогие для создания
- Background services
Когда НЕ использовать:
- Сервисы, зависящие от Scoped/Transient (captive dependency)
- Сервисы, которые хранят per-request состояние
Captive Dependencies Problem
Проблема: Singleton захватывает Scoped сервис, и Scoped сервис живёт как Singleton.
// Captive dependency — ОШИБКА!
public class ReportGenerator // Singleton
{
private readonly AppDbContext _context; // Scoped!
public ReportGenerator(AppDbContext context)
{
_context = context; // DbContext теперь живёт как Singleton!
}
}
// Регистрация
builder.Services.AddSingleton<IReportGenerator, ReportGenerator>();
builder.Services.AddScoped<AppDbContext>(); // Будет захвачен!
// Проблема: DbContext не будет disposed после запроса,
// будет копить tracked entities, memory leakРешения:
// Решение 1: Изменить lifetime на Singleton (если возможно)
builder.Services.AddSingleton<IReportGenerator, ReportGenerator>();
builder.Services.AddSingleton<AppDbContext>(); // Но это плохо для DbContext
// Решение 2: Использовать Factory для создания scope
public class ReportGenerator : IReportGenerator
{
private readonly IServiceProvider _serviceProvider;
public ReportGenerator(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task GenerateReportAsync()
{
// Создаём scope вручную
using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Используем context в рамках scope
var reports = await context.Reports.ToListAsync();
}
}
// Решение 3: Inject Func<T> или IServiceProvider
public class OrderProcessor
{
private readonly Func<AppDbContext> _dbContextFactory;
public OrderProcessor(IServiceProvider sp)
{
_dbContextFactory = () => sp.GetRequiredService<AppDbContext>();
}
public async Task ProcessAsync()
{
using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// ...
}
}Factory Patterns
Func Factory
// Регистрация Func<T>
builder.Services.AddTransient<ISmsSender, TwilioSmsSender>();
builder.Services.AddTransient<Func<ISmsSender>>(sp => () => sp.GetRequiredService<ISmsSender>());
// Использование
public class NotificationService
{
private readonly Func<ISmsSender> _smsFactory;
public NotificationService(Func<ISmsSender> smsFactory)
{
_smsFactory = smsFactory;
}
public async Task SendNotificationsAsync(IEnumerable<string> phoneNumbers)
{
foreach (var phone in phoneNumbers)
{
// Каждый раз новый экземпляр
using var scope = new ServiceScope(); // если нужно
var sender = _smsFactory();
await sender.SendAsync(phone, "Hello!");
}
}
}ActivatorUtilities
// ActivatorUtilities позволяет создавать объекты с DI + runtime параметрами
public class OrderFactory
{
private readonly IServiceProvider _serviceProvider;
public OrderFactory(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public Order CreateOrder(int customerId, IEnumerable<OrderItem> items)
{
// DI резолвит IOrderValidator, IOrderCalculator
// customerId и items передаются вручную
return ActivatorUtilities.CreateInstance<Order>(
_serviceProvider,
customerId,
items
);
}
}
// Order должен иметь конструктор, совместимый с параметрами
public class Order
{
public Order(
IOrderValidator validator, // Из DI
IOrderCalculator calculator, // Из DI
int customerId, // Runtime параметр
IEnumerable<OrderItem> items) // Runtime параметр
{
// ...
}
}IFactory — кастомная фабрика
public interface IFactory<out T>
{
T Create();
}
public class HttpClientFactory : IFactory<HttpClient>
{
private readonly string _baseUrl;
private readonly HttpMessageHandler _handler;
public HttpClientFactory(string baseUrl, HttpMessageHandler handler)
{
_baseUrl = baseUrl;
_handler = handler;
}
public HttpClient Create()
{
var client = new HttpClient(_handler);
client.BaseAddress = new Uri(_baseUrl);
return client;
}
}
// Регистрация
builder.Services.AddSingleton<HttpMessageHandler, SocketsHttpHandler>();
builder.Services.AddTransient<IFactory<HttpClient>>(sp =>
new HttpClientFactory("https://api.example.com", sp.GetRequiredService<HttpMessageHandler>()));Open Generic Registrations
// Open Generic — регистрация для любого типа параметра
builder.Services(typeof(IRepository<>), typeof(EfRepository<>));
// Работает для любого TEntity
var orderRepo = provider.GetRequiredService<IRepository<Order>>();
var productRepo = provider.GetRequiredService<IRepository<Product>>();
var userRepo = provider.GetRequiredService<IRepository<User>>();
// С ограничениями
public interface IValidator<T> where T : class { }
public class FluentValidator<T> : IValidator<T> where T : class { }
builder.Services(typeof(IValidator<>), typeof(FluentValidator<>));
// Multiple implementations
builder.Services.AddTransient(typeof(IValidator<>), typeof(OrderValidator<>));
builder.Services.AddTransient(typeof(IValidator<>), typeof(ProductValidator<>));
// Получение всех валидаторов
var validators = provider.GetServices<IValidator<Order>>();Generic с несколькими параметрами
public interface ICommandHandler<TCommand, TResult> { }
public class CreateOrderHandler : ICommandHandler<CreateOrderCommand, OrderResult> { }
// Регистрация конкретного handler
builder.Services.AddTransient<ICommandHandler<CreateOrderCommand, OrderResult>, CreateOrderHandler>();
// Или open generic
public interface IQueryHandler<TQuery, TResult> { }
public class EfQueryHandler<TQuery, TResult> : IQueryHandler<TQuery, TResult> { }
builder.Services(typeof(IQueryHandler<,>), typeof(EfQueryHandler<,>));DI в Background Services
Scoped в Background Service
public class OrderCleanupService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<OrderCleanupService> _logger;
public OrderCleanupService(
IServiceProvider serviceProvider,
ILogger<OrderCleanupService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// Создаём scope для каждой итерации
using var scope = _serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
var oldOrders = await dbContext.Orders
.Where(o => o.CreatedAt < DateTime.UtcNow.AddDays(-30))
.ToListAsync(stoppingToken);
foreach (var order in oldOrders)
{
await emailService.SendAsync(order.CustomerEmail, "Order archived");
dbContext.Orders.Remove(order);
}
await dbContext.SaveChangesAsync(stoppingToken);
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
}
}Кастомный Middleware для DI Diagnostics
public class DiagnosticsMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<DiagnosticsMiddleware> _logger;
public DiagnosticsMiddleware(RequestDelegate next, ILogger<DiagnosticsMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, IServiceProvider serviceProvider)
{
var stopwatch = Stopwatch.StartNew();
try
{
await _next(context);
}
finally
{
stopwatch.Stop();
// Логируем время резолвинга сервисов
_logger.LogInformation(
"Request {Method} {Path} completed in {Elapsed}ms",
context.Request.Method,
context.Request.Path,
stopwatch.ElapsedMilliseconds
);
// Проверка на captive dependencies
if (context.RequestServices is ServiceProvider sp)
{
// Можно добавить кастомную диагностику
}
}
}
}Decorator Pattern через DI Registration
Вручную
// Обёртываем вручную
builder.Services.AddSingleton<IEmailService>(sp =>
{
var inner = new SmtpEmailService();
var logger = sp.GetRequiredService<ILogger<LoggingEmailDecorator>>();
return new LoggingEmailDecorator(inner, logger);
});Через Scrutor
// NuGet: Scrutor
builder.Services.AddSingleton<IEmailService, SmtpEmailService>();
builder.Services.Decorate<IEmailService, LoggingEmailDecorator>();
builder.Services.Decorate<IEmailService, RetryEmailDecorator>();
// Порядок: RetryEmailDecorator(LoggingEmailDecorator(SmtpEmailService))Кастомный Extension Method
public static class ServiceCollectionExtensions
{
public static IServiceCollection Decorate<TService, TDecorator>(this IServiceCollection services)
where TDecorator : TService
{
var descriptors = services.Where(d => d.ServiceType == typeof(TService)).ToList();
foreach (var descriptor in descriptors)
{
services.Remove(descriptor);
}
services.Add(new ServiceDescriptor(typeof(TService), sp =>
{
// Создаём inner service из оригинального descriptor
var inner = CreateService(sp, descriptor);
// Создаём decorator, inject'им inner
return ActivatorUtilities.CreateInstance<TDecorator>(sp, inner);
}, descriptor.Lifetime));
return services;
}
private static object CreateService(IServiceProvider sp, ServiceDescriptor descriptor)
{
if (descriptor.ImplementationInstance != null)
return descriptor.ImplementationInstance;
if (descriptor.ImplementationFactory != null)
return descriptor.ImplementationFactory(sp);
return ActivatorUtilities.CreateInstance(sp, descriptor.ImplementationType);
}
}Validation Service Provider
// Валидация DI контейнера при startup
public static class ServiceCollectionExtensions
{
public static IServiceProvider BuildValidatingServiceProvider(this IServiceCollection services)
{
var provider = services.BuildServiceProvider();
// Проверяем все зарегистрированные сервисы
var serviceTypes = services
.Where(d => d.ImplementationType != null)
.Select(d => d.ImplementationType)
.Distinct();
foreach (var type in serviceTypes)
{
try
{
// Пытаемся создать — если не получится, узнаем на startup
ActivatorUtilities.CreateInstance(provider, type);
}
catch (Exception ex)
{
throw new InvalidOperationException(
$"Failed to create {type.Name}: {ex.Message}", ex);
}
}
return provider;
}
}Практика
Задание 1: Scoped-per-Request в Background Service
Реализовать BackgroundService, который создаёт scope для каждой итерации и корректно работает с Scoped сервисами (DbContext, etc.).
Задание 2: Кастомный Middleware для DI Diagnostics
Создать middleware, который логирует:
- Время резолвинга сервисов
- Количество созданных Transient экземпляров за запрос
- Предупреждения о потенциальных captive dependencies
Задание 3: Decorator через DI
Реализовать систему декораторов, которая позволяет навешивать декораторы на любой сервис через fluent API:
builder.Services
.AddSingleton<IEmailService, SmtpEmailService>()
.WithDecorator<LoggingDecorator>()
.WithDecorator<RetryDecorator>()
.WithDecorator<QueueDecorator>();Архитектурные стили
Layered Architecture (N-Tier)
Классическая трёхзвенная архитектура
┌─────────────────────────────────────────┐
│ Presentation Layer │
│ (Controllers, Views, API) │
├─────────────────────────────────────────┤
│ Business Logic Layer │
│ (Services, Validators, Rules) │
├─────────────────────────────────────────┤
│ Data Access Layer │
│ (Repositories, DbContext, SQL) │
└─────────────────────────────────────────┘Проблемы:
- Зависимости направлены вниз — UI зависит от Business, Business зависит от Data
- Сложно тестировать Business Layer без Data Layer
- Domain logic "протекает" в инфраструктуру
- При изменении БД приходится менять Business Layer
// Проблема: Business Layer зависит от Entity Framework
public class OrderService
{
private readonly AppDbContext _context; // Прямая зависимость от EF!
public OrderService(AppDbContext context)
{
_context = context;
}
public async Task<Order> GetOrderAsync(int id)
{
return await _context.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id);
}
}Clean Architecture
Принципы
Автор: Robert C. Martin (Uncle Bob)
Ключевая идея: Зависимости направлены внутрь. Внешние слои зависят от внутренних, но не наоборот.
┌─────────────────────────────────────────────────────────┐
│ Frameworks & Drivers │
│ (UI, DB, External APIs, CLI) │
├─────────────────────────────────────────────────────────┤
│ Interface Adapters │
│ (Controllers, Presenters, Gateways) │
├─────────────────────────────────────────────────────────┤
│ Use Cases │
│ (Application Services, Interactors) │
├─────────────────────────────────────────────────────────┤
│ Entities │
│ (Domain Models, Business Rules) │
└─────────────────────────────────────────────────────────┘Правило зависимостей: Исходный код зависимостей указывает только внутрь.
Структура проекта
src/
├── Domain/ # Inner layer — Entities, Value Objects, Domain Events
│ ├── Entities/
│ ├── ValueObjects/
│ ├── Events/
│ ├── Exceptions/
│ └── Domain.csproj
│
├── Application/ # Use Cases — Interfaces, DTOs, Services
│ ├── Interfaces/ # Repository interfaces, Service interfaces
│ ├── DTOs/
│ ├── Commands/ # CQRS Commands
│ ├── Queries/ # CQRS Queries
│ ├── Handlers/
│ └── Application.csproj # Depends on: Domain
│
├── Infrastructure/ # Outer layer — Implementations
│ ├── Persistence/ # DbContext, Repositories
│ ├── Services/ # Email, SMS, Payment implementations
│ ├── ExternalApis/
│ └── Infrastructure.csproj # Depends on: Application
│
└── WebApi/ # Presentation layer
├── Controllers/
├── Middleware/
└── WebApi.csproj # Depends on: Application, InfrastructureПример реализации
Domain Layer
// Entity
public class Order : Entity
{
private readonly List<OrderItem> _items = new();
public OrderId Id { get; private set; }
public CustomerId CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public DateTime CreatedAt { get; private set; }
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
public Money TotalAmount { get; private set; }
private Order() { } // EF Core
public Order(CustomerId customerId)
{
CustomerId = customerId ?? throw new ArgumentNullException(nameof(customerId));
Id = OrderId.New();
Status = OrderStatus.Draft;
CreatedAt = DateTime.UtcNow;
TotalAmount = Money.Zero;
AddDomainEvent(new OrderCreatedEvent(Id, CustomerId));
}
public void AddItem(ProductId productId, int quantity, Money price)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Cannot modify non-draft order");
if (quantity <= 0)
throw new DomainException("Quantity must be positive");
var item = new OrderItem(Id, productId, quantity, price);
_items.Add(item);
RecalculateTotal();
}
public void Confirm()
{
if (Status != OrderStatus.Draft)
throw new DomainException("Order must be in Draft status");
if (_items.Count == 0)
throw new DomainException("Order must have at least one item");
Status = OrderStatus.Confirmed;
AddDomainEvent(new OrderConfirmedEvent(Id));
}
private void RecalculateTotal()
{
TotalAmount = _items.Sum(i => i.LineTotal);
}
}
// Value Object
public readonly record struct Money(decimal Amount, string Currency)
{
public static Money Zero => new(0, "USD");
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Cannot add different currencies");
return new Money(Amount + other.Amount, Currency);
}
}
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
}
// Domain Event
public record OrderCreatedEvent(OrderId OrderId, CustomerId CustomerId) : IDomainEvent;
public record OrderConfirmedEvent(OrderId OrderId) : IDomainEvent;Application Layer
// Interface (порт)
public interface IOrderRepository
{
Task<Order> GetByIdAsync(OrderId id, CancellationToken ct = default);
Task AddAsync(Order order, CancellationToken ct = default);
Task UpdateAsync(Order order, CancellationToken ct = default);
}
// Command
public record CreateOrderCommand(CustomerId CustomerId) : IRequest<OrderResult>;
public record AddOrderItemCommand(OrderId OrderId, ProductId ProductId, int Quantity) : IRequest;
// Handler
public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, OrderResult>
{
private readonly IOrderRepository _repository;
private readonly IDomainEventPublisher _eventPublisher;
public CreateOrderHandler(IOrderRepository repository, IDomainEventPublisher eventPublisher)
{
_repository = repository;
_eventPublisher = eventPublisher;
}
public async Task<OrderResult> Handle(CreateOrderCommand request, CancellationToken ct)
{
var order = new Order(request.CustomerId);
await _repository.AddAsync(order, ct);
await _repository.UnitOfWork.SaveChangesAsync(ct);
await _eventPublisher.PublishAsync(order.DomainEvents, ct);
return new OrderResult(order.Id.Value, order.Status.ToString());
}
}Infrastructure Layer
// Реализация репозитория
public class EfOrderRepository : IOrderRepository
{
private readonly AppDbContext _context;
public EfOrderRepository(AppDbContext context) => _context = context;
public IUnitOfWork UnitOfWork => _context;
public async Task<Order> GetByIdAsync(OrderId id, CancellationToken ct = default)
{
return await _context.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id.Value == id.Value, ct);
}
public async Task AddAsync(Order order, CancellationToken ct = default)
{
await _context.Orders.AddAsync(order, ct);
}
public Task UpdateAsync(Order order, CancellationToken ct = default)
{
_context.Orders.Update(order);
return Task.CompletedTask;
}
}Onion Architecture
Принципы
Автор: Jeffrey Palermo (2008)
По сути аналогична Clean Architecture, но с другим визуальным представлением:
┌──────────────────────────────────────────────────────┐
│ Infrastructure │
│ ┌────────────────────────────────────────────────┐ │
│ │ Application Services │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ Domain Models │ │ │
│ │ │ (Entities, Value Objects, Interfaces) │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘Отличие от Clean Architecture: Onion Architecture делает больший акцент на Domain как на центре всего. Clean Architecture более общая и может применяться к non-domain-centric системам.
Структура проекта
src/
├── Domain/
│ ├── Entities/
│ ├── ValueObjects/
│ ├── Aggregates/
│ ├── Events/
│ └── Interfaces/ # Repository interfaces здесь!
│
├── Application/
│ ├── Services/
│ ├── DTOs/
│ ├── Validators/
│ └── Mappings/
│
├── Infrastructure/
│ ├── Data/
│ ├── Repositories/ # Реализации интерфейсов из Domain
│ ├── ExternalServices/
│ └── Messaging/
│
└── Web/
├── Controllers/
├── ViewModels/
└── wwwroot/Hexagonal Architecture (Ports and Adapters)
Принципы
Автор: Alistair Cockburn (2005)
Ключевая идея: Приложение — это шестиугольник (hexagon), окружённый портами (interfaces) и адаптерами (implementations).
┌──────────────┐
│ REST API │ ← Driving Adapter
└──────┬───────┘
│
┌──────────────┐ ┌───────▼────────┐ ┌──────────────┐
│ Web UI │──▶│ │ │ Database │
└──────────────┘ │ │ └──────┬───────┘
│ Hexagon │◀─────────┘
┌──────────────┐ │ (Application │ ┌──────┴───────┐
│ CLI │──▶│ + Domain) │ │ Message │
└──────────────┘ │ │ │ Broker │
│ │ └──────────────┘
┌──────────────┐ │ │
│ Tests │──▶│ │
└──────────────┘ └────────────────┘Порты:
- Driving (Primary): Входы в систему — HTTP запросы, CLI команды, события
- Driven (Secondary): Выходы из системы — БД, внешние API, файловая система
Пример реализации
// === DOMAIN (центр шестиугольника) ===
public class Order
{
public Guid Id { get; }
public string CustomerId { get; }
public List<OrderLine> Lines { get; }
public OrderStatus Status { get; private set; }
public void Confirm() => Status = OrderStatus.Confirmed;
}
// === PORTS (интерфейсы на границе) ===
// Driving Port — как внешний мир взаимодействует с системой
public interface IOrderService
{
Task<OrderDto> CreateOrderAsync(CreateOrderRequest request);
Task<OrderDto> GetOrderAsync(Guid id);
Task ConfirmOrderAsync(Guid id);
}
// Driven Ports — как система взаимодействует с внешним миром
public interface IOrderStore
{
Task<Order> GetByIdAsync(Guid id);
Task SaveAsync(Order order);
}
public interface IPaymentGateway
{
Task<PaymentResult> ChargeAsync(decimal amount, string currency);
}
public interface IEmailNotifier
{
Task SendOrderConfirmationAsync(string email, Guid orderId);
}
// === ADAPTERS (реализации портов) ===
// Driving Adapter — REST API
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly IOrderService _orderService;
public OrdersController(IOrderService orderService) => _orderService = orderService;
[HttpPost]
public async Task<ActionResult<OrderDto>> CreateOrder(CreateOrderRequest request)
{
var order = await _orderService.CreateOrderAsync(request);
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}
}
// Driven Adapter — EF Core Repository
public class EfOrderStore : IOrderStore
{
private readonly AppDbContext _context;
public EfOrderStore(AppDbContext context) => _context = context;
public async Task<Order> GetByIdAsync(Guid id) =>
await _context.Orders.FindAsync(id);
public async Task SaveAsync(Order order)
{
_context.Orders.Update(order);
await _context.SaveChangesAsync();
}
}
// Driven Adapter — Stripe Payment
public class StripePaymentGateway : IPaymentGateway
{
private readonly StripeClient _client;
public async Task<PaymentResult> ChargeAsync(decimal amount, string currency)
{
// Stripe API call
return new PaymentResult { Success = true, TransactionId = "..." };
}
}Domain-Driven Design (DDD)
Strategic Design
Bounded Contexts
Определение: Граница, внутри которой определённая модель имеет конкретное значение.
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Sales │ │ Shipping │ │ Billing │
│ │ │ │ │ │
│ Order (Sales) │──▶│ Order (Ship) │──▶│ Invoice │
│ - items │ │ - packages │ │ - amount │
│ - customer │ │ - address │ │ - tax │
│ - total │ │ - carrier │ │ - status │
└─────────────────┘ └─────────────────┘ └─────────────────┘Важно: Order в Sales и Order в Shipping — это разные модели с разными атрибутами и поведением.
Ubiquitous Language
Определение: Общий язык, используемый разработчиками и экспертами предметной области.
// Плохо: технические термины в domain
public class OrderEntity
{
public int Id { get; set; }
public DateTime Timestamp { get; set; }
public List<OrderItemEntity> Children { get; set; }
}
// Хорошо: язык бизнеса
public class Order
{
public OrderNumber Number { get; }
public DateTime PlacedAt { get; }
public IReadOnlyList<OrderLine> Lines { get; }
public void AddLine(Product product, Quantity quantity) { ... }
public void Cancel(CancellationReason reason) { ... }
}Context Map
Паттерны интеграции между Bounded Contexts:
| Паттерн | Описание | Когда использовать |
|---|---|---|
| Shared Kernel | Общий код между контекстами | Маленькие команды, тесная связь |
| Customer-Supplier | Один контекст зависит от другого | Чёткая зависимость |
| Conformist | Downstream принимает модель upstream | Нет возможности изменить upstream |
| Anti-Corruption Layer | Трансформация модели при интеграции | Legacy системы, разные модели |
| Open Host Service | Публикация API для многих consumers | Platform, SaaS |
| Published Language | Общий формат данных (XML, JSON Schema) | Стандартизированная интеграция |
Tactical Design
Entities
// Entity — имеет идентичность, изменяется во времени
public class Customer : Entity
{
public CustomerId Id { get; private set; }
public string Name { get; private set; }
public Email Email { get; private set; }
public Address Address { get; private set; }
public CustomerStatus Status { get; private set; }
private Customer() { }
public Customer(string name, Email email)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Email = email ?? throw new ArgumentNullException(nameof(email));
Id = CustomerId.New();
Status = CustomerStatus.Active;
}
public void ChangeAddress(Address newAddress)
{
Address = newAddress ?? throw new ArgumentNullException(nameof(newAddress));
AddDomainEvent(new CustomerAddressChangedEvent(Id, newAddress));
}
public void Suspend(string reason)
{
if (Status == CustomerStatus.Suspended)
throw new DomainException("Customer already suspended");
Status = CustomerStatus.Suspended;
AddDomainEvent(new CustomerSuspendedEvent(Id, reason));
}
}Value Objects
// Value Object — определяется значениями, неизменяем
public readonly record struct Address
{
public string Street { get; }
public string City { get; }
public string State { get; }
public string ZipCode { get; }
public string Country { get; }
public Address(string street, string city, string state, string zipCode, string country)
{
if (string.IsNullOrWhiteSpace(street)) throw new ArgumentException("Street is required");
if (string.IsNullOrWhiteSpace(city)) throw new ArgumentException("City is required");
if (!ZipCodeRegex.IsMatch(zipCode)) throw new ArgumentException("Invalid ZIP code");
Street = street;
City = city;
State = state;
ZipCode = zipCode;
Country = country;
}
}
// Value Object с бизнес-логикой
public readonly record struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency = "USD")
{
if (amount < 0) throw new ArgumentException("Amount cannot be negative");
if (string.IsNullOrWhiteSpace(currency)) throw new ArgumentException("Currency is required");
Amount = amount;
Currency = currency.ToUpperInvariant();
}
public static Money Zero => new(0);
public static Money FromDecimal(decimal amount) => new(amount);
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException($"Cannot add {Currency} and {other.Currency}");
return new Money(Amount + other.Amount, Currency);
}
public Money Multiply(decimal factor) => new(Amount * factor, Currency);
}Aggregates и Aggregate Roots
// Aggregate Root — Order контролирует все свои OrderItem
public class Order : AggregateRoot
{
private readonly List<OrderItem> _items = new();
public OrderId Id { get; private set; }
public CustomerId CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
public Money Total { get; private set; }
private Order() { }
public Order(CustomerId customerId)
{
Id = OrderId.New();
CustomerId = customerId;
Status = OrderStatus.Draft;
Total = Money.Zero;
}
// Все изменения OrderItem проходят через Order
public void AddItem(Product product, int quantity)
{
GuardDraft();
if (quantity <= 0) throw new DomainException("Quantity must be positive");
if (quantity > product.MaxOrderQuantity)
throw new DomainException($"Max order quantity is {product.MaxOrderQuantity}");
var item = new OrderItem(Id, product.Id, quantity, product.Price);
_items.Add(item);
RecalculateTotal();
}
public void RemoveItem(OrderItemId itemId)
{
GuardDraft();
var item = _items.FirstOrDefault(i => i.Id == itemId);
if (item == null) throw new DomainException("Item not found");
_items.Remove(item);
RecalculateTotal();
}
public void Confirm()
{
if (Status != OrderStatus.Draft)
throw new DomainException("Only draft orders can be confirmed");
if (!_items.Any())
throw new DomainException("Cannot confirm empty order");
Status = OrderStatus.Confirmed;
AddDomainEvent(new OrderConfirmedEvent(Id, CustomerId, Total));
}
private void GuardDraft()
{
if (Status != OrderStatus.Draft)
throw new DomainException($"Cannot modify order in {Status} status");
}
private void RecalculateTotal()
{
Total = _items.Aggregate(Money.Zero, (sum, item) => sum.Add(item.LineTotal));
}
}
// OrderItem — не Aggregate Root, не может существовать без Order
public class OrderItem : Entity
{
public OrderItemId Id { get; private set; }
public OrderId OrderId { get; private set; }
public ProductId ProductId { get; private set; }
public int Quantity { get; private set; }
public Money UnitPrice { get; private set; }
public Money LineTotal => UnitPrice.Multiply(Quantity);
internal OrderItem(OrderId orderId, ProductId productId, int quantity, Money unitPrice)
{
Id = OrderItemId.New();
OrderId = orderId;
ProductId = productId;
Quantity = quantity;
UnitPrice = unitPrice;
}
}Domain Events
// Базовый класс для Domain Events
public interface IDomainEvent
{
DateTime OccurredOn { get; }
}
public abstract class DomainEvent : IDomainEvent
{
public DateTime OccurredOn { get; } = DateTime.UtcNow;
}
// Entity с поддержкой Domain Events
public abstract class Entity
{
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void AddDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}
// Event Handler
public interface IDomainEventHandler<TEvent> where TEvent : IDomainEvent
{
Task HandleAsync(TEvent @event, CancellationToken ct = default);
}
// Конкретный handler
public class OrderConfirmedEmailHandler : IDomainEventHandler<OrderConfirmedEvent>
{
private readonly IEmailService _email;
private readonly ICustomerRepository _customers;
public async Task HandleAsync(OrderConfirmedEvent @event, CancellationToken ct = default)
{
var customer = await _customers.GetByIdAsync(@event.CustomerId, ct);
await _email.SendAsync(customer.Email, "Order Confirmed",
$"Your order {@event.OrderId} for {@event.Total} has been confirmed");
}
}
// Публикация событий
public class DomainEventPublisher : IDomainEventPublisher
{
private readonly IServiceProvider _serviceProvider;
public async Task PublishAsync(IEnumerable<IDomainEvent> events, CancellationToken ct = default)
{
foreach (var @event in events)
{
var eventType = @event.GetType();
var handlerType = typeof(IDomainEventHandler<>).MakeGenericType(eventType);
var handlers = _serviceProvider.GetServices(handlerType);
foreach (var handler in handlers)
{
var method = handlerType.GetMethod("HandleAsync");
await (Task)method.Invoke(handler, new object[] { @event, ct });
}
}
}
}Domain Services
// Domain Service — логика, которая не принадлежит одному Entity
public class OrderPricingService
{
private readonly IDiscountPolicy _discountPolicy;
private readonly ITaxCalculator _taxCalculator;
public OrderPricingService(IDiscountPolicy discountPolicy, ITaxCalculator taxCalculator)
{
_discountPolicy = discountPolicy;
_taxCalculator = taxCalculator;
}
public OrderTotal CalculateTotal(Order order, Customer customer)
{
var subtotal = order.Items.Sum(i => i.LineTotal.Amount);
var discount = _discountPolicy.CalculateDiscount(order, customer);
var taxableAmount = subtotal - discount;
var tax = _taxCalculator.Calculate(taxableAmount, customer.Address);
return new OrderTotal(subtotal, discount, tax);
}
}Repositories vs Direct Database Access
// Repository — абстракция коллекции Aggregate Root
public interface IOrderRepository
{
Task<Order> GetByIdAsync(OrderId id, CancellationToken ct = default);
Task<IReadOnlyList<Order>> GetByCustomerAsync(CustomerId customerId, CancellationToken ct = default);
Task AddAsync(Order order, CancellationToken ct = default);
Task RemoveAsync(Order order, CancellationToken ct = default);
}
// Почему Repository, а не прямой доступ к БД:
// 1. Domain layer не знает о EF Core / Dapper / etc.
// 2. Можно легко заменить реализацию
// 3. Можно замокать для тестирования
// 4. Repository выражает намерение — "коллекция Order"
// 5. Скрывает детали persistence от domain
// Anti-pattern: Repository для всего
public interface IGenericRepository<T> { ... } // Не делайте так!
// Лучше: конкретные repository для каждого Aggregate Root
public interface ICustomerRepository { ... }
public interface IProductRepository { ... }
// Нет IRepository<OrderItem> — OrderItem не Aggregate Root!Практика
Задание 1: E-commerce Bounded Context
Спроектировать bounded context для e-commerce:
- Определить Aggregate Roots (Order, Product, Customer)
- Определить Value Objects (Money, Address, ProductId)
- Определить Domain Events (OrderCreated, OrderShipped, PaymentReceived)
- Определить границы контекстов (Sales, Inventory, Shipping, Billing)
Задание 2: Anti-Corruption Layer
Реализовать ACL для интеграции с legacy системой заказов:
- Legacy система использует плоскую структуру данных
- Новая система использует DDD модель
- ACL трансформирует данные между моделями
Задание 3: Domain Model с Value Objects
Создать domain model для банковского счёта:
- Value Objects: AccountNumber, Money, Currency, TransactionId
- Entity: Account (Aggregate Root)
- Invariant: баланс не может быть меньше овердрафта
- Domain Events: Deposited, Withdrawn, AccountOpened, AccountClosed
Микросервисы vs Монолит
Когда микросервисы — правильный выбор
Монолит: когда он подходит
Монолит — это default choice. Начинайте с монолита.
Подходит, когда:
- Команда < 10 человек
- Domain не полностью понятен
- Time-to-market критичен
- Нет требований к независимому масштабированию
- Простая deployment pipeline достаточна
Преимущества монолита:
- Простота разработки и деплоя
- Простое тестирование (in-memory, один процесс)
- Простая отладка (один лог, один процесс)
- ACID транзакции
- Нет network latency между компонентами
- Меньше operational overhead
Микросервисы: когда они нужны
Переходите к микросервисам, когда:
| Фактор | Монолит | Микросервисы |
|---|---|---|
| Команда | < 10 человек | 10+ человек, несколько команд |
| Масштабирование | Всё или ничего | Только hot-spots |
| Time-to-market | Быстро для маленькой команды | Быстро для больших организаций |
| Технологии | Один стек | Разные стеки для разных задач |
| Отказоустойчивость | Single point of failure | Изоляция отказов |
| Deployment | Один артефакт | Независимый деплой |
Правило: Если вы не уверены, что вам нужны микросервисы — вам они не нужны.
Service Discovery, API Gateway, BFF
Service Discovery
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Service │────▶│ Registry │◀────│ Service │
│ A │ │ (Consul, │ │ B │
│ register │ │ Eureka, │ │ register │
│ │ │ K8s DNS) │ │ │
└─────────────┘ └──────┬───────┘ └─────────────┘
│
┌──────▼───────┐
│ Service │
│ Consumer │
│ (lookup) │
└──────────────┘Client-side Discovery
public class ServiceDiscoveryClient
{
private readonly HttpClient _httpClient;
private readonly string _registryUrl;
private ConcurrentDictionary<string, ServiceInstance> _cache = new();
public async Task<ServiceInstance> ResolveAsync(string serviceName)
{
if (_cache.TryGetValue(serviceName, out var instance))
{
if (instance.Healthy && instance.ExpiresAt > DateTime.UtcNow)
return instance;
}
var response = await _httpClient.GetAsync($"{_registryUrl}/services/{serviceName}");
var instances = await response.Content.ReadFromJsonAsync<List<ServiceInstance>>();
var healthy = instances.FirstOrDefault(i => i.Healthy);
if (healthy == null) throw new ServiceUnavailableException(serviceName);
_cache[serviceName] = healthy;
return healthy;
}
}Server-side Discovery (через Load Balancer)
Client → Load Balancer → Service Registry → Service InstanceВ Kubernetes это делается через Service и DNS автоматически.
API Gateway
┌──────────────┐
│ Client │
└──────┬───────┘
│
▼
┌─────────────────────────────────┐
│ API Gateway │
│ ┌───────────────────────────┐ │
│ │ Authentication │ │
│ │ Rate Limiting │ │
│ │ Routing │ │
│ │ Request/Response Transform│ │
│ │ Aggregation │ │
│ └───────────────────────────┘ │
└──────┬──────┬──────┬────────────┘
│ │ │
▼ ▼ ▼
┌─────┐ ┌─────┐ ┌─────┐
│UserService│ │OrderService│ │PaymentService│
└─────┘ └─────┘ └─────┘Реализация с YARP (Yet Another Reverse Proxy)
// NuGet: Yarp.ReverseProxy
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms(builderContext =>
{
// Добавляем authentication header
builderContext.AddRequestTransform(async transformContext =>
{
var token = transformContext.HttpContext.Request.Headers["Authorization"];
transformContext.ProxyRequest.Headers.Add("X-Forwarded-Token", token);
});
});
app.MapReverseProxy();{
"ReverseProxy": {
"Routes": {
"users-route": {
"ClusterId": "users-cluster",
"Match": { "Path": "/api/users/{**catch-all}" }
},
"orders-route": {
"ClusterId": "orders-cluster",
"Match": { "Path": "/api/orders/{**catch-all}" }
}
},
"Clusters": {
"users-cluster": {
"Destinations": {
"destination1": { "Address": "http://user-service:8080/" }
}
},
"orders-cluster": {
"Destinations": {
"destination1": { "Address": "http://order-service:8080/" }
}
}
}
}
}BFF (Backend for Frontend)
┌─────────────┐ ┌─────────────┐
│ Web App │────▶│ Web BFF │────▶ Microservices
└─────────────┘ └─────────────┘
┌─────────────┐ ┌─────────────┐
│ Mobile App │────▶│ Mobile BFF │────▶ Microservices
└─────────────┘ └─────────────┘
┌─────────────┐ ┌─────────────┐
│ Public API │────▶│ Public BFF │────▶ Microservices
└─────────────┘ └─────────────┘Зачем: Каждый клиент получает API, оптимизированный под его нужды.
// Web BFF — агрегирует данные из нескольких сервисов
public class DashboardController : ControllerBase
{
private readonly IUserServiceClient _users;
private readonly IOrderServiceClient _orders;
private readonly INotificationServiceClient _notifications;
[HttpGet("dashboard")]
public async Task<DashboardDto> GetDashboard()
{
var userId = User.GetUserId();
// Параллельные запросы к разным сервисам
var (user, orders, notifications) = await Task.WhenAll(
_users.GetUserAsync(userId),
_orders.GetRecentOrdersAsync(userId, 10),
_notifications.GetUnreadAsync(userId)
);
return new DashboardDto
{
User = user,
RecentOrders = orders,
UnreadNotifications = notifications.Count
};
}
}Inter-service Communication
Synchronous: gRPC
// order.proto
syntax = "proto3";
service OrderService {
rpc GetOrder (GetOrderRequest) returns (GetOrderResponse);
rpc CreateOrder (CreateOrderRequest) returns (CreateOrderResponse);
rpc StreamOrderUpdates (StreamOrderUpdatesRequest) returns (stream OrderUpdate);
}
message GetOrderRequest {
string order_id = 1;
}
message GetOrderResponse {
string order_id = 1;
string customer_id = 2;
repeated OrderItem items = 3;
double total = 4;
OrderStatus status = 5;
}
enum OrderStatus {
DRAFT = 0;
CONFIRMED = 1;
SHIPPED = 2;
DELIVERED = 3;
}// Server
public class OrderService : OrderService.OrderServiceBase
{
private readonly IOrderRepository _repository;
public OrderService(IOrderRepository repository) => _repository = repository;
public override async Task<GetOrderResponse> GetOrder(
GetOrderRequest request, ServerCallContext context)
{
var order = await _repository.GetByIdAsync(request.OrderId);
if (order == null)
throw new RpcException(new Status(StatusCode.NotFound, "Order not found"));
return new GetOrderResponse
{
OrderId = order.Id.ToString(),
CustomerId = order.CustomerId.ToString(),
Total = order.Total.Amount,
Status = (OrderStatus)order.Status
};
}
}
// Client
var channel = GrpcChannel.ForAddress("https://order-service:5001");
var client = new OrderService.OrderServiceClient(channel);
var response = await client.GetOrderAsync(new GetOrderRequest { OrderId = "123" });Synchronous: HTTP/REST
// Typed HttpClient
public class OrderServiceClient
{
private readonly HttpClient _http;
public OrderServiceClient(HttpClient http)
{
_http = http;
_http.BaseAddress = new Uri("https://order-service/api/");
}
public async Task<OrderDto> GetOrderAsync(string orderId, CancellationToken ct = default)
{
var response = await _http.GetAsync($"orders/{orderId}", ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OrderDto>(ct);
}
}
// Регистрация
builder.Services.AddHttpClient<IOrderServiceClient, OrderServiceClient>();Asynchronous: Message Brokers
RabbitMQ
// Publisher
public class OrderEventPublisher : IOrderEventPublisher
{
private readonly IConnection _connection;
private readonly IModel _channel;
public OrderEventPublisher(IConnection connection)
{
_connection = connection;
_channel = _connection.CreateModel();
_channel.ExchangeDeclare("order-events", ExchangeType.Topic, durable: true);
}
public async Task PublishAsync<T>(T @event, string routingKey, CancellationToken ct = default)
{
var body = JsonSerializer.SerializeToUtf8Bytes(@event);
var properties = _channel.CreateBasicProperties();
properties.Persistent = true;
properties.MessageId = Guid.NewGuid().ToString();
properties.Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds());
properties.Type = typeof(T).Name;
_channel.BasicPublish(
exchange: "order-events",
routingKey: routingKey,
basicProperties: properties,
body: body);
}
}
// Consumer
public class OrderEventHandler : BackgroundService
{
private readonly IConnection _connection;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<OrderEventHandler> _logger;
public OrderEventHandler(
IConnection connection,
IServiceProvider serviceProvider,
ILogger<OrderEventHandler> logger)
{
_connection = connection;
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
var channel = _connection.CreateModel();
channel.ExchangeDeclare("order-events", ExchangeType.Topic, durable: true);
channel.QueueDeclare("order-processing", durable: true, exclusive: false, autoDelete: false);
channel.QueueBind("order-processing", "order-events", "order.*");
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.Received += async (model, ea) =>
{
using var scope = _serviceProvider.CreateScope();
var body = ea.Body.ToArray();
var @event = JsonSerializer.Deserialize<OrderCreatedEvent>(body);
try
{
var handler = scope.ServiceProvider.GetRequiredService<IOrderCreatedHandler>();
await handler.HandleAsync(@event, stoppingToken);
channel.BasicAck(ea.DeliveryTag, multiple: false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing event");
channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: false); // DLQ
}
};
channel.BasicConsume("order-processing", autoAck: false, consumer: consumer);
return Task.CompletedTask;
}
}Kafka
// NuGet: Confluent.Kafka
// Producer
public class KafkaEventPublisher : IEventPublisher
{
private readonly IProducer<string, string> _producer;
public KafkaEventPublisher(IProducer<string, string> producer) => _producer = producer;
public async Task PublishAsync<T>(T @event, string topic, string key)
{
var message = new Message<string, string>
{
Key = key,
Value = JsonSerializer.Serialize(@event)
};
await _producer.ProduceAsync(topic, message);
}
}
// Consumer
public class KafkaConsumer : BackgroundService
{
private readonly IConsumer<string, string> _consumer;
private readonly IServiceProvider _serviceProvider;
public KafkaConsumer(IConsumer<string, string> consumer, IServiceProvider serviceProvider)
{
_consumer = consumer;
_serviceProvider = serviceProvider;
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
_consumer.Subscribe("order-events");
while (!stoppingToken.IsCancellationRequested)
{
var result = _consumer.Consume(stoppingToken);
using var scope = _serviceProvider.CreateScope();
var @event = JsonSerializer.Deserialize<OrderCreatedEvent>(result.Message.Value);
var handler = scope.ServiceProvider.GetRequiredService<IOrderCreatedHandler>();
await handler.HandleAsync(@event, stoppingToken);
}
return Task.CompletedTask;
}
}Event-Driven Architecture
Event Sourcing Basics
State = f(Events)
Order Created → Item Added → Item Added → Confirmed → Shipped
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
Draft Draft Draft Confirmed ShippedПринцип: Вместо хранения текущего состояния, храните все события, которые привели к этому состоянию.
CQRS (Command Query Responsibility Segregation)
┌──────────────────────────────────────────────────────┐
│ CQRS │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Commands │ │ Queries │ │
│ │ (Write) │ │ (Read) │ │
│ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Write Model │ │ Read Model │ │
│ │ (Domain) │ │ (Projections)│ │
│ └──────┬──────┘ └──────┬──────┘ │
│ │ ▲ │
│ │ Events │ │
│ └────────────────────────────┘ │
└──────────────────────────────────────────────────────┘Когда использовать CQRS:
- Разные требования к read и write моделям
- High read/write ratio
- Разные команды работают с read и write
- Event sourcing
Когда НЕ использовать:
- Простые CRUD приложения
- Нет scaling проблем
- Команда маленькая
Distributed Transactions
Saga Pattern
Проблема: В микросервисах нет единой БД — нет ACID транзакций.
Решение: Saga — последовательность локальных транзакций с компенсирующими действиями.
Order Saga:
1. Create Order (Order Service)
2. Reserve Inventory (Inventory Service)
3. Process Payment (Payment Service)
4. Create Shipment (Shipping Service)
Compensating Actions:
1. Cancel Order
2. Release Inventory
3. Refund Payment
4. Cancel ShipmentChoreography-based Saga
// Каждый сервис публикует события, другие реагируют
public class OrderSagaChoreography
{
// Order Service
public async Task CreateOrderAsync(CreateOrderCommand command)
{
var order = new Order(command.CustomerId);
await _repository.SaveAsync(order);
// Публикуем событие — другие сервисы реагируют
await _eventPublisher.PublishAsync(new OrderCreatedEvent(order.Id));
}
}
// Inventory Service
public class InventorySagaHandler
{
public async Task HandleAsync(OrderCreatedEvent @event)
{
var reserved = await _inventory.ReserveAsync(@event.OrderId, @event.Items);
if (reserved)
await _eventPublisher.PublishAsync(new InventoryReservedEvent(@event.OrderId));
else
await _eventPublisher.PublishAsync(new InventoryReservationFailedEvent(@event.OrderId));
}
}
// Payment Service
public class PaymentSagaHandler
{
public async Task HandleAsync(InventoryReservedEvent @event)
{
var charged = await _payment.ChargeAsync(@event.OrderId);
if (charged)
await _eventPublisher.PublishAsync(new PaymentCompletedEvent(@event.OrderId));
else
await _eventPublisher.PublishAsync(new PaymentFailedEvent(@event.OrderId));
}
}Orchestration-based Saga
public class OrderSagaOrchestrator : ISagaOrchestrator
{
private readonly IOrderService _orders;
private readonly IInventoryService _inventory;
private readonly IPaymentService _payment;
private readonly IShippingService _shipping;
private readonly ILogger<OrderSagaOrchestrator> _logger;
public async Task ExecuteAsync(CreateOrderCommand command, CancellationToken ct = default)
{
var sagaData = new OrderSagaData
{
OrderId = Guid.NewGuid(),
CustomerId = command.CustomerId,
Items = command.Items
};
try
{
// Step 1: Create Order
sagaData.Order = await _orders.CreateAsync(sagaData.OrderId, sagaData.CustomerId, ct);
// Step 2: Reserve Inventory
await _inventory.ReserveAsync(sagaData.OrderId, sagaData.Items, ct);
// Step 3: Process Payment
await _payment.ChargeAsync(sagaData.OrderId, sagaData.Order.Total, ct);
// Step 4: Create Shipment
await _shipping.CreateShipmentAsync(sagaData.OrderId, ct);
// Step 5: Confirm Order
await _orders.ConfirmAsync(sagaData.OrderId, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Saga failed, executing compensations");
await CompensateAsync(sagaData, ct);
throw;
}
}
private async Task CompensateAsync(OrderSagaData sagaData, CancellationToken ct)
{
// Компенсации в обратном порядке
await _shipping.CancelShipmentAsync(sagaData.OrderId, ct);
await _payment.RefundAsync(sagaData.OrderId, ct);
await _inventory.ReleaseAsync(sagaData.OrderId, ct);
await _orders.CancelAsync(sagaData.OrderId, ct);
}
}Outbox Pattern
Проблема: Как гарантировать, что сохранение в БД и публикация события — атомарны?
Решение: Сохраняем событие в ту же транзакцию, что и бизнес-данные. Отдельный процесс публикует события.
// Outbox Entity
public class OutboxMessage
{
public Guid Id { get; set; }
public string Type { get; set; }
public string Content { get; set; }
public DateTime OccurredOn { get; set; }
public DateTime? ProcessedAt { get; set; }
public int RetryCount { get; set; }
public string Error { get; set; }
}
// В той же транзакции, что и бизнес-данные
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _context;
public async Task SaveAsync(Order order, CancellationToken ct = default)
{
_context.Orders.Update(order);
// Сохраняем domain events в outbox — в той же транзакции!
foreach (var @event in order.DomainEvents)
{
_context.OutboxMessages.Add(new OutboxMessage
{
Id = Guid.NewGuid(),
Type = @event.GetType().Name,
Content = JsonSerializer.Serialize(@event),
OccurredOn = DateTime.UtcNow
});
}
// Один SaveChanges — атомарно!
await _context.SaveChangesAsync(ct);
}
}
// Background processor — публикует события
public class OutboxProcessor : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly IEventPublisher _publisher;
private readonly ILogger<OutboxProcessor> _logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var messages = await context.OutboxMessages
.Where(m => m.ProcessedAt == null && m.RetryCount < 5)
.OrderBy(m => m.OccurredOn)
.Take(100)
.ToListAsync(stoppingToken);
foreach (var message in messages)
{
try
{
var @event = JsonSerializer.Deserialize(message.Content, Type.GetType(message.Type));
await _publisher.PublishAsync(@event, message.Type);
message.ProcessedAt = DateTime.UtcNow;
}
catch (Exception ex)
{
message.RetryCount++;
message.Error = ex.Message;
_logger.LogError(ex, "Failed to process outbox message {Id}", message.Id);
}
}
await context.SaveChangesAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}Практика
Задание 1: Outbox Pattern
Реализовать Outbox pattern для надёжной отправки событий:
- Outbox messages сохраняются в той же транзакции
- Background processor публикует события
- Retry логика для failed messages
- Dead letter queue для messages с max retries
Задание 2: API Gateway
Реализовать API Gateway с:
- Rate limiting (fixed window, sliding window, token bucket)
- Authentication (JWT validation)
- Routing к разным микросервисам
- Request/Response transformation
Задание 3: Saga Orchestrator
Реализовать saga orchestrator для distributed order processing:
- Create Order → Reserve Inventory → Process Payment → Ship
- Compensating actions для каждого шага
- Saga persistence (сохранение состояния saga)
- Timeout handling
Resilience Patterns
Polly Library
Overview
Polly — .NET library для transient fault handling. Реализует resilience patterns как first-class citizens.
NuGet: Polly
GitHub: https://github.com/App-vNext/PollyRetry Policy
// Базовый retry
var retryPolicy = Policy
.Handle<HttpRequestException>()
.RetryAsync(3);
// Retry с wait
var retryWithWait = Policy
.Handle<HttpRequestException>()
.WaitAndRetryAsync(3, retryAttempt =>
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
// Retry с callback
var retryWithLogging = Policy
.Handle<SqlException>(ex => ex.Number == 1205) // Deadlock
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: retry => TimeSpan.FromSeconds(Math.Pow(2, retry)),
onRetry: (exception, timespan, retryCount, context) =>
{
context["Logger"].LogWarning(
"Retry {RetryCount} after {Delay}s due to {Exception}",
retryCount, timespan.TotalSeconds, exception.Message);
});
// Retry forever (с осторожностью!)
var retryForever = Policy
.Handle<Exception>()
.RetryForeverAsync(onRetry: ex => Log.Error(ex, "Retrying..."));
// Использование
await retryWithLogging.ExecuteAsync(async ctx =>
{
ctx["Logger"] = logger;
return await httpClient.GetAsync("https://api.example.com/data");
}, new Context());Circuit Breaker
// Circuit Breaker
var circuitBreaker = Policy
.Handle<HttpRequestException>()
.CircuitBreakerAsync(
exceptionsAllowedBeforeBreaking: 5,
durationOfBreak: TimeSpan.FromSeconds(30),
onBreak: (ex, duration) =>
Log.Warning($"Circuit broken for {duration}s due to {ex.Message}"),
onReset: () => Log.Info("Circuit reset — healthy again"),
onHalfOpen: () => Log.Info("Circuit half-open — testing"));
// Advanced Circuit Breaker (на основе % failures)
var advancedCircuitBreaker = Policy
.Handle<HttpRequestException>()
.AdvancedCircuitBreakerAsync(
failureThreshold: 0.5, // 50% failures
samplingDuration: TimeSpan.FromSeconds(30),
minimumThroughput: 10, // Минимум 10 запросов за sampling
durationOfBreak: TimeSpan.FromSeconds(60));
// States: Closed → Open → Half-Open → Closed
// Closed: нормальная работа
// Open: все запросы fail fast
// Half-Open: один test request, если success → Closed, если fail → OpenBulkhead
// Bulkhead — ограничение параллелизма
var bulkhead = Policy.BulkheadAsync(
maxParallelization: 10, // Максимум 10 параллельных запросов
maxQueuingActions: 5, // Ещё 5 в очереди
onBulkheadRejected: async context =>
{
Log.Warning("Bulkhead full — request rejected");
throw new BulkheadRejectedException("System at capacity");
});
// Isolated bulkheads для разных операций
var bulkheadIsolation = Policy.BulkheadAsync(10, 5); // Для DB
var bulkheadHttp = Policy.BulkheadAsync(20, 10); // Для HTTP
// Использование
await bulkheadIsolation.ExecuteAsync(async () =>
await dbContext.Orders.ToListAsync());Timeout
// Optimistic Timeout (поддерживает CancellationToken)
var timeout = Policy.TimeoutAsync(
TimeSpan.FromSeconds(30),
TimeoutStrategy.Optimistic,
onTimeoutAsync: async (context, timespan, task) =>
{
Log.Warning("Operation timed out after {Timeout}s", timespan.TotalSeconds);
});
// Pessimistic Timeout (прерывает выполнение)
var pessimisticTimeout = Policy.TimeoutAsync(
TimeSpan.FromSeconds(30),
TimeoutStrategy.Pessimistic);
// Использование с HttpClient
await timeout.ExecuteAsync(async ct =>
{
return await httpClient.GetAsync("https://api.example.com/data", ct);
});Fallback
// Fallback — значение по умолчанию при ошибке
var fallback = Policy
.Handle<HttpRequestException>()
.FallbackAsync(async ct =>
{
Log.Warning("Using fallback value");
return await GetCachedDataAsync();
});
// Fallback с fallback value
var fallbackValue = Policy
.Handle<Exception>()
.Fallback<User>(
fallbackValue: new User { Name = "Guest", IsAnonymous = true },
onFallback: ex => Log.Warning(ex, "Falling back to anonymous user"));
// Fallback с fallback action
var fallbackAction = Policy
.Handle<CacheException>()
.FallbackAsync(
fallbackAction: async ct => await GetFromDatabaseAsync(),
onFallback: async (ex, ctx) => await Log.WarningAsync(ex, "Cache miss, using DB"));Composite Policies — Policy Wrap
// Комбинирование политик
var timeout = Policy.TimeoutAsync(TimeSpan.FromSeconds(10));
var retry = Policy
.Handle<HttpRequestException>()
.WaitAndRetryAsync(3, i => TimeSpan.FromSeconds(Math.Pow(2, i)));
var circuitBreaker = Policy
.Handle<HttpRequestException>()
.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30));
var fallback = Policy
.Handle<Exception>()
.FallbackAsync(async ct => await GetCachedResponseAsync());
// Порядок: outer → inner
// Fallback → CircuitBreaker → Retry → Timeout → Execution
var resiliencePipeline = Policy
.WrapAsync(fallback, circuitBreaker, retry, timeout);
// Использование
var response = await resiliencePipeline.ExecuteAsync(async ct =>
{
return await httpClient.GetAsync("https://api.example.com/data", ct);
});Polly v8 — Resilience Pipeline (новый API)
// NuGet: Microsoft.Extensions.Resilience (Polly v8)
// Простой pipeline
var pipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromSeconds(2),
BackoffType = DelayBackoffType.Exponential,
ShouldHandle = new PredicateBuilder().Handle<HttpRequestException>()
})
.AddTimeout(TimeSpan.FromSeconds(10))
.Build();
// Pipeline с registry
var pipelineRegistry = new ResiliencePipelineRegistry<string>();
pipelineRegistry.TryAddPipeline("http-default", new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 })
.AddCircuitBreaker(new CircuitBreakerStrategyOptions
{
FailureRatio = 0.5,
SamplingDuration = TimeSpan.FromSeconds(30),
MinimumThroughput = 10,
BreakDuration = TimeSpan.FromSeconds(60)
})
.AddTimeout(TimeSpan.FromSeconds(30))
.Build());
// Использование
var pipeline = pipelineRegistry.GetPipeline("http-default");
await pipeline.ExecuteAsync(async ct => await httpClient.GetAsync(url, ct));Resilient HTTP Client
Полная реализация
public class ResilientHttpClient
{
private readonly ResiliencePipeline _pipeline;
private readonly HttpClient _httpClient;
private readonly IMemoryCache _cache;
private readonly ILogger<ResilientHttpClient> _logger;
public ResilientHttpClient(
HttpClient httpClient,
IMemoryCache cache,
ILogger<ResilientHttpClient> logger)
{
_httpClient = httpClient;
_cache = cache;
_logger = logger;
_pipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
ShouldHandle = new PredicateBuilder()
.Handle<HttpRequestException>()
.Handle<TimeoutRejectedException>()
.HandleResult<HttpResponseMessage>(r => !r.IsSuccessStatusCode)
})
.AddCircuitBreaker(new CircuitBreakerStrategyOptions
{
FailureRatio = 0.5,
SamplingDuration = TimeSpan.FromSeconds(30),
MinimumThroughput = 10,
BreakDuration = TimeSpan.FromSeconds(60),
OnOpened = args =>
{
_logger.LogWarning("Circuit opened after {FailureCount} failures",
args.FailureCount);
return default;
},
OnClosed = args =>
{
_logger.LogInformation("Circuit closed — service recovered");
return default;
}
})
.AddBulkhead(new BulkheadStrategyOptions
{
MaxParallelization = 20,
MaxQueuing = 10
})
.AddTimeout(TimeSpan.FromSeconds(30))
.Build();
}
public async Task<T> GetAsync<T>(string url, CancellationToken ct = default)
{
return await _pipeline.ExecuteAsync(async pipelineCt =>
{
var response = await _httpClient.GetAsync(url, pipelineCt);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<T>(pipelineCt);
}, ct);
}
public async Task<T> GetWithCacheAsync<T>(string url, TimeSpan cacheDuration, CancellationToken ct = default)
{
// Проверяем кэш
if (_cache.TryGetValue(url, out T cached))
return cached;
// Запрос с resilience
var result = await _pipeline.ExecuteAsync(async pipelineCt =>
{
var response = await _httpClient.GetAsync(url, pipelineCt);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<T>(pipelineCt);
}, ct);
// Сохраняем в кэш
_cache.Set(url, result, cacheDuration);
return result;
}
}Adaptive Resilience
Dynamic Policy Adjustment
public class AdaptiveResiliencePolicy
{
private readonly ConcurrentQueue<RequestMetric> _metrics = new();
private readonly TimeSpan _window = TimeSpan.FromMinutes(5);
private ResiliencePipeline _currentPipeline;
public AdaptiveResiliencePolicy()
{
_currentPipeline = CreateNormalPipeline();
}
public ResiliencePipeline GetCurrentPipeline()
{
CleanupOldMetrics();
var errorRate = CalculateErrorRate();
if (errorRate > 0.5)
return CreateDegradedPipeline();
if (errorRate > 0.2)
return CreateCautiousPipeline();
return _currentPipeline;
}
public void RecordMetric(RequestMetric metric)
{
_metrics.Enqueue(metric);
}
private ResiliencePipeline CreateNormalPipeline() => new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 })
.AddTimeout(TimeSpan.FromSeconds(10))
.Build();
private ResiliencePipeline CreateCautiousPipeline() => new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 5, Delay = TimeSpan.FromSeconds(2) })
.AddCircuitBreaker(new CircuitBreakerStrategyOptions { FailureRatio = 0.3, BreakDuration = TimeSpan.FromSeconds(30) })
.AddTimeout(TimeSpan.FromSeconds(5))
.Build();
private ResiliencePipeline CreateDegradedPipeline() => new ResiliencePipelineBuilder()
.AddCircuitBreaker(new CircuitBreakerStrategyOptions { FailureRatio = 0.1, BreakDuration = TimeSpan.FromSeconds(10) })
.AddTimeout(TimeSpan.FromSeconds(2))
.Build();
private double CalculateErrorRate()
{
var recent = _metrics.Where(m => m.Timestamp > DateTime.UtcNow - _window).ToList();
if (recent.Count == 0) return 0;
return (double)recent.Count(m => m.IsError) / recent.Count;
}
private void CleanupOldMetrics()
{
var cutoff = DateTime.UtcNow - _window;
while (_metrics.TryPeek(out var metric) && metric.Timestamp < cutoff)
_metrics.TryDequeue(out _);
}
}
public record RequestMetric(DateTime Timestamp, bool IsError, TimeSpan Duration);Chaos Engineering
Principles
- Build a hypothesis around steady state behavior — "System should respond in < 200ms"
- Vary real-world events — network latency, service crashes, disk full
- Run experiments in production — но с blast radius control
- Automate experiments — continuous chaos testing
- Minimize blast radius — canary, feature flags
Chaos Test Implementation
// Chaos Engineering middleware
public class ChaosMiddleware
{
private readonly RequestDelegate _next;
private readonly ChaosConfig _config;
private readonly ILogger<ChaosMiddleware> _logger;
public ChaosMiddleware(RequestDelegate next, ChaosConfig config, ILogger<ChaosMiddleware> logger)
{
_next = next;
_config = config;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
if (!_config.IsEnabled)
{
await _next(context);
return;
}
// Random failures
if (ShouldFail(_config.FailureRate))
{
_logger.LogWarning("Chaos: Injecting failure for {Path}", context.Request.Path);
var failureType = GetRandomFailureType();
switch (failureType)
{
case FailureType.Exception:
throw new ChaosException("Chaos: Random failure");
case FailureType.Timeout:
await Task.Delay(_config.TimeoutDuration, context.RequestAborted);
break;
case FailureType.Latency:
await Task.Delay(_config.LatencyDuration, context.RequestAborted);
break;
case FailureType.StatusCode:
context.Response.StatusCode = _config.ErrorStatusCode;
return;
}
}
await _next(context);
}
private bool ShouldFail(double failureRate) =>
Random.Shared.NextDouble() < failureRate;
private FailureType GetRandomFailureType()
{
var types = Enum.GetValues<FailureType>();
return types[Random.Shared.Next(types.Length)];
}
}
public class ChaosConfig
{
public bool IsEnabled { get; set; }
public double FailureRate { get; set; } = 0.01; // 1%
public TimeSpan LatencyDuration { get; set; } = TimeSpan.FromSeconds(2);
public TimeSpan TimeoutDuration { get; set; } = TimeSpan.FromSeconds(30);
public int ErrorStatusCode { get; set; } = 500;
}
public enum FailureType { Exception, Timeout, Latency, StatusCode }
public class ChaosException : Exception
{
public ChaosException(string message) : base(message) { }
}Chaos Testing с Polly
public class ChaosTest
{
private readonly ResiliencePipeline _pipeline;
private readonly ChaosMiddleware _chaos;
public ChaosTest(ResiliencePipeline pipeline)
{
_pipeline = pipeline;
}
public async Task RunChaosTestAsync(int iterations = 1000)
{
var successCount = 0;
var failureCount = 0;
var circuitBreakerCount = 0;
for (int i = 0; i < iterations; i++)
{
try
{
await _pipeline.ExecuteAsync(async ct =>
{
// Simulate external call with chaos
await SimulateExternalCallAsync(ct);
});
successCount++;
}
catch (BrokenCircuitException)
{
circuitBreakerCount++;
}
catch
{
failureCount++;
}
}
Console.WriteLine($"Success: {successCount}, Failed: {failureCount}, Circuit Breaker: {circuitBreakerCount}");
}
private async Task SimulateExternalCallAsync(CancellationToken ct)
{
// 10% chance of failure
if (Random.Shared.NextDouble() < 0.1)
throw new HttpRequestException("Simulated failure");
await Task.Delay(Random.Shared.Next(10, 500), ct);
}
}Graceful Degradation vs Fail-Fast
Graceful Degradation
public class ProductService
{
private readonly IProductApiClient _api;
private readonly IProductCache _cache;
private readonly ResiliencePipeline _pipeline;
public async Task<ProductDto> GetProductAsync(string id, CancellationToken ct = default)
{
try
{
return await _pipeline.ExecuteAsync(async pipelineCt =>
{
return await _api.GetProductAsync(id, pipelineCt);
}, ct);
}
catch (Exception ex)
{
// Graceful degradation: используем кэш
_logger.LogWarning(ex, "API failed, falling back to cache for product {Id}", id);
var cached = await _cache.GetAsync(id);
if (cached != null)
{
cached.IsStale = true;
return cached;
}
// Если кэша нет — возвращаем минимальную информацию
return new ProductDto
{
Id = id,
Name = "Product Unavailable",
IsAvailable = false,
FallbackMessage = "Product details temporarily unavailable"
};
}
}
}Fail-Fast
public class PaymentService
{
private readonly IPaymentGateway _gateway;
private readonly ResiliencePipeline _pipeline;
public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request, CancellationToken ct = default)
{
// Fail-fast: нет retry, нет fallback — платёж либо прошёл, либо нет
var timeoutPolicy = Policy.TimeoutAsync(TimeSpan.FromSeconds(5));
try
{
return await timeoutPolicy.ExecuteAsync(async pipelineCt =>
{
return await _gateway.ChargeAsync(request, pipelineCt);
});
}
catch (TimeoutRejectedException)
{
// Не знаем, прошёл платёж или нет — НЕЛЬЗЯ retry
throw new PaymentUncertainException(
"Payment status unknown — do not retry, check manually");
}
catch (Exception ex)
{
throw new PaymentFailedException("Payment failed", ex);
}
}
}Stale-While-Revalidate
public class StaleWhileRevalidateCache<T>
{
private readonly IMemoryCache _cache;
private readonly TimeSpan _stalenessThreshold;
private readonly SemaphoreSlim _semaphore = new(1, 1);
public StaleWhileRevalidateCache(IMemoryCache cache, TimeSpan stalenessThreshold)
{
_cache = cache;
_stalenessThreshold = stalenessThreshold;
}
public async Task<T> GetOrRefreshAsync(
string key,
Func<Task<T>> factory,
CancellationToken ct = default)
{
var cached = _cache.Get<CacheEntry<T>>(key);
if (cached == null)
{
// Нет кэша — загружаем
var value = await factory();
_cache.Set(key, new CacheEntry<T>(value, DateTime.UtcNow),
new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });
return value;
}
// Есть кэш
var age = DateTime.UtcNow - cached.StoredAt;
if (age > _stalenessThreshold)
{
// Stale — запускаем background refresh
_ = RefreshAsync(key, factory, ct);
}
return cached.Value;
}
private async Task RefreshAsync(string key, Func<Task<T>> factory, CancellationToken ct)
{
if (!await _semaphore.WaitAsync(TimeSpan.Zero, ct))
return; // Уже кто-то обновляет
try
{
var value = await factory();
_cache.Set(key, new CacheEntry<T>(value, DateTime.UtcNow),
new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });
}
finally
{
_semaphore.Release();
}
}
}
public record CacheEntry<T>(T Value, DateTime StoredAt);Практика
Задание 1: Resilient HTTP Client
Реализовать resilient HTTP client с composite Polly policies:
- Retry с exponential backoff и jitter
- Circuit Breaker с adaptive threshold
- Bulkhead для isolation
- Timeout с graceful degradation
- Fallback с cached data
Задание 2: Chaos Test
Написать chaos test для проверки system behavior при failures:
- Random latency injection
- Random exception injection
- Random timeout injection
- Metrics collection и analysis
- Pass/fail criteria
Задание 3: Fallback с Cached Data
Реализовать fallback mechanism с:
- Stale-while-revalidate pattern
- Cache invalidation при recovery
- Monitoring staleness
- Gradual degradation levels
Enterprise Architecture Patterns
Multi-Tenancy Patterns
Patterns Overview
| Pattern | Isolation | Cost | Complexity | Когда использовать |
|---|---|---|---|---|
| Database per Tenant | Максимальная | Высокая | Средняя | Enterprise, compliance, HIPAA |
| Schema per Tenant | Высокая | Средняя | Средняя | Mid-market, PostgreSQL |
| Row-level (Discriminator) | Логическая | Низкая | Низкая | SMB, SaaS с тысячами tenants |
Database per Tenant
// Tenant resolution middleware
public class TenantMiddleware
{
private readonly RequestDelegate _next;
public TenantMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context, ITenantResolver resolver, ITenantContext tenantContext)
{
var tenant = await resolver.ResolveAsync(context);
if (tenant == null)
{
context.Response.StatusCode = 401;
return;
}
tenantContext.SetTenant(tenant);
await _next(context);
}
}
// Tenant Context (Scoped)
public class TenantContext : ITenantContext
{
public Tenant Tenant { get; private set; }
public void SetTenant(Tenant tenant) => Tenant = tenant;
}
// Tenant-aware DbContext
public class MultiTenantDbContext : DbContext
{
private readonly ITenantContext _tenantContext;
public MultiTenantDbContext(DbContextOptions options, ITenantContext tenantContext)
: base(options)
{
_tenantContext = tenantContext;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
// Выбираем connection string на основе tenant
var connectionString = GetConnectionStringForTenant(_tenantContext.Tenant);
optionsBuilder.UseSqlServer(connectionString);
}
private string GetConnectionStringForTenant(Tenant tenant)
{
// Из конфигурации или cache
return $"Server=...;Database={tenant.DatabaseName};...";
}
}
// Tenant-aware repository
public class EfRepository<T> : IRepository<T> where T : class
{
private readonly MultiTenantDbContext _context;
public EfRepository(MultiTenantDbContext context) => _context = context;
public async Task<T> GetByIdAsync(Guid id, CancellationToken ct = default)
{
return await _context.Set<T>().FindAsync(new object[] { id }, ct);
}
}Schema per Tenant
public class SchemaPerTenantDbContext : DbContext
{
private readonly ITenantContext _tenantContext;
public SchemaPerTenantDbContext(DbContextOptions options, ITenantContext tenantContext)
: base(options)
{
_tenantContext = tenantContext;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Устанавливаем schema для всех entities
var schema = _tenantContext.Tenant.SchemaName;
foreach (var entity in modelBuilder.Model.GetEntityTypes())
{
entity.SetSchema(schema);
}
}
}Row-level Multi-Tenancy
// TenantId на каждой entity
public abstract class MultiTenantEntity
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
}
// Global Query Filter — автоматически фильтрует по tenant
public class AppDbContext : DbContext
{
private readonly ITenantContext _tenantContext;
public AppDbContext(DbContextOptions options, ITenantContext tenantContext)
: base(options)
{
_tenantContext = tenantContext;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Применяем фильтр ко всем MultiTenantEntity
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (typeof(MultiTenantEntity).IsAssignableFrom(entityType.ClrType))
{
var parameter = Expression.Parameter(entityType.ClrType, "e");
var property = Expression.Property(parameter, nameof(MultiTenantEntity.TenantId));
var tenantId = Expression.Constant(_tenantContext.Tenant.Id);
var equality = Expression.Equal(property, tenantId);
var lambda = Expression.Lambda(equality, parameter);
modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda);
}
}
}
}
// Tenant resolution из subdomain
public class SubdomainTenantResolver : ITenantResolver
{
private readonly ITenantStore _store;
public async Task<Tenant> ResolveAsync(HttpContext context)
{
var host = context.Request.Host.Host; // tenant1.example.com
var subdomain = host.Split('.')[0];
return await _store.GetBySubdomainAsync(subdomain);
}
}Plugin Architecture
Assembly Loading
public class PluginManager : IPluginManager
{
private readonly ILogger<PluginManager> _logger;
private readonly List<IPlugin> _plugins = new();
private readonly List<AssemblyLoadContext> _contexts = new();
public async Task LoadPluginsAsync(string pluginsDirectory)
{
var pluginFiles = Directory.GetFiles(pluginsDirectory, "*.dll");
foreach (var file in pluginFiles)
{
try
{
var context = new PluginLoadContext(file);
var assembly = context.LoadFromAssemblyName(new AssemblyName(Path.GetFileNameWithoutExtension(file)));
var pluginTypes = assembly.GetTypes()
.Where(t => typeof(IPlugin).IsAssignableFrom(t) && !t.IsAbstract);
foreach (var type in pluginTypes)
{
var plugin = (IPlugin)Activator.CreateInstance(type);
await plugin.InitializeAsync();
_plugins.Add(plugin);
_contexts.Add(context);
_logger.LogInformation("Loaded plugin: {PluginName}", plugin.Name);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load plugin from {File}", file);
}
}
}
public IEnumerable<IPlugin> GetPlugins<T>() where T : IPlugin =>
_plugins.OfType<T>();
public async Task UnloadPluginAsync(string pluginName)
{
var plugin = _plugins.FirstOrDefault(p => p.Name == pluginName);
if (plugin == null) return;
await plugin.DisposeAsync();
_plugins.Remove(plugin);
var context = _contexts.FirstOrDefault();
context?.Unload();
_contexts.Remove(context);
}
}
// Isolated AssemblyLoadContext для hot-reload
public class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver;
public PluginLoadContext(string pluginPath) : base(isCollectible: true)
{
_resolver = new AssemblyDependencyResolver(pluginPath);
}
protected override Assembly Load(AssemblyName assemblyName)
{
var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
if (assemblyPath != null)
{
return LoadFromAssemblyPath(assemblyPath);
}
return null;
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
var libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
if (!string.IsNullOrEmpty(libraryPath))
{
return LoadUnmanagedDllFromPath(libraryPath);
}
return IntPtr.Zero;
}
}
// Plugin interface
public interface IPlugin : IAsyncDisposable
{
string Name { get; }
string Version { get; }
Task InitializeAsync();
}
// Plugin implementation
public class PaymentPlugin : IPlugin
{
public string Name => "StripePayment";
public string Version => "1.0.0";
public Task InitializeAsync()
{
// Регистрация в DI, настройка
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
// Cleanup
return ValueTask.CompletedTask;
}
}Hot-Reload
public class HotReloadPluginManager : BackgroundService
{
private readonly PluginManager _pluginManager;
private readonly string _pluginsDirectory;
private readonly ILogger<HotReloadPluginManager> _logger;
private FileSystemWatcher _watcher;
public HotReloadPluginManager(
PluginManager pluginManager,
string pluginsDirectory,
ILogger<HotReloadPluginManager> logger)
{
_pluginManager = pluginManager;
_pluginsDirectory = pluginsDirectory;
_logger = logger;
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
_watcher = new FileSystemWatcher(_pluginsDirectory, "*.dll");
_watcher.Created += async (s, e) => await OnPluginAddedAsync(e.FullPath);
_watcher.Deleted += async (s, e) => await OnPluginRemovedAsync(e.FullPath);
_watcher.Changed += async (s, e) => await OnPluginChangedAsync(e.FullPath);
_watcher.EnableRaisingEvents = true;
return Task.CompletedTask;
}
private async Task OnPluginChangedAsync(string path)
{
var pluginName = Path.GetFileNameWithoutExtension(path);
_logger.LogInformation("Hot-reloading plugin: {PluginName}", pluginName);
await _pluginManager.UnloadPluginAsync(pluginName);
// Ждём, пока файл освободится
await Task.Delay(1000);
await _pluginManager.LoadPluginsAsync(_pluginsDirectory);
}
}Feature Toggles
Launch Darkly Pattern
// Feature Flag интерфейс
public interface IFeatureManager
{
Task<bool> IsEnabledAsync(string featureName, CancellationToken ct = default);
Task<bool> IsEnabledAsync(string featureName, User user, CancellationToken ct = default);
Task<double> GetRolloutPercentageAsync(string featureName, CancellationToken ct = default);
}
// Feature Flag сервис
public class FeatureManager : IFeatureManager
{
private readonly IFeatureStore _store;
private readonly IMemoryCache _cache;
public async Task<bool> IsEnabledAsync(string featureName, User user, CancellationToken ct = default)
{
var flag = await _store.GetAsync(featureName);
if (flag == null) return false;
if (!flag.Enabled) return false;
// Gradual rollout
if (flag.RolloutPercentage < 100)
{
var hash = GetConsistentHash(featureName, user.Id);
if (hash > flag.RolloutPercentage) return false;
}
// Targeting rules
if (flag.TargetingRules.Any())
{
foreach (var rule in flag.TargetingRules)
{
if (rule.Matches(user))
return rule.Value;
}
}
return flag.DefaultValue;
}
private int GetConsistentHash(string featureName, string userId)
{
using var sha256 = SHA256.Create();
var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes($"{featureName}:{userId}"));
return Math.Abs(BitConverter.ToInt32(bytes, 0)) % 100;
}
}
// Feature Flag конфигурация
public class FeatureFlag
{
public string Name { get; set; }
public bool Enabled { get; set; }
public bool DefaultValue { get; set; }
public double RolloutPercentage { get; set; } = 100;
public List<TargetingRule> TargetingRules { get; set; } = new();
}
public class TargetingRule
{
public Expression<Func<User, bool>> Condition { get; set; }
public bool Value { get; set; }
public bool Matches(User user) => Condition.Compile()(user);
}
// Использование
public class CheckoutController : ControllerBase
{
private readonly IFeatureManager _features;
[HttpPost("checkout")]
public async Task<ActionResult> Checkout(CheckoutRequest request)
{
var user = GetCurrentUser();
if (await _features.IsEnabledAsync("new-checkout-flow", user))
{
return await ProcessNewCheckoutAsync(request);
}
return await ProcessLegacyCheckoutAsync(request);
}
}
// Feature Flag middleware для kill switch
public class FeatureFlagMiddleware
{
private readonly RequestDelegate _next;
public async Task InvokeAsync(HttpContext context, IFeatureManager features)
{
var path = context.Request.Path.Value;
if (path.StartsWith("/api/v2/") && !await features.IsEnabledAsync("api-v2"))
{
context.Response.StatusCode = 404;
return;
}
await _next(context);
}
}Blue-Green Deployment
Концепция
┌──────────────────────────────────────────────────┐
│ Load Balancer │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Blue │ │ Green │ │
│ │ (active) │ │ (idle) │ │
│ │ v2.0 │ │ v2.1 │ │
│ └─────────────┘ └─────────────┘ │
└──────────────────────────────────────────────────┘
Switch: Load Balancer переключает traffic на Green
Rollback: Load Balancer переключает traffic обратно на BlueРеализация в .NET
// Health check для readiness
public class DeploymentHealthCheck : IHealthCheck
{
private readonly IDeploymentState _deploymentState;
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken ct = default)
{
if (_deploymentState.IsReady)
return Task.FromResult(HealthCheckResult.Healthy("Deployment is ready"));
return Task.FromResult(HealthCheckResult.Unhealthy("Deployment is not ready"));
}
}
// Readiness probe
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Name == "deployment"
});
// Liveness probe
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = check => check.Name == "liveness"
});Canary Releases
┌─────────────────────────────────────────────────────┐
│ Traffic Router │
│ │
│ 95% ──────────────▶ Blue (v2.0) │
│ │
│ 5% ──────────────▶ Green (v2.1) ← Canary │
│ │
│ Если metrics OK → увеличиваем % canary │
│ Если metrics BAD → rollback │
└─────────────────────────────────────────────────────┘public class CanaryMiddleware
{
private readonly RequestDelegate _next;
private readonly IFeatureManager _features;
private readonly IMetricsService _metrics;
public async Task InvokeAsync(HttpContext context)
{
var canaryPercentage = await _features.GetRolloutPercentageAsync("canary-release");
if (IsCanaryUser(context, canaryPercentage))
{
// Canary user — направляем на новую версию
context.Items["Version"] = "canary";
context.Response.Headers["X-Version"] = "canary";
}
else
{
context.Items["Version"] = "stable";
context.Response.Headers["X-Version"] = "stable";
}
await _next(context);
}
private bool IsCanaryUser(HttpContext context, double percentage)
{
var userId = context.User.FindFirst("sub")?.Value;
if (userId == null) return false;
var hash = GetConsistentHash("canary", userId);
return hash < percentage;
}
}Strangler Fig Pattern
Концепция
Постепенная миграция legacy системы путём "оборачивания" старой функциональности новой.
┌─────────────────────────────────────────────────────┐
│ Facade / Proxy │
│ │
│ Request ──▶ ┌───────────────┐ │
│ │ Router │ │
│ │ │ │
│ │ /api/orders ──▶ New Service │
│ │ /api/users ──▶ New Service │
│ │ /api/legacy ──▶ Legacy System │
│ └───────────────┘ │
└─────────────────────────────────────────────────────┘
Шаг 1: Создаём Facade перед legacy системой
Шаг 2: По одному переносим endpoints в новые сервисы
Шаг 3: Когда всё перенесено — убираем legacyРеализация
public class StranglerMiddleware
{
private readonly RequestDelegate _next;
private readonly HttpClient _legacyClient;
private readonly IFeatureManager _features;
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path.Value;
// Проверяем, мигрирован ли endpoint
if (await _features.IsEnabledAsync($"migrated-{path}"))
{
// Новый сервис обрабатывает
await _next(context);
}
else
{
// Proxy к legacy системе
await ProxyToLegacyAsync(context);
}
}
private async Task ProxyToLegacyAsync(HttpContext context)
{
var request = new HttpRequestMessage(
new HttpMethod(context.Request.Method),
$"https://legacy-system{context.Request.Path}{context.Request.QueryString}");
// Копируем headers и body
foreach (var header in context.Request.Headers)
request.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray());
if (context.Request.Method != "GET" && context.Request.Method != "HEAD")
request.Content = new StreamContent(context.Request.Body);
var response = await _legacyClient.SendAsync(request);
context.Response.StatusCode = (int)response.StatusCode;
foreach (var header in response.Headers)
context.Response.Headers[header.Key] = header.Value.ToArray();
await response.Content.CopyToAsync(context.Response.Body);
}
}Практика
Задание 1: Multi-Tenant Middleware
Реализовать multi-tenant middleware с:
- Tenant resolution из subdomain / header / JWT claim
- Tenant isolation (database per tenant или row-level)
- Tenant-aware DbContext с global query filters
- Tenant-specific configuration
Задание 2: Plugin System с Hot-Reload
Создать plugin system с:
- Assembly loading через AssemblyLoadContext
- Plugin isolation (каждый plugin в отдельном context)
- Hot-reload при изменении DLL
- Plugin lifecycle (initialize, dispose)
Задание 3: Feature Flag System
Реализовать feature flag system с:
- Gradual rollout (percentage-based)
- User targeting rules
- Kill switch
- Dynamic configuration (без restart)
- Audit log изменений
Event Sourcing и CQRS
Event Store
Append-Only Stream
// Event — неизменяемый факт
public abstract class DomainEvent
{
public Guid EventId { get; } = Guid.NewGuid();
public DateTime OccurredAt { get; } = DateTime.UtcNow;
public int Version { get; set; }
}
// Конкретные события
public record OrderCreatedEvent(Guid OrderId, Guid CustomerId, decimal Total) : DomainEvent;
public record OrderItemAddedEvent(Guid OrderId, Guid ProductId, int Quantity, decimal Price) : DomainEvent;
public record OrderItemRemovedEvent(Guid OrderId, Guid ProductId) : DomainEvent;
public record OrderConfirmedEvent(Guid OrderId) : DomainEvent;
public record OrderCancelledEvent(Guid OrderId, string Reason) : DomainEvent;
// Event Stream — последовательность событий для одного Aggregate
public class EventStream
{
public string StreamId { get; }
public int CurrentVersion { get; private set; }
private readonly List<DomainEvent> _events = new();
public EventStream(string streamId, int currentVersion = 0)
{
StreamId = streamId;
CurrentVersion = currentVersion;
}
public IReadOnlyList<DomainEvent> Events => _events.AsReadOnly();
public void AddEvent(DomainEvent @event)
{
CurrentVersion++;
@event.Version = CurrentVersion;
_events.Add(@event);
}
}
// Event Store — append-only
public interface IEventStore
{
Task<EventStream> LoadStreamAsync(string streamId, CancellationToken ct = default);
Task SaveStreamAsync(EventStream stream, int expectedVersion, CancellationToken ct = default);
}
public class SqlEventStore : IEventStore
{
private readonly IDbConnection _connection;
private readonly ISerializer _serializer;
public async Task<EventStream> LoadStreamAsync(string streamId, CancellationToken ct = default)
{
var sql = """
SELECT Version, EventType, EventData, OccurredAt
FROM Events
WHERE StreamId = @StreamId
ORDER BY Version
""";
var events = new List<DomainEvent>();
int version = 0;
var results = await _connection.QueryAsync<EventRecord>(sql, new { StreamId = streamId });
foreach (var record in results)
{
version = record.Version;
var @event = _serializer.Deserialize(record.EventData, record.EventType);
@event.Version = record.Version;
events.Add(@event);
}
return new EventStream(streamId, version) { Events = events };
}
public async Task SaveStreamAsync(EventStream stream, int expectedVersion, CancellationToken ct = default)
{
using var transaction = _connection.BeginTransaction();
try
{
// Optimistic concurrency check
var currentVersion = await _connection.ExecuteScalarAsync<int>(
"SELECT COALESCE(MAX(Version), 0) FROM Events WHERE StreamId = @StreamId",
new { StreamId = stream.StreamId },
transaction);
if (currentVersion != expectedVersion)
throw new ConcurrencyException(
$"Stream {stream.StreamId} expected version {expectedVersion} but found {currentVersion}");
// Append events
foreach (var @event in stream.Events)
{
var sql = """
INSERT INTO Events (StreamId, Version, EventType, EventData, OccurredAt)
VALUES (@StreamId, @Version, @EventType, @EventData, @OccurredAt)
""";
await _connection.ExecuteAsync(sql, new
{
StreamId = stream.StreamId,
Version = @event.Version,
EventType = @event.GetType().Name,
EventData = _serializer.Serialize(@event),
OccurredAt = @event.OccurredAt
}, transaction);
}
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
}
public record EventRecord(int Version, string EventType, string EventData, DateTime OccurredAt);Aggregate из Events
// Aggregate с Event Sourcing
public class Order : AggregateRoot
{
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public decimal Total { get; private set; }
private readonly List<OrderItem> _items = new();
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
// Static factory — создаёт новый aggregate
public static Order Create(Guid customerId)
{
var order = new Order();
var @event = new OrderCreatedEvent(Guid.NewGuid(), customerId, 0);
order.Apply(@event);
order.AddUncommittedEvent(@event);
return order;
}
// Методы, которые генерируют события
public void AddItem(Guid productId, int quantity, decimal price)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Cannot modify non-draft order");
var @event = new OrderItemAddedEvent(Id, productId, quantity, price);
Apply(@event);
AddUncommittedEvent(@event);
}
public void RemoveItem(Guid productId)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Cannot modify non-draft order");
var @event = new OrderItemRemovedEvent(Id, productId);
Apply(@event);
AddUncommittedEvent(@event);
}
public void Confirm()
{
if (Status != OrderStatus.Draft)
throw new DomainException("Order must be in Draft status");
if (!_items.Any())
throw new DomainException("Cannot confirm empty order");
var @event = new OrderConfirmedEvent(Id);
Apply(@event);
AddUncommittedEvent(@event);
}
public void Cancel(string reason)
{
if (Status == OrderStatus.Cancelled)
throw new DomainException("Order already cancelled");
var @event = new OrderCancelledEvent(Id, reason);
Apply(@event);
AddUncommittedEvent(@event);
}
// Apply — изменяет state на основе события
private void Apply(OrderCreatedEvent @event)
{
Id = @event.OrderId;
CustomerId = @event.CustomerId;
Status = OrderStatus.Draft;
Total = 0;
}
private void Apply(OrderItemAddedEvent @event)
{
_items.Add(new OrderItem(@event.ProductId, @event.Quantity, @event.Price));
Total += @event.Quantity * @event.Price;
}
private void Apply(OrderItemRemovedEvent @event)
{
var item = _items.FirstOrDefault(i => i.ProductId == @event.ProductId);
if (item != null)
{
Total -= item.Quantity * item.Price;
_items.Remove(item);
}
}
private void Apply(OrderConfirmedEvent @event) => Status = OrderStatus.Confirmed;
private void Apply(OrderCancelledEvent @event) => Status = OrderStatus.Cancelled;
// Rebuild state из событий
public void RebuildFromHistory(IEnumerable<DomainEvent> history)
{
foreach (var @event in history)
{
Apply(@event);
}
ClearUncommittedEvents();
}
}
// Базовый Aggregate Root для ES
public abstract class AggregateRoot
{
private readonly List<DomainEvent> _uncommittedEvents = new();
public IReadOnlyList<DomainEvent> UncommittedEvents => _uncommittedEvents.AsReadOnly();
protected void AddUncommittedEvent(DomainEvent @event) => _uncommittedEvents.Add(@event);
protected void ClearUncommittedEvents() => _uncommittedEvents.Clear();
public void MarkEventsAsCommitted() => _uncommittedEvents.Clear();
}Projections — Read Model Building
Projection Handler
// Projection — строит read model из events
public interface IProjection
{
string Name { get; }
Task HandleAsync(DomainEvent @event, CancellationToken ct = default);
}
// Order Summary Projection
public class OrderSummaryProjection : IProjection
{
public string Name => "OrderSummary";
private readonly IDbConnection _connection;
public async Task HandleAsync(DomainEvent @event, CancellationToken ct = default)
{
switch (@event)
{
case OrderCreatedEvent e:
await _connection.ExecuteAsync("""
INSERT INTO OrderSummary (OrderId, CustomerId, Total, Status, CreatedAt)
VALUES (@OrderId, @CustomerId, @Total, @Status, @CreatedAt)
""", new
{
OrderId = e.OrderId,
CustomerId = e.CustomerId,
Total = e.Total,
Status = "Draft",
CreatedAt = e.OccurredAt
});
break;
case OrderItemAddedEvent e:
await _connection.ExecuteAsync("""
UPDATE OrderSummary
SET Total = Total + (@Quantity * @Price), ItemCount = ItemCount + 1
WHERE OrderId = @OrderId
""", new { e.OrderId, e.Quantity, e.Price });
break;
case OrderConfirmedEvent e:
await _connection.ExecuteAsync("""
UPDATE OrderSummary SET Status = 'Confirmed' WHERE OrderId = @OrderId
""", new { e.OrderId });
break;
case OrderCancelledEvent e:
await _connection.ExecuteAsync("""
UPDATE OrderSummary SET Status = 'Cancelled' WHERE OrderId = @OrderId
""", new { e.OrderId });
break;
}
}
}
// Event Processor — dispatches events to projections
public class EventProcessor : BackgroundService
{
private readonly IEventStore _eventStore;
private readonly IEnumerable<IProjection> _projections;
private readonly IPositionTracker _positionTracker;
private readonly ILogger<EventProcessor> _logger;
public EventProcessor(
IEventStore eventStore,
IEnumerable<IProjection> projections,
IPositionTracker positionTracker,
ILogger<EventProcessor> logger)
{
_eventStore = eventStore;
_projections = projections;
_positionTracker = positionTracker;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var lastPosition = await _positionTracker.GetLastPositionAsync();
var newEvents = await _eventStore.GetEventsAfterAsync(lastPosition, stoppingToken);
foreach (var @event in newEvents)
{
foreach (var projection in _projections)
{
try
{
await projection.HandleAsync(@event, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Projection {Projection} failed on event {Event}",
projection.Name, @event.GetType().Name);
}
}
await _positionTracker.UpdatePositionAsync(@event.Version);
}
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
}
}
}Snapshotting
Зачем нужны Snapshots
Проблема: При большом количестве событий rebuild aggregate становится медленным.
Решение: Периодически сохраняем snapshot состояния aggregate.
Events: [E1, E2, E3, ..., E99, E100]
Snapshot: [State@50]
Rebuild: Load Snapshot@50 → Apply E51..E100 (вместо E1..E100)Реализация
public class Snapshot
{
public string StreamId { get; set; }
public int Version { get; set; }
public string State { get; set; } // Сериализованное состояние
public DateTime CreatedAt { get; set; }
}
public interface ISnapshotStore
{
Task<Snapshot> GetSnapshotAsync(string streamId, CancellationToken ct = default);
Task SaveSnapshotAsync(Snapshot snapshot, CancellationToken ct = default);
}
public class AggregateWithSnapshotting
{
private readonly IEventStore _eventStore;
private readonly ISnapshotStore _snapshotStore;
private readonly int _snapshotThreshold = 50;
public async Task<T> LoadAggregateAsync<T>(string streamId, CancellationToken ct = default)
where T : AggregateRoot, new()
{
// 1. Пробуем загрузить snapshot
var snapshot = await _snapshotStore.GetSnapshotAsync(streamId, ct);
var aggregate = new T();
int fromVersion = 0;
if (snapshot != null)
{
// Восстанавливаем из snapshot
aggregate = DeserializeSnapshot<T>(snapshot);
fromVersion = snapshot.Version;
}
// 2. Загружаем события после snapshot
var events = await _eventStore.GetEventsAfterAsync(streamId, fromVersion, ct);
foreach (var @event in events)
{
aggregate.ApplyEvent(@event);
}
return aggregate;
}
public async Task SaveAggregateAsync(AggregateRoot aggregate, int expectedVersion, CancellationToken ct = default)
{
await _eventStore.SaveStreamAsync(aggregate, expectedVersion, ct);
// 3. Сохраняем snapshot если threshold достигнут
if (aggregate.Version >= _snapshotThreshold &&
aggregate.Version % _snapshotThreshold == 0)
{
var snapshot = new Snapshot
{
StreamId = aggregate.StreamId,
Version = aggregate.Version,
State = SerializeAggregate(aggregate),
CreatedAt = DateTime.UtcNow
};
await _snapshotStore.SaveSnapshotAsync(snapshot, ct);
}
}
}Event Versioning и Migration
Проблема
v1: OrderCreatedEvent { OrderId, CustomerId, Total }
v2: OrderCreatedEvent { OrderId, CustomerId, Total, Currency }
v3: OrderCreatedEvent { OrderId, CustomerId, Items, Total, Currency }Upcasting
public interface IEventUpcaster
{
DomainEvent Upcast(DomainEvent @event);
bool CanUpcast(DomainEvent @event);
}
// Upcaster v1 → v2
public class OrderCreatedEventV1ToV2Upcaster : IEventUpcaster
{
public bool CanUpcast(DomainEvent @event) =>
@event is OrderCreatedEventV1;
public DomainEvent Upcast(DomainEvent @event)
{
var v1 = (OrderCreatedEventV1)@event;
return new OrderCreatedEventV2(
v1.OrderId,
v1.CustomerId,
v1.Total,
"USD" // Default для старых событий
);
}
}
// Upcaster v2 → v3
public class OrderCreatedEventV2ToV3Upcaster : IEventUpcaster
{
public bool CanUpcast(DomainEvent @event) =>
@event is OrderCreatedEventV2;
public DomainEvent Upcast(DomainEvent @event)
{
var v2 = (OrderCreatedEventV2)@event;
return new OrderCreatedEventV3(
v2.OrderId,
v2.CustomerId,
new[] { new OrderItemSnapshot { Total = v2.Total } }, // Approximation
v2.Total,
v2.Currency
);
}
}
// Upcaster pipeline
public class EventUpcasterPipeline
{
private readonly List<IEventUpcaster> _upcasters = new();
public void AddUpcaster(IEventUpcaster upcaster) => _upcasters.Add(upcaster);
public DomainEvent Upcast(DomainEvent @event)
{
var current = @event;
while (true)
{
var upcaster = _upcasters.FirstOrDefault(u => u.CanUpcast(current));
if (upcaster == null) break;
current = upcaster.Upcast(current);
}
return current;
}
}
// Использование при загрузке событий
public class EventStoreWithUpcasting : IEventStore
{
private readonly IEventStore _inner;
private readonly EventUpcasterPipeline _upcasters;
public async Task<EventStream> LoadStreamAsync(string streamId, CancellationToken ct = default)
{
var stream = await _inner.LoadStreamAsync(streamId, ct);
var upcastedEvents = stream.Events.Select(e => _upcasters.Upcast(e)).ToList();
return new EventStream(streamId, stream.CurrentVersion)
{
Events = upcastedEvents
};
}
}Event Schema Evolution с JSON
// Используем JSON с $schema полем
public record OrderCreatedEventV1(
Guid OrderId,
Guid CustomerId,
decimal Total,
string SchemaVersion = "1.0");
public record OrderCreatedEventV2(
Guid OrderId,
Guid CustomerId,
decimal Total,
string Currency,
string SchemaVersion = "2.0");
// При десериализации проверяем версию
public class VersionedEventSerializer : ISerializer
{
public DomainEvent Deserialize(string json, string eventType)
{
using var doc = JsonDocument.Parse(json);
var version = doc.RootElement.GetProperty("SchemaVersion").GetString();
return version switch
{
"1.0" => JsonSerializer.Deserialize<OrderCreatedEventV1>(json),
"2.0" => JsonSerializer.Deserialize<OrderCreatedEventV2>(json),
_ => throw new UnknownSchemaVersionException(version)
};
}
}CQRS Trade-offs
Когда CQRS полезен
| Сценарий | Без CQRS | С CQRS |
|---|---|---|
| Simple CRUD | ✅ Просто | ❌ Overkill |
| Read-heavy (100:1) | ❌ Медленные reads | ✅ Оптимизированные read models |
| Complex queries | ❌ Domain model polluted | ✅ Специализированные projections |
| Event Sourcing | ❌ Невозможно без CQRS | ✅ Естественное разделение |
| Team scaling | ❌ Конфликты на модели | ✅ Разные команды на read/write |
CQRS с MediatR
// Commands (Write)
public record CreateOrderCommand(Guid CustomerId, List<OrderItemDto> Items) : IRequest<OrderResult>;
public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, OrderResult>
{
private readonly IEventStore _eventStore;
public async Task<OrderResult> Handle(CreateOrderCommand request, CancellationToken ct)
{
var order = Order.Create(request.CustomerId);
foreach (var item in request.Items)
order.AddItem(item.ProductId, item.Quantity, item.Price);
await _eventStore.SaveStreamAsync(order, -1, ct);
return new OrderResult(order.Id, order.Status);
}
}
// Queries (Read)
public record GetOrderQuery(Guid OrderId) : IRequest<OrderDto>;
public class GetOrderHandler : IRequestHandler<GetOrderQuery, OrderDto>
{
private readonly IReadDbContext _readDb;
public async Task<OrderDto> Handle(GetOrderQuery request, CancellationToken ct)
{
// Читаем из оптимизированной read model
return await _readDb.Orders
.Where(o => o.Id == request.OrderId)
.Select(o => new OrderDto
{
Id = o.Id,
CustomerId = o.CustomerId,
Total = o.Total,
Status = o.Status,
Items = o.Items.Select(i => new OrderItemDto
{
ProductId = i.ProductId,
Quantity = i.Quantity,
Price = i.Price
}).ToList()
})
.FirstOrDefaultAsync(ct);
}
}
// Controller
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator) => _mediator = mediator;
[HttpPost]
public async Task<ActionResult<OrderResult>> CreateOrder(CreateOrderCommand command)
{
var result = await _mediator.Send(command);
return CreatedAtAction(nameof(GetOrder), new { id = result.Id }, result);
}
[HttpGet("{id}")]
public async Task<ActionResult<OrderDto>> GetOrder(Guid id)
{
var order = await _mediator.Send(new GetOrderQuery(id));
if (order == null) return NotFound();
return order;
}
}Практика
Задание 1: Event Store с Stream Projection
Реализовать:
- Append-only event store (SQL или in-memory)
- Optimistic concurrency control
- Projection handler для build read model
- Event processor для dispatch events к projections
Задание 2: CQRS с Separate Read/Write Models
Реализовать:
- Write model с Event Sourcing
- Read model с материализованными views
- MediatR для command/query dispatch
- Projection для синхронизации read/write
Задание 3: Event Versioning Handler
Реализовать:
- Event upcaster pipeline
- Поддержка нескольких версий событий
- Backward compatibility
- Migration strategy для existing events
System Design для .NET
Capacity Planning
RPS, Latency Budgets, Resource Estimation
Формулы:
RPS (Requests Per Second) = Total Requests / Time Window
P99 Latency = 99th percentile response time
Throughput = RPS * Average Response Size
CPU Required = (RPS * Avg CPU Time per Request) / CPU Cores
Memory Required = (Concurrent Requests * Memory per Request) + Base MemoryПример: Capacity Plan для 1M DAU
Assumptions:
- 1M Daily Active Users
- Average 20 requests per user per day
- Peak hour = 10% of daily traffic
- P99 latency target = 200ms
- Average response size = 50KB
Calculations:
- Daily requests = 1M * 20 = 20M
- Peak hour requests = 20M * 0.1 = 2M
- Peak RPS = 2M / 3600 = ~555 RPS
- With 3x headroom = ~1,700 RPS
Per-instance capacity (4 CPU, 8GB):
- Each instance handles ~500 RPS (P99 < 200ms)
- Instances needed = 1,700 / 500 = 4 (round up)
- With redundancy = 6 instances
Memory:
- Per request: 50KB response + 100KB overhead = 150KB
- Concurrent requests: 500 RPS * 0.2s avg = 100 concurrent
- Memory per instance: 100 * 150KB + 500MB base = ~515MB
- 8GB is more than enough
Network:
- Bandwidth: 1,700 RPS * 50KB = 85 MB/s = 680 Mbps
- Need 1Gbps network interfaceLittle's Law
Concurrent Requests = RPS * Average Response Time
Пример:
- 1000 RPS
- Avg response time = 50ms = 0.05s
- Concurrent = 1000 * 0.05 = 50 concurrent requestsHorizontal vs Vertical Scaling
Vertical Scaling (Scale Up)
┌──────────────────────────────────────┐
│ Single Server │
│ ┌────────────────────────────────┐ │
│ │ More CPU, More RAM, More SSD │ │
│ └────────────────────────────────┘ │
└──────────────────────────────────────┘
Pros: Simple, no distributed system complexity
Cons: Hard limit, single point of failure, expensiveHorizontal Scaling (Scale Out)
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Server 1 │ │ Server 2 │ │ Server 3 │
│ (app) │ │ (app) │ │ (app) │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└─────────────┼─────────────┘
│
┌──────▼──────┐
│ Load Balancer│
└─────────────┘
Pros: No hard limit, fault tolerance, cost-effective
Cons: Distributed complexity, stateless requiredStateless Design для Horizontal Scaling
// Stateful — НЕ масштабируется горизонтально
public class ShoppingCartService
{
private static readonly ConcurrentDictionary<string, Cart> _carts = new();
// Data lost on restart, not shared between instances
}
// Stateless — масштабируется
public class ShoppingCartService
{
private readonly ICartRepository _repository;
private readonly IDistributedCache _cache;
public async Task<Cart> GetCartAsync(string userId, CancellationToken ct = default)
{
var cacheKey = $"cart:{userId}";
var cached = await _cache.GetStringAsync(cacheKey, ct);
if (cached != null) return JsonSerializer.Deserialize<Cart>(cached);
var cart = await _repository.GetByUserIdAsync(userId, ct);
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(cart),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) }, ct);
return cart;
}
}Caching Strategies
Multi-Level Caching
┌─────────────────────────────────────────────────────────┐
│ Client Browser │
│ (Browser Cache, Service Worker) │
├─────────────────────────────────────────────────────────┤
│ CDN │
│ (CloudFlare, CloudFront, Akamai) │
├─────────────────────────────────────────────────────────┤
│ Reverse Proxy (YARP) │
│ (Response Caching, Output Cache) │
├─────────────────────────────────────────────────────────┤
│ Application (IMemoryCache) │
│ (In-process, per-instance) │
├─────────────────────────────────────────────────────────┤
│ Distributed Cache (Redis) │
│ (Shared across instances) │
├─────────────────────────────────────────────────────────┤
│ Database │
└─────────────────────────────────────────────────────────┘Client-Side Caching
// HTTP Cache Headers
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/api/public"))
{
context.Response.Headers["Cache-Control"] = "public, max-age=300"; // 5 min
context.Response.Headers["ETag"] = GenerateETag(context.Request.Path);
}
await next();
});
// Conditional GET — If-None-Match
public class ETagMiddleware
{
private readonly RequestDelegate _next;
public async Task InvokeAsync(HttpContext context)
{
var ifNoneMatch = context.Request.Headers["If-None-Match"].ToString();
var etag = GenerateETag(context.Request.Path);
if (ifNoneMatch == etag)
{
context.Response.StatusCode = 304; // Not Modified
return;
}
context.Response.Headers["ETag"] = etag;
await _next(context);
}
}CDN Caching
// Cache-Control для CDN
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)]
public IActionResult GetProduct(string id)
{
return Ok(_products.GetById(id));
}
// Cache invalidation через CDN API
public class CdnCacheInvalidator
{
private readonly HttpClient _cdnClient;
public async Task InvalidateAsync(string path)
{
await _cdnClient.PostAsync("/purge", new StringContent(
JsonSerializer.Serialize(new { paths = new[] { path } })
));
}
}Reverse Proxy Caching (YARP)
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddOutputCache(); // Response caching
// Config
"ReverseProxy": {
"Routes": {
"cached-route": {
"ClusterId": "api-cluster",
"Match": { "Path": "/api/products/{**catch-all}" },
"OutputCache": { "PolicyName": "products" }
}
},
"OutputCacheProfiles": {
"products": {
"Expire": "00:05:00",
"VaryByHeader": "Accept-Encoding"
}
}
}Distributed Cache (Redis)
// NuGet: Microsoft.Extensions.Caching.StackExchangeRedis
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "redis:6379";
options.InstanceName = "MyApp:";
});
// Использование
public class ProductService
{
private readonly IDistributedCache _cache;
private readonly IProductRepository _repository;
public async Task<Product> GetProductAsync(string id, CancellationToken ct = default)
{
var cacheKey = $"product:{id}";
var cached = await _cache.GetStringAsync(cacheKey, ct);
if (cached != null)
return JsonSerializer.Deserialize<Product>(cached);
var product = await _repository.GetByIdAsync(id, ct);
if (product != null)
{
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(product),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
SlidingExpiration = TimeSpan.FromMinutes(5)
}, ct);
}
return product;
}
public async Task UpdateProductAsync(Product product, CancellationToken ct = default)
{
await _repository.UpdateAsync(product, ct);
// Invalidate cache
await _cache.RemoveAsync($"product:{product.Id}", ct);
}
}Cache Invalidation Patterns
Cache-Aside (Lazy Loading)
// Самый распространённый паттерн
public async Task<T> GetAsync(string key)
{
// 1. Проверяем кэш
var cached = await _cache.GetAsync<T>(key);
if (cached != null) return cached;
// 2. Cache miss — загружаем из источника
var data = await _source.GetAsync(key);
// 3. Сохраняем в кэш
await _cache.SetAsync(key, data, expiration);
return data;
}Write-Through
// Запись одновременно в кэш и источник
public async Task SetAsync(string key, T value)
{
// 1. Записываем в источник
await _source.SetAsync(key, value);
// 2. Обновляем кэш
await _cache.SetAsync(key, value, expiration);
}
// Pros: кэш всегда консистентен
// Cons: медленнее записьWrite-Behind (Write-Back)
// Запись сначала в кэш, потом асинхронно в источник
public class WriteBehindCache<T>
{
private readonly IDistributedCache _cache;
private readonly Channel<CacheOperation> _writeChannel;
public WriteBehindCache(IDistributedCache cache)
{
_cache = cache;
_writeChannel = Channel.CreateBounded<CacheOperation>(1000);
_ = ProcessWritesAsync();
}
public async Task SetAsync(string key, T value)
{
// Сразу обновляем кэш
await _cache.SetAsync(key, value);
// Асинхронно записываем в источник
await _writeChannel.Writer.WriteAsync(new CacheOperation(key, value));
}
private async Task ProcessWritesAsync()
{
await foreach (var op in _writeChannel.Reader.ReadAllAsync())
{
await _source.SetAsync(op.Key, op.Value);
}
}
}
// Pros: быстрая запись
// Cons: возможна потеря данных при crashMessage Queue Patterns
Pub/Sub
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Publisher │────▶│ Exchange │────▶│ Consumer 1 │
└─────────────┘ │ (Topic) │ └─────────────┘
│ │ ┌─────────────┐
│ │────▶│ Consumer 2 │
└──────────────┘ └─────────────┘// Pub/Sub с MassTransit
builder.Services.AddMassTransit(x =>
{
x.UsingRabbitMq((context, cfg) =>
{
cfg.Host("rabbitmq", "/", h => { });
cfg.ReceiveEndpoint("order-created-queue", e =>
{
e.Subscribe<OrderCreatedEvent>();
e.Handler<OrderCreatedEvent>(context =>
context.Consumer<IOrderCreatedConsumer>());
});
});
});
// Publisher
public class OrderService
{
private readonly IPublishEndpoint _publishEndpoint;
public async Task CreateOrderAsync(CreateOrderCommand command)
{
var order = new Order(command.CustomerId);
await _repository.SaveAsync(order);
await _publishEndpoint.Publish(new OrderCreatedEvent(order.Id, order.CustomerId));
}
}
// Consumer
public class OrderCreatedConsumer : IConsumer<OrderCreatedEvent>
{
public async Task Consume(ConsumeContext<OrderCreatedEvent> context)
{
var @event = context.Message;
// Handle event
}
}Work Queues
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Producer │────▶│ Queue │────▶│ Worker 1 │
└─────────────┘ │ │ └─────────────┘
│ │ ┌─────────────┐
│ │────▶│ Worker 2 │
└──────────────┘ └─────────────┘// Work queue — один consumer получает каждое сообщение
public class EmailWorker : BackgroundService
{
private readonly IModel _channel;
private readonly IServiceProvider _serviceProvider;
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
_channel.BasicQos(prefetchSize: 0, prefetchCount: 10, global: false);
var consumer = new EventingBasicConsumer(_channel);
consumer.Received += async (model, ea) =>
{
var body = ea.Body.ToArray();
var message = JsonSerializer.Deserialize<EmailMessage>(body);
try
{
using var scope = _serviceProvider.CreateScope();
var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
await emailService.SendAsync(message.To, message.Subject, message.Body);
_channel.BasicAck(ea.DeliveryTag, multiple: false);
}
catch
{
_channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: true);
}
};
_channel.BasicConsume("email-queue", autoAck: false, consumer: consumer);
return Task.CompletedTask;
}
}Dead Letter Queue
// DLQ — сообщения, которые не удалось обработать
public class DlqHandler
{
private readonly IModel _channel;
public void SetupDlq()
{
// Основная очередь с DLX
var args = new Dictionary<string, object>
{
{ "x-dead-letter-exchange", "dlx" },
{ "x-dead-letter-routing-key", "dlq" },
{ "x-max-length", 10000 },
{ "x-message-ttl", 60000 } // 60 seconds
};
_channel.QueueDeclare("main-queue", durable: true, exclusive: false, autoDelete: false, arguments: args);
// DLQ
_channel.ExchangeDeclare("dlx", ExchangeType.Direct);
_channel.QueueDeclare("dlq", durable: true, exclusive: false, autoDelete: false);
_channel.QueueBind("dlq", "dlx", "dlq");
}
// Retry из DLQ
public async Task ProcessDlqAsync()
{
var result = _channel.BasicGet("dlq", autoAck: false);
if (result == null) return;
var message = JsonSerializer.Deserialize<FailedMessage>(result.Body.ToArray());
if (message.RetryCount < 3)
{
message.RetryCount++;
message.NextRetryAt = DateTime.UtcNow.AddMinutes(Math.Pow(2, message.RetryCount));
// Re-publish с delay
var properties = _channel.CreateBasicProperties();
properties.Headers = new Dictionary<string, object>
{
{ "x-delay", (int)(message.NextRetryAt - DateTime.UtcNow).TotalMilliseconds }
};
_channel.BasicPublish("", "main-queue", properties,
JsonSerializer.SerializeToUtf8Bytes(message));
}
else
{
// Max retries reached — log and discard
_channel.BasicAck(result.DeliveryTag, multiple: false);
}
}
}Database Scaling
Read Replicas
┌─────────────┐ ┌──────────────┐
│ Writes │────▶│ Primary │
│ │ │ (Master) │
└─────────────┘ └──────┬───────┘
│ Replication
┌──────┴───────┐
│ │
┌─────▼─────┐ ┌────▼─────┐
│ Replica 1 │ │ Replica 2│
│ (Reads) │ │ (Reads) │
└────────────┘ └──────────┘// Read/Write splitting в EF Core
public class ReadWriteDbContext : DbContext
{
private readonly IHttpContextAccessor _httpContext;
public ReadWriteDbContext(DbContextOptions<ReadWriteDbContext> options, IHttpContextAccessor httpContext)
: base(options)
{
_httpContext = httpContext;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
// GET запросы → replica, POST/PUT/DELETE → primary
var method = _httpContext.HttpContext?.Request.Method;
var connectionString = IsReadOperation(method)
? GetReadReplicaConnectionString()
: GetPrimaryConnectionString();
optionsBuilder.UseSqlServer(connectionString);
}
private bool IsReadOperation(string method) =>
method == HttpMethods.Get || method == HttpMethods.Head;
}Sharding
┌─────────────────────────────────────────────────────┐
│ Shard Router │
│ │
│ Tenant A ──▶ Shard 1 (DB-A) │
│ Tenant B ──▶ Shard 1 (DB-A) │
│ Tenant C ──▶ Shard 2 (DB-B) │
│ Tenant D ──▶ Shard 2 (DB-B) │
└─────────────────────────────────────────────────────┘public class ShardingDbContext : DbContext
{
private readonly ITenantContext _tenantContext;
private readonly IShardResolver _shardResolver;
public ShardingDbContext(
DbContextOptions options,
ITenantContext tenantContext,
IShardResolver shardResolver)
: base(options)
{
_tenantContext = tenantContext;
_shardResolver = shardResolver;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
var shard = _shardResolver.ResolveShard(_tenantContext.Tenant.Id);
optionsBuilder.UseSqlServer(shard.ConnectionString);
}
}
public interface IShardResolver
{
ShardInfo ResolveShard(Guid tenantId);
}
public class ConsistentHashShardResolver : IShardResolver
{
private readonly List<ShardInfo> _shards;
public ShardInfo ResolveShard(Guid tenantId)
{
var hash = GetHash(tenantId);
var index = hash % _shards.Count;
return _shards[index];
}
private int GetHash(Guid tenantId)
{
var bytes = tenantId.ToByteArray();
return Math.Abs(BitConverter.ToInt32(bytes, 0));
}
}Partitioning
-- Range Partitioning (по дате)
CREATE TABLE Orders (
Id INT,
OrderDate DATE,
CustomerId INT,
Total DECIMAL
) PARTITION BY RANGE (YEAR(OrderDate)) (
PARTITION p2023 VALUES LESS THAN (2024),
PARTITION p2024 VALUES LESS THAN (2025),
PARTITION p2025 VALUES LESS THAN (2026)
);
-- Hash Partitioning (для равномерного распределения)
CREATE TABLE Users (
Id INT,
Name VARCHAR(100),
Email VARCHAR(100)
) PARTITION BY HASH (Id) PARTITIONS 4;
-- List Partitioning (по региону)
CREATE TABLE Stores (
Id INT,
Name VARCHAR(100),
Region VARCHAR(50)
) PARTITION BY LIST (Region) (
PARTITION p_east VALUES IN ('NY', 'MA', 'CT'),
PARTITION p_west VALUES IN ('CA', 'WA', 'OR'),
PARTITION p_central VALUES IN ('TX', 'IL', 'OH')
);Практика
Задание 1: System Design для 1M DAU
Спроектировать систему с capacity plan:
- RPS calculation с peak factor
- Instance sizing и count
- Database sizing (connections, storage, IOPS)
- Network bandwidth requirements
- Cost estimation
Задание 2: Multi-Level Caching Strategy
Реализовать:
- Browser cache с ETag/Last-Modified
- CDN cache с invalidation
- Application IMemoryCache
- Distributed Redis cache
- Cache-aside pattern
- Write-through для critical data
Задание 3: Load Balancer Simulation
Создать load balancer simulation с:
- Health checks (liveness + readiness)
- Circuit breaking для unhealthy instances
- Load balancing algorithms (round-robin, least-connections, weighted)
- Instance registration/deregistration
Observability Architecture
Three Pillars: Logs, Metrics, Traces
┌─────────────────────────────────────────────────────┐
│ Observability │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Logs │ │ Metrics │ │ Traces │ │
│ │ │ │ │ │ │ │
│ │ "What │ │ "How │ │ "Why" │ │
│ │ happened?" │ │ much?" │ │ │ │
│ │ │ │ │ │ │ │
│ │ Structured │ │ Numeric, │ │ Request │ │
│ │ text with │ │ aggregated, │ │ flow across │ │
│ │ context │ │ time-series │ │ services │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────┘| Pillar | Что отвечает | Пример | Инструменты |
|---|---|---|---|
| Logs | Что произошло? | "Order 123 failed: timeout" | Serilog, Seq, ELK |
| Metrics | Сколько/как часто? | "P99 latency = 250ms" | Prometheus, Grafana |
| Traces | Почему/где? | "Request flow: API → Order → Payment" | Jaeger, Zipkin |
OpenTelemetry
Instrumentation
// NuGet: OpenTelemetry.Extensions.Hosting
// NuGet: OpenTelemetry.Instrumentation.AspNetCore
// NuGet: OpenTelemetry.Instrumentation.Http
// NuGet: OpenTelemetry.Instrumentation.EntityFrameworkCore
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddSource("MyApp")
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddJaegerExporter()) // или OTLP
.WithMetrics(metrics => metrics
.AddMeter("MyApp")
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddProcessInstrumentation()
.AddPrometheusExporter());Custom Instrumentation
public class OrderService
{
private static readonly ActivitySource ActivitySource = new("MyApp", "1.0.0");
private static readonly Meter Meter = new("MyApp", "1.0.0");
private static readonly Counter<long> OrdersCreatedCounter =
Meter.CreateCounter<long>("orders.created", description: "Number of orders created");
private static readonly Histogram<double> OrderProcessingDuration =
Meter.CreateHistogram<double>("orders.processing.duration.ms",
description: "Order processing duration");
public async Task<Order> CreateOrderAsync(CreateOrderCommand command, CancellationToken ct = default)
{
using var activity = ActivitySource.StartActivity("CreateOrder");
activity?.SetTag("customer.id", command.CustomerId);
activity?.SetTag("order.items.count", command.Items.Count);
var stopwatch = Stopwatch.StartNew();
try
{
var order = new Order(command.CustomerId);
foreach (var item in command.Items)
{
order.AddItem(item.ProductId, item.Quantity, item.Price);
activity?.AddEvent(new ActivityEvent("ItemAdded", tags: new ActivityTagsCollection
{
{ "product.id", item.ProductId },
{ "quantity", item.Quantity }
}));
}
await _repository.SaveAsync(order, ct);
OrdersCreatedCounter.Add(1);
activity?.SetStatus(ActivityStatusCode.Ok);
return order;
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.RecordException(ex);
throw;
}
finally
{
stopwatch.Stop();
OrderProcessingDuration.Record(stopwatch.ElapsedMilliseconds);
}
}
}Baggage — передача контекста
// Baggage — метаданные, которые propagate через все сервисы
public class OrderController : ControllerBase
{
[HttpPost("orders")]
public async Task<ActionResult> CreateOrder(CreateOrderCommand command)
{
// Устанавливаем baggage
Baggage.SetItem("user.id", User.GetUserId());
Baggage.SetItem("user.tier", User.GetTier());
Baggage.SetItem("request.source", "web");
var order = await _orderService.CreateOrderAsync(command);
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}
}
// В другом сервисе — читаем baggage
public class PaymentService
{
public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request)
{
var userId = Baggage.GetItem("user.id");
var userTier = Baggage.GetItem("user.tier");
// Premium users get priority processing
if (userTier == "premium")
{
return await ProcessPriorityPaymentAsync(request);
}
return await ProcessStandardPaymentAsync(request);
}
}Distributed Tracing
W3C Trace Context
Trace ID: 4bf92f3577b34da6a3ce929d0e0e4736
Span ID: 00f067aa0ba902b7
Parent: 0000000000000000
HTTP Headers:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
tracestate: vendor1=value1,vendor2=value2Request → API Gateway → Order Service → Payment Service → Email Service
│ │ │ │ │
│───traceparent header propagates automatically ──────────│
│ │
└─── Full Trace: │
TraceId: abc123... │
├── Span 1: API Gateway (0-50ms) │
│ └── Span 2: Order Service (10-200ms) │
│ ├── Span 3: DB Query (20-45ms) │
│ └── Span 4: Payment Service (50-180ms) │
│ ├── Span 5: Stripe API (60-150ms) │
│ └── Span 6: Email Service (160-175ms) │
└─── Total: 200ms │Propagation в .NET
// .NET автоматически propagates trace context через HttpClient
// Но нужно настроить для gRPC и message brokers
// gRPC propagation
builder.Services.AddGrpcClient<OrderService.OrderServiceClient>(options =>
{
options.Address = new Uri("https://order-service:5001");
})
.AddInterceptor<GrpcTraceInterceptor>();
public class GrpcTraceInterceptor : Interceptor
{
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(
TRequest request,
ClientInterceptorContext<TRequest, TResponse> context,
AsyncUnaryCallContinuation<TRequest, TResponse> continuation)
{
var metadata = context.Options.Headers ?? new Metadata();
// Inject current trace context
if (Activity.Current != null)
{
metadata.Add("traceparent",
$"00-{Activity.Current.TraceId}-{Activity.Current.SpanId}-01");
}
var newOptions = context.Options.WithHeaders(metadata);
var newContext = new ClientInterceptorContext<TRequest, TResponse>(
context.Method, context.Host, newOptions);
return continuation(request, newContext);
}
}
// RabbitMQ propagation
public class RabbitMqTracePublisher : IEventPublisher
{
private readonly IModel _channel;
public async Task PublishAsync<T>(T @event, string routingKey)
{
var properties = _channel.CreateBasicProperties();
if (Activity.Current != null)
{
properties.Headers = new Dictionary<string, object>
{
{ "traceparent", $"00-{Activity.Current.TraceId}-{Activity.Current.SpanId}-01" }
};
}
var body = JsonSerializer.SerializeToUtf8Bytes(@event);
_channel.BasicPublish("order-events", routingKey, properties, body);
}
}
// RabbitMQ consumer — извлекает trace context
public class TraceAwareConsumer : BackgroundService
{
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
var consumer = new AsyncEventingBasicConsumer(_channel);
consumer.Received += async (model, ea) =>
{
// Extract trace context
if (ea.BasicProperties.Headers?.TryGetValue("traceparent", out var traceparent) == true)
{
var traceContext = traceparent.ToString();
// Parse and create Activity from traceparent
var activity = new Activity("ProcessMessage");
activity.SetParentId(traceContext);
activity.Start();
}
try
{
await ProcessMessageAsync(ea);
_channel.BasicAck(ea.DeliveryTag, false);
}
catch
{
_channel.BasicNack(ea.DeliveryTag, false, true);
}
finally
{
Activity.Current?.Stop();
}
};
_channel.BasicConsume("queue", false, consumer);
return Task.CompletedTask;
}
}Structured Logging — Serilog
Настройка
// NuGet: Serilog.AspNetCore
// NuGet: Serilog.Sinks.Console
// NuGet: Serilog.Sinks.File
// NuGet: Serilog.Sinks.Seq
// NuGet: Serilog.Enrichers.Environment
// NuGet: Serilog.Enrichers.Thread
// NuGet: Serilog.Enrichers.Span
builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.Enrich.WithEnvironmentName()
.Enrich.WithMachineName()
.Enrich.WithThreadId()
.Enrich.WithSpan() // OpenTelemetry correlation
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}")
.WriteTo.File(
path: "logs/app-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30)
.WriteTo.Seq("http://seq:5341"));Structured Logging
public class OrderService
{
private readonly ILogger<OrderService> _logger;
public async Task<Order> CreateOrderAsync(CreateOrderCommand command)
{
// Structured logging — свойства, не строки
_logger.LogInformation(
"Creating order for customer {CustomerId} with {ItemCount} items",
command.CustomerId, command.Items.Count);
try
{
var order = new Order(command.CustomerId);
foreach (var item in command.Items)
{
order.AddItem(item.ProductId, item.Quantity, item.Price);
_logger.LogDebug(
"Added item {ProductId} x{Quantity} @ {Price} to order {OrderId}",
item.ProductId, item.Quantity, item.Price, order.Id);
}
await _repository.SaveAsync(order);
_logger.LogInformation(
"Order {OrderId} created successfully with total {Total}",
order.Id, order.Total);
return order;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to create order for customer {CustomerId}",
command.CustomerId);
throw;
}
}
}Log Enrichers
// Кастомный enricher
public class TenantEnricher : ILogEventEnricher
{
private readonly ITenantContext _tenantContext;
public TenantEnricher(ITenantContext tenantContext) => _tenantContext = tenantContext;
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
if (_tenantContext.Tenant != null)
{
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(
"TenantId", _tenantContext.Tenant.Id));
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(
"TenantName", _tenantContext.Tenant.Name));
}
}
}
// Request enricher
public class RequestEnricher : ILogEventEnricher
{
private readonly IHttpContextAccessor _httpContext;
public RequestEnricher(IHttpContextAccessor httpContext) => _httpContext = httpContext;
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
var context = _httpContext.HttpContext;
if (context == null) return;
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(
"RequestId", context.TraceIdentifier));
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(
"RequestMethod", context.Request.Method));
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(
"RequestPath", context.Request.Path));
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(
"UserAgent", context.Request.Headers["User-Agent"].ToString()));
}
}
// Регистрация
configuration.Enrich.With(new TenantEnricher(tenantContext));
configuration.Enrich.With(new RequestEnricher(httpContext));Health Checks
Liveness vs Readiness
Liveness Probe: "Is the process alive?"
- Если NO → restart container
- Проверяет: процесс не crashed, нет deadlock
Readiness Probe: "Is the service ready to accept traffic?"
- Если NO → remove from load balancer
- Проверяет: БД доступна, кэш подключён, зависимости healthyImplementation
builder.Services.AddHealthChecks()
// Liveness — только базовая проверка
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: new[] { "liveness" })
// Readiness — все зависимости
.AddSqlServer(builder.Configuration.GetConnectionString("Default"),
name: "database", tags: new[] { "readiness" })
.AddRedis(builder.Configuration.GetConnectionString("Redis"),
name: "redis", tags: new[] { "readiness" })
.AddUrlGroup(new Uri("https://payment-service/health"),
name: "payment-service", tags: new[] { "readiness" })
.AddDbContextCheck<AppDbContext>(
name: "dbcontext", tags: new[] { "readiness" });
var app = builder.Build();
// Liveness endpoint — lightweight
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("liveness"),
ResponseWriter = WriteHealthResponse
});
// Readiness endpoint — full check
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("readiness"),
ResponseWriter = WriteHealthResponse
});
// Full health — для admin dashboard
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = WriteHealthResponse
});
static Task WriteHealthResponse(HttpContext context, HealthReport report)
{
context.Response.ContentType = "application/json";
var json = JsonSerializer.Serialize(new
{
status = report.Status.ToString(),
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
duration = e.Value.Duration.TotalMilliseconds,
description = e.Value.Description,
data = e.Value.Data
}),
totalDuration = report.TotalDuration.TotalMilliseconds
});
return context.Response.WriteAsync(json);
}Custom Health Check
public class OrderQueueHealthCheck : IHealthCheck
{
private readonly IOrderQueue _queue;
private readonly ILogger<OrderQueueHealthCheck> _logger;
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken ct = default)
{
try
{
var queueDepth = await _queue.GetDepthAsync(ct);
if (queueDepth > 10000)
{
return HealthCheckResult.Degraded(
$"Order queue depth is {queueDepth} (threshold: 10000)");
}
if (queueDepth > 50000)
{
return HealthCheckResult.Unhealthy(
$"Order queue depth is {queueDepth} (critical: 50000)");
}
return HealthCheckResult.Healthy($"Queue depth: {queueDepth}");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to check order queue health");
return HealthCheckResult.Unhealthy("Cannot connect to order queue", ex);
}
}
}Metrics — Prometheus + Grafana
Custom Metrics
public class OrderMetrics
{
private static readonly Meter Meter = new("MyApp.Orders", "1.0.0");
private static readonly Counter<long> _ordersCreated =
Meter.CreateCounter<long>("orders.created.total", "orders", "Total orders created");
private static readonly Counter<long> _ordersConfirmed =
Meter.CreateCounter<long>("orders.confirmed.total", "orders", "Total orders confirmed");
private static readonly Counter<long> _ordersCancelled =
Meter.CreateCounter<long>("orders.cancelled.total", "orders", "Total orders cancelled");
private static readonly Histogram<double> _orderValue =
Meter.CreateHistogram<double>("orders.value", "USD", "Order total value");
private static readonly Histogram<double> _orderProcessingTime =
Meter.CreateHistogram<double>("orders.processing.time.ms", "ms", "Order processing time");
public static void RecordOrderCreated(decimal total)
{
_ordersCreated.Add(1);
_orderValue.Record((double)total);
}
public static void RecordOrderConfirmed() => _ordersConfirmed.Add(1);
public static void RecordOrderCancelled() => _ordersCancelled.Add(1);
public static void RecordProcessingTime(double milliseconds) =>
_orderProcessingTime.Record(milliseconds);
}Prometheus Exporter
// NuGet: OpenTelemetry.Exporter.Prometheus.AspNetCore
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics
.AddMeter("MyApp.Orders")
.AddAspNetCoreInstrumentation()
.AddRuntimeInstrumentation()
.AddPrometheusExporter());
var app = builder.Build();
// Prometheus scraping endpoint
app.MapPrometheusScrapingEndpoint();
// Prometheus config
/*
scrape_configs:
- job_name: 'myapp'
scrape_interval: 15s
static_configs:
- targets: ['myapp:5000']
*/Grafana Dashboard
{
"dashboard": {
"title": "Order Service Dashboard",
"panels": [
{
"title": "Orders per Second",
"type": "graph",
"targets": [{
"expr": "rate(orders_created_total[1m])"
}]
},
{
"title": "P99 Processing Time",
"type": "graph",
"targets": [{
"expr": "histogram_quantile(0.99, rate(orders_processing_time_ms_bucket[5m]))"
}]
},
{
"title": "Order Value Distribution",
"type": "heatmap",
"targets": [{
"expr": "rate(orders_value_bucket[5m])"
}]
},
{
"title": "Error Rate",
"type": "singlestat",
"targets": [{
"expr": "rate(http_server_requests_seconds_count{status=~\"5..\"}[5m]) / rate(http_server_requests_seconds_count[5m]) * 100"
}]
}
]
}
}Практика
Задание 1: End-to-End Distributed Tracing
Настроить distributed tracing для microservice system:
- OpenTelemetry instrumentation для всех сервисов
- Trace context propagation через HTTP, gRPC, message brokers
- Jaeger/Zipkin UI для визуализации
- Correlation ID для логов
Задание 2: Custom OpenTelemetry Instrumentation
Реализовать custom instrumentation:
- ActivitySource для business operations
- Meter с Counter, Histogram, Gauge
- Baggage для user context propagation
- Custom span events и attributes
Задание 3: Observability Dashboard
Создать observability dashboard с key business metrics:
- Prometheus metrics export
- Grafana dashboard panels
- Alert rules (error rate > 1%, latency > 500ms)
- Business metrics (orders/hour, revenue, conversion rate)
Контрольная точка модуля 4
- Clean Architecture + DDD для domain layer
- Multi-tenancy с tenant isolation
- CQRS + Event Sourcing для core aggregate
- Resilient external integrations через Polly
- Full observability (logs, metrics, traces)
- API Gateway с authentication и rate limiting
- Architecture decision records (ADR) для каждого major решения
- Domain model проходит review на DDD correctness
- System handles graceful degradation при failures
- Distributed trace показывает full request flow
- Load test подтверждает architecture scalability claims