SOLID і принципи
П’ять принципів SOLID і три евристики DRY, KISS та YAGNI: що вони насправді стверджують, як виглядають порушення на C# і в самому .NET і де принципи перестають допомагати. Перелічити літери вміє кожен, а на рівні Senior питають ціну кожного принципу та його межі.
Перед цією темою: ООП.
Що означають принцип єдиної відповідальності (SRP) і принцип відкритості/закритості (OCP)?
Section titled “Що означають принцип єдиної відповідальності (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 і як вони співвідносяться?
Section titled “Що таке 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)?
Section titled “Що таке принцип підстановки Лісков (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?
Section titled “Що таке принцип інверсії залежностей (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 шкодить і як ухвалювати рішення щодо проєктування?
Section titled “Коли дотримання 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, формат зберігання, межі між командами.
- Як прибрати невдалу абстракцію? — Вбудувати код назад у місця виклику, видалити зайве в кожному й виділити абстракцію заново.