.NET Tutorial
Junior8 мин чтенияВопрос 1 из 17

SOLID: расшифровка, реальные примеры нарушений на .NET?

Обзор

S — Single Responsibility
одна причина для изменения

O — Open/Closed
расширяй, не меняй

L — Liskov Substitution
наследник заменяет базу

I — Interface Segregation
узкие интерфейсы

D — Dependency Inversion
зависимость от абстракций

S — Single Responsibility
одна причина для изменения

O — Open/Closed
расширяй, не меняй

L — Liskov Substitution
наследник заменяет базу

I — Interface Segregation
узкие интерфейсы

D — Dependency Inversion
зависимость от абстракций

SOLID — пять принципов

S — Single Responsibility

Нарушение: класс меняется по многим причинам
public class OrderService
{
    public void PlaceOrder(Order order)
    {
        if (order.Items.Count == 0) throw new Exception("Пустой заказ");        // валидация
        var total = order.Items.Sum(i => i.Price * i.Qty) * (1 - GetDiscount()); // ценообразование
        using var conn = new SqlConnection("...");                             // доступ к данным
        conn.Execute("INSERT INTO orders ...", order);
        var pdf = GenerateInvoicePdf(order, total);                            // генерация документов
        new SmtpClient("smtp.example.uz").Send("shop@x.uz", order.Email, "Счёт", pdf); // рассылка
        File.AppendAllText("log.txt", $"Order {order.Id}");                    // логирование
    }
}

Изменения скидок (маркетинг), формата счёта (бухгалтерия), почтового провайдера (DevOps), схемы БД — всё ведёт в этот класс. Любое изменение рискует сломать остальное.

Исправление: каждая ответственность — у своего компонента
public sealed class PlaceOrderHandler(
    IValidator<PlaceOrder> validator,
    IPricingService pricing,
    IOrderRepository orders,
    IInvoiceGenerator invoices,
    INotificationSender notifications)
{
    public async Task<Guid> Handle(PlaceOrder cmd, CancellationToken ct)
    {
        await validator.ValidateAndThrowAsync(cmd, ct);
        var order = Order.Create(cmd.CustomerId, cmd.Items, pricing.CalculateDiscount(cmd));
        await orders.AddAsync(order, ct);
        var invoice = invoices.Generate(order);
        await notifications.SendInvoiceAsync(order.CustomerEmail, invoice, ct);
        return order.Id;
    }
}

O — Open/Closed

Нарушение: каждый новый способ оплаты — правка существующего кода
public decimal CalculateFee(Payment p) => p.Method switch
{
    "card"   => p.Amount * 0.02m,
    "wallet" => p.Amount * 0.01m,
    "cash"   => 0,
    // завтра "installments", послезавтра "crypto" — снова открываем этот файл
    _ => throw new NotSupportedException()
};
Исправление: расширение через новые классы
public interface IPaymentFeePolicy
{
    string Method { get; }
    decimal Calculate(decimal amount);
}

public sealed class CardFeePolicy : IPaymentFeePolicy { public string Method => "card"; public decimal Calculate(decimal a) => a * 0.02m; }
public sealed class WalletFeePolicy : IPaymentFeePolicy { public string Method => "wallet"; public decimal Calculate(decimal a) => a * 0.01m; }

public sealed class FeeCalculator(IEnumerable<IPaymentFeePolicy> policies)
{
    private readonly Dictionary<string, IPaymentFeePolicy> _byMethod = policies.ToDictionary(p => p.Method);
    public decimal Calculate(Payment p) => _byMethod.TryGetValue(p.Method, out var policy)
        ? policy.Calculate(p.Amount)
        : throw new NotSupportedException(p.Method);
}

// Новый способ оплаты = новый класс + регистрация, существующий код не трогаем
builder.Services.AddSingleton<IPaymentFeePolicy, InstallmentsFeePolicy>();

В .NET OCP встречается повсюду: middleware, фильтры, IHostedService, обработчики MediatR, IEnumerable<IValidator> — всё это точки расширения.

L — Liskov Substitution

Клиентский код:
void Resize(Rectangle r)
r.Width = 5, r.Height = 10
ожидает площадь 50

Rectangle — OK: 50

Square : Rectangle
сеттер Width меняет и Height
площадь 100

Клиентский код:
void Resize(Rectangle r)
r.Width = 5, r.Height = 10
ожидает площадь 50

Rectangle — OK: 50

Square : Rectangle
сеттер Width меняет и Height
площадь 100

Нарушение LSP: подтип ломает ожидания клиента

Примеры нарушений из .NET и практики:

C#
// 1. ReadOnlyCollection<T> реализует ICollection<T>, но Add бросает NotSupportedException.
ICollection<int> items = new ReadOnlyCollection<int>(new List<int>());
items.Add(1);   // NotSupportedException — клиент, получивший ICollection, этого не ожидает

// 2. Массив ковариантен — и может упасть при записи
object[] arr = new string[1];
arr[0] = 42;    // ArrayTypeMismatchException

// 3. Наследник усиливает предусловия
public class FileStorage { public virtual Task SaveAsync(string path, Stream s) => ...; }
public class S3Storage : FileStorage
{
    public override Task SaveAsync(string path, Stream s)
    {
        if (s.Length > 5_000_000) throw new InvalidOperationException("Лимит 5 МБ");   // базовый контракт такого не обещал
        return ...;
    }
}

Правила LSP: наследник не усиливает предусловия, не ослабляет постусловия, сохраняет инварианты и не бросает новых неожиданных исключений. Если «является» не выполняется поведенчески — используйте композицию вместо наследования.

I — Interface Segregation

Нарушение: «толстый» интерфейс
public interface IRepository<T>
{
    Task<T?> GetAsync(Guid id);
    Task<IReadOnlyList<T>> SearchAsync(string q);
    Task AddAsync(T entity);
    Task UpdateAsync(T entity);
    Task DeleteAsync(Guid id);
    Task BulkImportAsync(Stream csv);
    Task<byte[]> ExportToExcelAsync();
}

// Сервису отчётов нужно только чтение, но он зависит от всего; тестовый фейк реализует 7 методов
public class AuditLogRepository : IRepository<AuditEntry>
{
    public Task DeleteAsync(Guid id) => throw new NotSupportedException();   // журнал аудита нельзя удалять — нарушение LSP как следствие
    // ...
}
Исправление: роли разделены
public interface IReadRepository<T> { Task<T?> GetAsync(Guid id); Task<IReadOnlyList<T>> SearchAsync(string q); }
public interface IWriteRepository<T> { Task AddAsync(T entity); Task UpdateAsync(T entity); }
public interface IDeletable { Task DeleteAsync(Guid id); }

public class AuditLogRepository : IReadRepository<AuditEntry>, IWriteRepository<AuditEntry> { /* без Delete */ }

В BCL хороший пример — разделение IEnumerable<T> / IReadOnlyCollection<T> / IReadOnlyList<T> / IList<T>: метод принимает ровно то, что ему нужно.

D — Dependency Inversion

До: высокий уровень зависит от деталей

OrderService

SmtpClient

SqlConnection

После: оба зависят от абстракции

реализует

OrderService

IEmailSender
(в слое приложения)

SmtpEmailSender
(инфраструктура)

До: высокий уровень зависит от деталей

OrderService

SmtpClient

SqlConnection

После: оба зависят от абстракции

реализует

OrderService

IEmailSender
(в слое приложения)

SmtpEmailSender
(инфраструктура)

Инверсия зависимостей
C#
// Нарушение
public class ReportService
{
    private readonly SqlConnection _conn = new("Server=prod;...");   // жёсткая зависимость от конкретной БД
    public DateTime Now => DateTime.Now;                              // скрытая зависимость от системных часов
}

// Исправление
public class ReportService(IReportRepository repository, TimeProvider clock)
{
    public async Task<Report> BuildDailyAsync(CancellationToken ct)
    {
        var today = DateOnly.FromDateTime(clock.GetLocalNow().DateTime);
        return new Report(today, await repository.GetSalesAsync(today, ct));
    }
}

Важно: абстракция принадлежит тому, кто её использует (слой приложения/домена), а не реализации. Это основа Clean/Onion Architecture.

Когда SOLID вредит

  • Интерфейс для каждого класса с единственной реализацией «на всякий случай» — шум. Интерфейс нужен, когда есть несколько реализаций, граница модуля или необходимость подмены в тестах.
  • Дробление до атомов: 40 классов по 5 строк, логику невозможно проследить.
  • Абстракции ради абстракций (generic repository поверх EF Core, который уже UoW + репозиторий).

Цель SOLID — низкая стоимость изменений. Если принцип делает код сложнее без выгоды — это сигнал остановиться.

Вопросы на засыпку

Как SRP соотносится с микросервисами?

Та же идея на другом уровне: сервис должен иметь одну причину для изменения — одну бизнес-возможность (bounded context). Сервис «Заказы», который меняется из-за требований склада, биллинга и маркетинга, нарушает SRP на уровне архитектуры.

Как проверить, не нарушает ли наследник LSP?

Прогнать тесты базового класса/контракта против наследника (contract tests). Если какие-то тесты приходится отключать или наследник бросает NotSupportedException — нарушение. Сигнал в коде: проверки if (x is Square) у клиентов.

Какой принцип нарушает switch по типу?

Обычно OCP (каждый новый тип — правка switch) и часто LSP (клиент знает о конкретных подтипах). Но для закрытых иерархий (discriminated unions) pattern matching по типам — нормальная практика.

Зачем TimeProvider, если есть DateTime.Now?

DateTime.Now — скрытая зависимость от системных часов: код нельзя детерминированно протестировать («завтра истекает подписка»). TimeProvider (.NET 8) — абстракция времени, в тестах подменяется FakeTimeProvider. Это DIP для времени.

Итог

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