Введение: когда SOLID перестаёт быть теорией
Большинство разработчиков знакомы с аббревиатурой SOLID на уровне "класс должен отвечать за одну вещь". Однако в реальных проектах на C# (.NET) с Dependency Injection, микросервисами и асинхронными паттернами наивное следование принципам приводит к обратному эффекту — переусложнению, раздуванию интерфейсов и нарушению инкапсуляции.
В этой статье мы рассмотрим SOLID не как набор догм, а как инструмент для принятия архитектурных решений. Вы узнаете, как правильно применять принципы в продвинутых сценариях: работа с IoC-контейнерами, композиция вместо наследования, обработка ошибок через интерфейсы и тестирование саг.
1. Single Responsibility: границы ответственности в DDD
Принцип единственной ответственности (SRP) часто путают с "один класс — одна функция". На самом деле речь идёт о причине изменения (reason to change). В контексте C# и Domain-Driven Design это означает, что класс должен изменяться только по одной причине, связанной с одним актором или бизнес-правилом.
Продвинутый антипаттерн: "Божественный Entity"
Рассмотрим типичный пример нарушения SRP в .NET — класс OrderEntity, который одновременно управляет состоянием заказа, рассчитывает налоги, отправляет email и сохраняет данные в БД через Entity Framework.
// ❌ Нарушение SRP
public class OrderEntity
{
public int Id { get; set; }
public decimal Total { get; set; }
public string Email { get; set; }
public void CalculateTax() { /* логика налогов */ }
public void SendConfirmation() { /* отправка email */ }
public void SaveToDatabase() { /* EF Core SaveChanges */ }
}
Проблема: изменение бизнес-правила по налогам (например, новый закон) приведёт к изменению класса, который отвечает ещё и за отправку уведомлений. Это повышает риск регрессии.
Решение: выделение сервисов через DI
// ✅ SRP через сервисы
public class Order
{
public int Id { get; set; }
public decimal Total { get; set; }
// Только состояние, никакой логики
}
public class TaxCalculator : ITaxCalculator
{
public decimal Calculate(Order order) { /* ... */ }
}
public class EmailService : INotificationService
{
public Task SendConfirmation(Order order) { /* ... */ }
}
public class OrderRepository : IRepository<Order>
{
public Task SaveAsync(Order order) { /* EF Core */ }
}
Теперь каждый класс имеет ровно одну причину для изменения. Контейнер DI (Microsoft.Extensions.DependencyInjection) связывает их через конструкторы.
2. Open/Closed: расширение через стратегию и декоратор
Принцип открытости/закрытости (OCP) гласит: класс должен быть открыт для расширения, но закрыт для модификации. В .NET это достигается не только через наследование, но и через композицию с паттернами Strategy и Decorator.
Продвинутый кейс: валидация с ростом правил
Представьте, что у вас есть система валидации заказов. Начальная версия проверяла только сумму и адрес. Через месяц добавилось правило "нельзя заказывать товары на сумму больше лимита". Вместо изменения класса OrderValidator, используем композицию.
// ❌ Нарушение OCP — модификация существующего класса
public class OrderValidator
{
public bool Validate(Order order)
{
if (order.Total < 0) return false;
if (string.IsNullOrEmpty(order.Address)) return false;
// Новое правило — модифицируем класс
if (order.Total > 10000) return false;
return true;
}
}
// ✅ OCP через композицию валидаторов
public interface IValidator<T>
{
bool Validate(T entity);
}
public class TotalValidator : IValidator<Order>
{
public bool Validate(Order order) => order.Total > 0;
}
public class AddressValidator : IValidator<Order>
{
public bool Validate(Order order) => !string.IsNullOrEmpty(order.Address);
}
public class CompositeValidator<T> : IValidator<T>
{
private readonly IEnumerable<IValidator<T>> _validators;
public CompositeValidator(IEnumerable<IValidator<T>> validators)
{
_validators = validators;
}
public bool Validate(T entity) => _validators.All(v => v.Validate(entity));
}
Теперь для добавления нового правила (например, проверка лимита) достаточно создать новый класс LimitValidator и зарегистрировать его в DI. Исходный код CompositeValidator не меняется.
3. Liskov Substitution: контракты и ковариантность в generics
Принцип подстановки Лисков (LSP) — один из самых сложных для понимания. На продвинутом уровне речь идёт о контрактном программировании: подкласс не должен нарушать предусловия, постусловия и инварианты базового класса.
Типичная ошибка: нарушение через ковариантность
В C# есть поддержка ковариантности для интерфейсов (ключевое слово out). Однако её неправильное использование может нарушить LSP.
// ❌ Нарушение LSP через ковариантность
public interface IRepository<out T> where T : Entity
{
T GetById(int id);
}
public class OrderRepository : IRepository<Order>
{
public Order GetById(int id) => new Order();
}
// Клиентский код ожидает Entity, но получает Order — корректно
IRepository<Entity> repo = new OrderRepository();
var entity = repo.GetById(1); // Работает
Проблема возникает, если IRepository содержит метод void Save(T item) — ковариантность запрещает параметры. Но если вы используете ковариантность только для чтения, LSP не нарушается. Однако если подкласс возвращает null там, где базовый класс гарантирует не-null, это нарушение.
Правильный подход: контракты через Code Contracts или FluentValidation
// ✅ LSP через контракты
public abstract class RepositoryBase<T> where T : Entity
{
public abstract T GetById(int id);
// Постусловие: результат не null, если id > 0
}
public class OrderRepository : RepositoryBase<Order>
{
public override Order GetById(int id)
{
if (id <= 0) throw new ArgumentException("Id must