SOLID и принципы
Пять принципов SOLID и три эвристики DRY, KISS и YAGNI: что они на самом деле утверждают, как выглядят нарушения на C# и в самом .NET и где принципы перестают помогать. Перечислить буквы умеет каждый, а на уровне Senior спрашивают цену каждого принципа и его границы.
Перед этой темой: ООП.
Что означают принцип единственной ответственности (SRP) и принцип открытости/закрытости (OCP)?
Заголовок раздела «Что означают принцип единственной ответственности (SRP) и принцип открытости/закрытости (OCP)?»Что это
- SRP, Single Responsibility Principle. У класса одна причина для изменения: им управляет один источник требований.
- OCP, Open/Closed Principle. Класс открыт для расширения и закрыт для изменения: новое поведение добавляют, не переписывая готовый код.
Зачем
Правка одной функции не ломает соседнюю, а проверенный код не трогают ради новой фичи.
Как пользоваться
- Найдите в классе части, которые меняются по разным причинам: расчёт, формат, сохранение. Вынесите их в отдельные классы.
- Растущий
switchпо типу или формату замените интерфейсом и отдельной реализацией на каждый вариант.
Частая ошибка
Понимать SRP как «класс делает одно действие» и дробить код на классы с одним методом. Принцип про причины изменений, а не про число методов.
Как устроено
- SRP по Мартину. В «Clean Architecture» модуль отвечает перед одним актором — группой людей, которые просят изменений. Пример Мартина:
Employeeс методамиCalculatePay(бухгалтерия),ReportHours(операционный отдел) иSave(технический отдел). Если первые два делят вспомогательный метод, правка для бухгалтерии тихо меняет отчёт. - OCP. Формулировку дал Бертран Мейер в 1988 году. Мейер расширял модуль наследованием реализации, Мартин — через абстракции: клиент зависит от интерфейса, а новое поведение — новая реализация.
- Стратегия. Типичный способ соблюсти OCP: алгоритм, который меняется, выносится в интерфейс, и класс получает нужную реализацию снаружи.
Ловушки
- Один и тот же
switchпо типу в нескольких местах — главный признак нарушения OCP: новый вариант требует правок во всех. - SRP не означает «один метод». Класс с десятью методами вокруг одного правила нормален.
- Исправление ошибки OCP не нарушает: принцип про добавление поведения.
Правило
Разделяйте код по тем, кто просит изменений. Точку расширения вводите, когда изменение по этой оси уже случилось, а не «на всякий случай».
Под капотом
- Мартин пересказывает SRP так: собирайте вместе то, что меняется по одним причинам, и разделяйте то, что меняется по разным. Это связность, сформулированная через людей и организацию.
- «Функция должна делать одно дело» — отдельный принцип для функций. Мартин прямо оговаривает, что это не SRP.
- Закрытость не бывает полной: модуль закрыт только от предвиденных изменений. Мартин называет это стратегической закрытостью — автор выбирает, от чего защищаться.
Цена
- Каждая точка расширения — интерфейс, лишние типы, косвенный вызов и лишний переход при чтении кода.
- Ось, угаданная неверно, хуже, чем никакая. Изменение приходит поперёк абстракции и правит её вместе со всеми реализациями.
Компромиссы
- Первое изменение вносят прямо в код, на втором похожем выделяют абстракцию: ось уже видна по истории.
- На уровне архитектуры OCP — это плагины. Зависимости направлены к ядру, а новый модуль подключается без правки ядра.
Как проверить в проде
git logпо файлу: класс, который правят в каждом втором PR по несвязанным задачам, и конфликты слияния разных команд в одном файле указывают на нарушение SRP.switchпо одному и тому же признаку в нескольких местах — кандидат на полиморфизм.
Итог: SRP и OCP защищают от каскада изменений, а ось для них выбирают по реальной истории изменений, а не угадывают.
var report = new SalesReport();Console.WriteLine(report.Export([120, 80], "csv")); // total;200
public class SalesReport{ public string Export(int[] sales, string format) { int total = sales.Sum(); // правило расчёта меняет бухгалтерия return format switch // формат меняют потребители отчёта { "csv" => $"total;{total}", "json" => $$"""{"total":{{total}}}""", _ => throw new NotSupportedException(format) }; // новый формат — правка этого метода }}var report = new SalesReport(new SalesCalculator());Console.WriteLine(report.Export([120, 80], new CsvFormatter())); // total;200Console.WriteLine(report.Export([120, 80], new JsonFormatter())); // {"total":200}
// Одна причина для изменения: правила расчётаpublic class SalesCalculator{ public int Total(int[] sales) => sales.Sum();}
public interface IReportFormatter { string Format(int total); }
// Новый формат — новый класс, SalesReport не меняетсяpublic class CsvFormatter : IReportFormatter{ public string Format(int total) => $"total;{total}";}
public class JsonFormatter : IReportFormatter{ public string Format(int total) => $$"""{"total":{{total}}}""";}
public class SalesReport(SalesCalculator calculator){ public string Export(int[] sales, IReportFormatter formatter) => formatter.Format(calculator.Total(sales));}- Как понять, что у класса несколько ответственностей? — Его меняют по просьбам разных людей или отделов; в нём конфликтуют правки разных команд.
- Нарушает ли OCP исправление ошибки? — Нет, OCP про добавление поведения; ошибку исправляют на месте.
- Чем OCP Мейера отличается от OCP Мартина? — Мейер расширял наследованием реализации, Мартин — новыми реализациями абстракции.
- Как связаны OCP и стратегия? — Стратегия закрывает класс от изменений алгоритма: новый алгоритм — новая реализация.
- Применим ли SRP к сервисам? — Да, граница сервиса тоже должна совпадать с одним источником изменений.
Что такое DRY, KISS и YAGNI и как они соотносятся?
Заголовок раздела «Что такое DRY, KISS и YAGNI и как они соотносятся?»Что это
| Принцип | Смысл |
|---|---|
| DRY, Don’t Repeat Yourself | у каждого знания одно место в системе |
| KISS, Keep It Simple, Stupid | самое простое решение, которое работает |
| YAGNI, You Aren’t Gonna Need It | не писать то, что пока не нужно |
Как пользоваться
- DRY. Ставка налога или правило валидации — в одном месте, иначе одну из копий забудут обновить.
- KISS. Обычный
ifвместо паттерна, пока простое решение справляется. - YAGNI. «Вдруг понадобится второй провайдер оплаты» — не повод писать его поддержку сейчас.
Как они соотносятся
DRY подталкивает объединять, KISS и YAGNI — не усложнять заранее. Компромисс — правило трёх: два похожих фрагмента терпят, на третьем выносят общее.
Частая ошибка
Объединять код только потому, что он одинаково выглядит. Если фрагменты выражают разные правила, общий метод свяжет их.
Как устроено
- DRY. Формулировка Ханта и Томаса (1999): каждое знание должно иметь единственное, однозначное, авторитетное представление в системе. Во втором издании (2019) авторы уточнили, что DRY — про дублирование знания и намерения, а не про одинаковые строки.
- Знание дублируется и в разной форме. Правило в коде и в ограничении БД, контракт в DTO и в документации — тоже нарушение DRY.
- Случайное дублирование. Проверки «имя до 50 символов» и «не больше 50 штук в заказе» похожи, но меняются по разным причинам.
- YAGNI пришёл из экстремального программирования (Кент Бек). Он запрещает функциональность впрок, но не рефакторинг и тесты.
Когда что выбирать
| Ситуация | Что делать |
|---|---|
| Одно правило в двух местах | вынести в один источник |
| Похожий код, разные причины изменений | оставить дубль |
| Третье повторение одной логики | выделить общее |
| «Пригодится потом» | не писать |
Ловушки
- DRY через общий базовый класс или
Utilsсклеивает модули, которые иначе друг от друга не зависели бы. - YAGNI не оправдывает отсутствие тестов: они нужны уже сейчас.
Правило
Сначала спросите «это одно знание?», а не «это похожий код?».
Цена
- Неверная абстракция дороже дубля. Сэнди Метц описывает типичный путь: общий метод обрастает параметрами и
ifпод каждого нового вызывающего. Её совет — встроить код обратно в места вызова и выделить абстракцию заново. - Фаулер раскладывает цену преждевременной фичи на четыре части: построить, задержать ценное, нести сложность (cost of carry) и переделать, если угадали неверно. Даже угаданная фича несёт сложность до того дня, когда понадобится.
Компромиссы
- DRY между сервисами опаснее, чем внутри одного: общая библиотека моделей связывает релизы. Между ограниченными контекстами дублирование часто правильно.
- Знание, которое обязано меняться синхронно, держат в одном месте даже ценой связности: схему сообщения генерируют из одного контракта.
- YAGNI работает, только когда изменение дёшево: есть тесты и частые релизы. Публичный API или формат хранения так не переделать, их проектируют заранее.
Как проверить в проде
- Одна и та же ошибка, исправленная в нескольких местах, — пропущенный DRY.
- Метод с флагами-параметрами и ветками под каждого вызывающего — ложный DRY.
- Код без вызовов и без покрытия тестами — след фич «на будущее».
Итог: выбор между дублем и абстракцией — ставка на то, меняются ли фрагменты вместе; при сомнении дешевле подождать.
Console.WriteLine(UserRules.IsValidName("Ann")); // TrueConsole.WriteLine(OrderRules.IsValidQuantity(3)); // True
// Одно знание — одно место: валидация и схема БД// (HasMaxLength(UserRules.MaxNameLength)) берут лимит отсюдаpublic static class UserRules{ public const int MaxNameLength = 50;
public static bool IsValidName(string name) => name.Length is > 0 and <= MaxNameLength;}
// Похоже на дубль, но это другое знание: лимит заказа меняет бизнес.// Общий метод IsInRange(x, 1, 50) связал бы два независимых правила.public static class OrderRules{ public const int MaxQuantity = 50;
public static bool IsValidQuantity(int quantity) => quantity is > 0 and <= MaxQuantity;}- Нарушает ли DRY одинаковая константа
50в двух местах? — Нет, если это разные правила; да, если одно правило записано дважды. - Что такое правило трёх? — Два повтора терпят, на третьем выносят общее; правило Дона Робертса, популяризировал Фаулер в «Рефакторинге».
- Противоречит ли YAGNI рефакторингу? — Нет, YAGNI запрещает функциональность впрок, а не улучшение структуры.
- Когда YAGNI не работает? — Там, где изменение дорого: публичный API, формат данных, схема большой БД.
- Где DRY применяется кроме кода? — Схема БД, конфигурация, документация, контракты API.
Что такое принцип подстановки Лисков (LSP) и принцип разделения интерфейсов (ISP)?
Заголовок раздела «Что такое принцип подстановки Лисков (LSP) и принцип разделения интерфейсов (ISP)?»Что это
- LSP, принцип подстановки Лисков. Код, который работает с базовым типом, правильно работает и с любым наследником, не зная о нём.
- ISP, принцип разделения интерфейсов. Клиент зависит только от методов, которые вызывает.
Зачем
- LSP позволяет полагаться на полиморфизм. Проверка
if (shape is Square)в клиентском коде — признак нарушения. - ISP уменьшает связь: изменение чужого метода клиента не затрагивает, а заглушку для теста писать проще.
Как пользоваться
- Наследуйтесь, только если наследник выполняет всё, что обещает базовый тип.
- Если реализация вынуждена бросать
NotImplementedException, интерфейс слишком широк: разделите его.
Частая ошибка
Наследовать по принципу «является» из реальной жизни. В геометрии квадрат — прямоугольник, но изменяемый Square не подтип изменяемого Rectangle: установка ширины меняет высоту.
Как устроено
LSP — про контракт метода: предусловия (что верно до вызова), постусловия (что гарантировано после) и инварианты (что верно всегда). Подтип обязан:
- не усиливать предусловия — не отвергать аргументы, которые базовый метод принимает;
- не ослаблять постусловия — не обещать меньше базового;
- сохранять инварианты базового типа;
- не бросать исключения, которых клиент базового типа не ждёт.
Сигнатуры проверяет компилятор, поведение — нет.
Классические нарушения
Square : Rectangleс изменяемыми сторонами: ломается постусловие «сеттер ширины не меняет высоту».NotImplementedExceptionв реализации интерфейса.ReadOnlyCollection<T>, переданная какICollection<T>:AddбросаетNotSupportedException.
ISP
«Толстый» интерфейс, например IRepository с двадцатью методами, заставляет клиентов зависеть от лишнего. Решение — ролевые интерфейсы по нуждам клиентов: IOrderReader и IOrderWriter. Один класс может реализовать оба.
Правило
Принимайте самый узкий интерфейс, который нужен методу: IEnumerable<T> или IReadOnlyList<T>, а не List<T>.
Под капотом
- Неформально LSP прозвучал в докладе Барбары Лисков «Data Abstraction and Hierarchy» (1987). Строго — в статье Лисков и Винг «A Behavioral Notion of Subtyping» (1994): если свойство доказуемо для объектов типа
T, оно должно выполняться для объектов подтипаS. - Статья добавляет ограничение истории: подтип не допускает изменений состояния, которых не допускает базовый тип. Поэтому изменяемая точка не подтип неизменяемой, даже если все методы совпадают.
- Мартин в «Clean Architecture» применяет LSP шире наследования: к реализациям интерфейсов и даже к REST-сервисам с общим контрактом.
Компромиссы
StreamсCanSeekиICollection<T>сIsReadOnlyформально LSP соблюдают: базовый контракт сам разрешаетNotSupportedException. Цена — каждый клиент проверяет флаг, и проверку легко забыть.IReadOnlyCollection<T>иIReadOnlyList<T>(.NET Framework 4.5) — ISP задним числом: метод, который только читает, больше не требует изменяемой коллекции.- Слишком мелкие интерфейсы дробят модель. Фаулер противопоставляет ролевые интерфейсы «заголовочным», которые копируют все публичные методы класса; режут по ролям клиентов, а не по одному интерфейсу на метод.
Как проверить в проде
is,asиGetType()для конкретных подтипов в клиентском коде — запах нарушения LSP.NotImplementedExceptionв реализациях находит поиск по коду.- Общий абстрактный тестовый класс, который прогоняется для каждой реализации интерфейса, проверяет контракт, а не сигнатуры.
Итог: LSP — поведенческий контракт, который компилятор не проверяет, а ISP удешевляет его соблюдение, сужая контракт до роли клиента.
Rectangle r = new Square();r.Width = 2;r.Height = 5;Console.WriteLine(r.Area); // 25, а клиент Rectangle ждёт 10
public class Rectangle{ public virtual int Width { get; set; } public virtual int Height { get; set; } public int Area => Width * Height;}
// Квадрат добавляет инвариант «ширина == высота» и ломает// постусловие базового типа: сеттер меняет обе стороныpublic class Square : Rectangle{ public override int Width { get => base.Width; set { base.Width = value; base.Height = value; } }
public override int Height { get => base.Height; set { base.Width = value; base.Height = value; } }}IList<int> list = new List<int> { 1, 2 }.AsReadOnly();Console.WriteLine(list.IsReadOnly); // True: контракт разрешает отказtry{ list.Add(3);}catch (NotSupportedException){ Console.WriteLine("NotSupportedException");}
// ISP: методу нужно только читать — он просит узкий интерфейсConsole.WriteLine(Sum(new List<int> { 1, 2 })); // 3Console.WriteLine(Sum(new[] { 1, 2, 3 })); // 6
static int Sum(IReadOnlyList<int> items){ int sum = 0; for (int i = 0; i < items.Count; i++) sum += items[i]; return sum;}- Как исправить квадрат и прямоугольник? — Сделать типы неизменяемыми или не связывать их наследованием: общий интерфейс
IShapeсArea. - Нарушает ли LSP
ReadOnlyCollection<T>.Add? — Формально нет: контрактICollection<T>допускает отказ черезIsReadOnly; по духу — да. - Что такое ограничение истории? — Подтип не допускает изменений состояния, которых не допускает базовый тип.
- Чем ролевой интерфейс отличается от заголовочного? — Ролевой описывает нужды клиента, заголовочный копирует все методы класса.
- Как LSP связан с вариантностью? — Подтип может принимать более общие аргументы и возвращать более конкретный результат; с C# 9 переопределение может вернуть более конкретный тип.
Что такое принцип инверсии зависимостей (DIP) и чем он отличается от внедрения зависимостей (DI) и IoC?
Заголовок раздела «Что такое принцип инверсии зависимостей (DIP) и чем он отличается от внедрения зависимостей (DI) и IoC?»Что это
DIP — буква D в SOLID. Две части по Мартину:
- Модули верхнего уровня не зависят от модулей нижнего уровня. Оба зависят от абстракций.
- Абстракции не зависят от деталей. Детали зависят от абстракций.
Верхний уровень — бизнес-правила, например InvoiceService. Нижний — детали: база, почта, платёжный шлюз.
Зачем
- Бизнес-логику можно протестировать с заглушкой вместо настоящего шлюза.
- Смена базы или провайдера не трогает правила.
Как пользоваться
- Объявите интерфейс рядом с бизнес-логикой:
IPaymentGateway. - Пусть класс зависит от интерфейса, а не от клиента конкретного провайдера.
- Реализацию передайте снаружи — через конструктор.
Частая ошибка
Считать DIP и DI одним и тем же. DIP — про направление зависимостей, DI — про способ передать объект.
Как устроено
Без DIP зависимость в коде совпадает с потоком вызовов: InvoiceService → CardGatewayClient. С DIP InvoiceService вызывает IPaymentGateway, а CardGateway его реализует. Вызов идёт как раньше, но зависимость исходного кода направлена от детали к бизнес-правилам.
Кто владеет абстракцией
Интерфейс принадлежит клиенту: лежит в его модуле и назван в его терминах — Charge(invoiceId, amount), а не PostJson(path, body). Если интерфейс лежит в сборке инфраструктуры, бизнес-модуль по-прежнему ссылается на неё, и инверсии нет.
DIP, DI и IoC
| Понятие | Что это |
|---|---|
| DIP | принцип: куда направлены зависимости |
| DI | техника: зависимость приходит снаружи |
| IoC | принцип: управление у фреймворка, а не у вашего кода |
DI помогает соблюдать DIP, но не гарантирует его. Как это делает контейнер ASP.NET Core, разобрано в теме Внедрение зависимостей.
Ловушки
- Интерфейс ради интерфейса.
IOrderServiceс одной реализацией, повторяющий все её методы и лежащий рядом с ней. Он ничего не инвертирует, только добавляет переход. - Протекающая абстракция.
IRepository, который возвращаетIQueryable<T>илиDbSet<T>, привязывает клиента к EF Core.
Правило
Интерфейс объявляет тот, кто им пользуется, и только с теми методами, которые ему нужны.
Под капотом
- Поток вызовов и зависимости исходного кода — разные графы. Интерфейс позволяет развернуть любую стрелку зависимости, не меняя поток вызовов. Мартин в «Clean Architecture» называет это главной силой ООП.
- Мартин подчёркивает: инвертируется не только зависимость, но и владение интерфейсом. Клиент объявляет, что ему нужно, а сервер подстраивается.
- На этом стоят чистая архитектура и порты и адаптеры: ядро объявляет порты, инфраструктура их реализует, а связывает всё composition root — место сборки графа объектов при старте.
Цена
- Каждый шов — интерфейс, регистрация и лишний переход при навигации и отладке.
- Абстракция, выделенная по одной реализации, повторяет её форму, и вторая реализация в неё не ложится.
Компромиссы
- Стабильное против изменчивого. От
string,List<T>и остальной BCL зависеть нормально. Инвертировать нужно зависимость от того, что меняется или недетерминировано: БД, внешних API, фреймворков, времени. - Внутри модуля конкретные классы допустимы. DIP ставят на границе: домен и инфраструктура, модуль и модуль.
Как проверить в проде
- Тесты архитектуры (NetArchTest, ArchUnitNET) фиксируют правило «домен не ссылается на инфраструктуру».
- Ссылки проектов в
.csproj: если доменный проект ссылается на EF Core или на проект инфраструктуры, инверсии нет.
Итог: DIP — инструмент для границ модулей, а не для каждого класса: интерфейс у клиента, детали снаружи.
var service = new InvoiceService();Console.WriteLine(service.Pay(42)); // Invoice 42 paid
public class InvoiceService{ // Политика создаёт и знает HTTP-клиент конкретного провайдера private readonly CardGatewayClient _client = new();
public string Pay(int invoiceId) => _client.PostJson("/charges", $$"""{"invoice":{{invoiceId}}}""") == 200 ? $"Invoice {invoiceId} paid" : "Payment declined";}
public class CardGatewayClient{ public int PostJson(string path, string body) => 200; // HTTP-вызов провайдера}using Billing;using Infrastructure;
// Composition root — единственное место, которое знает обе стороныvar service = new InvoiceService(new CardGateway());Console.WriteLine(service.Pay(42)); // Invoice 42 paid
namespace Billing{ // Абстракция лежит у клиента и названа в его терминах public interface IPaymentGateway { bool Charge(int invoiceId, decimal amount); }
public class InvoiceService(IPaymentGateway gateway) { public string Pay(int invoiceId) => gateway.Charge(invoiceId, 100m) ? $"Invoice {invoiceId} paid" : "Payment declined"; }}
namespace Infrastructure{ // Деталь зависит от абстракции бизнес-правил, а не наоборот public class CardGateway : Billing.IPaymentGateway { public bool Charge(int invoiceId, decimal amount) => true; // HTTP-вызов провайдера }}- В какой сборке должен лежать
IOrderRepository? — В доменной или прикладной, рядом с клиентом; реализация — в инфраструктуре. - Можно ли соблюдать DIP без контейнера? — Да, собрать граф вручную в
Program.cs; контейнер лишь автоматизирует сборку. - Это DIP, если внедряется конкретный класс? — Нет, это DI без инверсии; для стабильных зависимостей это нормально.
- Зачем интерфейс, если реализация одна? — Если это граница с изменчивой деталью или шов для тестов; иначе он не нужен.
- Что инвертирует DIP — поток вызовов или зависимости? — Зависимости исходного кода; поток вызовов остаётся прежним.
Когда следование SOLID вредит и как принимать решения о проектировании?
Заголовок раздела «Когда следование SOLID вредит и как принимать решения о проектировании?»Что это
SOLID — советы, как сделать код удобным для изменений. Они не бесплатны: каждый интерфейс и новый класс — ещё одно место, которое надо прочитать.
Когда SOLID вредит
- Интерфейс на каждый класс, хотя реализация одна и подменять её не нужно.
- Класс разбит на пять мелких, и логику одной операции приходится собирать по пяти файлам.
- «Расширяемость» под требования, которых нет.
Как пользоваться
- Напишите простое решение.
- Покройте его тестами.
- Когда появилась вторая реализация или правки стали болезненными, выделите абстракцию.
Частая ошибка
Обосновывать усложнение словами «так требует SOLID». Принцип — средство, а не цель: если он не решает конкретную проблему, он её создаёт.
Как устроено
Каждая абстракция добавляет косвенность. Чтобы понять вызов, надо найти реализацию, её регистрацию в контейнере и, возможно, декоратор. Это окупается, только если по этой оси действительно приходят изменения.
Признаки переусердия
- Интерфейс-копия.
IOrderServiceдляOrderServiceс одной реализацией, повторяющий все его методы. - Фрагментация. Одна операция размазана по
Handler,Validator,Mapper,FactoryиProvider, и чтобы её понять, нужно открыть десять файлов. - Спекулятивная общность. Параметры, хуки и базовые классы «на будущее», которыми пользуется один вызывающий. Так этот запах называет Фаулер в «Рефакторинге».
Когда что выбирать
| Ситуация | Решение |
|---|---|
| Сеть, время, файлы, внешняя система | абстракция сразу — это шов для тестов |
| Одна реализация внутри модуля | конкретный класс |
| Появилась вторая реализация | выделить интерфейс по факту |
| Абстракция мешает изменениям | встроить код обратно и выделить заново |
Правило
Сначала просто, рефакторинг — по мере боли. Абстракцию выделяют, когда известны хотя бы два варианта: тогда видно, что у них общего.
Откуда взялись принципы
Мартин собрал эти принципы в статье «Design Principles and Design Patterns» (2000), аббревиатуру SOLID позже предложил Майкл Фэзерс. Они отвечали на боль больших C++- и Java-систем: изменение одного заголовка тянуло пересборку и переразвёртывание половины системы.
Критика
- Дэн Норт в шуточном докладе «Why Every Element of SOLID is Wrong» назвал SRP «бессмысленно расплывчатым», а для каждого принципа предложил одну альтернативу — «пишите простой код».
- Позже он предложил CUPID: Composable, Unix philosophy, Predictable, Idiomatic, Domain-based. Это свойства, а не правила: код не «соответствует» им, а приближается к ним.
Цена
- Случайная сложность, по Фреду Бруксу, — сложность, которую добавили мы сами, а не задача. Интерфейсы, фабрики и слои без второй реализации — её классический источник.
- Косвенность бьёт по чтению и отладке: стек вызовов через декораторы и прокси, «Go to Implementation» с пятью результатами.
Компромиссы
- Решение — ставка: вероятность изменения и его цена без абстракции против цены абстракции сейчас. Дорогое для изменения — публичный API, формат данных, границы модулей — проектируют заранее, остальное — по мере необходимости.
- Команде аргумент дают ценой, а не принципом: «шов здесь, потому что API провайдера нестабилен, а тесты без него ходят в сеть».
- Подход «рефакторинг по мере боли» работает только с тестами. Без них отложенная абстракция не наступает никогда.
Как проверить в проде
- Интерфейсы с одной реализацией, которые не подменяются и в тестах, — кандидаты на удаление.
- Частота изменений файлов в
git logпоказывает реальные оси изменений. Абстракции там, где правок нет, не окупаются.
Итог: принципы — инструменты против конкретной боли; решение принимают по цене и вероятности изменений, а не по букве принципа.
var calculator = new DiscountCalculatorFactory().Create();Console.WriteLine(calculator.Calculate(new DiscountRequest(200))); // 20
// Одна реализация, никто не подменяет — косвенность без пользыpublic interface IDiscountCalculator { int Calculate(DiscountRequest request); }public interface IDiscountCalculatorFactory { IDiscountCalculator Create(); }public record DiscountRequest(int Amount);
public class DiscountCalculator : IDiscountCalculator{ public int Calculate(DiscountRequest request) => request.Amount / 10;}
public class DiscountCalculatorFactory : IDiscountCalculatorFactory{ public IDiscountCalculator Create() => new DiscountCalculator();}Console.WriteLine(Discount.For(200)); // 20
// Чистая функция: тестируется без заглушек и читается сразу.// Интерфейс появится, когда появится второй способ расчёта.public static class Discount{ public static int For(int amount) => amount / 10;}- Нужен ли интерфейс для класса с одной реализацией? — Только если это шов: внешняя система, подмена в тесте, граница модуля.
- Как переубедить коллегу, который требует интерфейс для всего? — Ценой: что интерфейс даёт сейчас, кто подменяет реализацию, во что обходится навигация.
- Что такое CUPID? — Пять свойств Дэна Норта: Composable, Unix philosophy, Predictable, Idiomatic, Domain-based.
- Когда абстракцию стоит закладывать заранее? — Когда изменение потом дорого: публичный API, формат хранения, границы между командами.
- Как убрать неудачную абстракцию? — Встроить код обратно в места вызова, удалить лишнее в каждом и выделить абстракцию заново.