Перейти до вмісту

SOLID і принципи

П’ять принципів SOLID і три евристики DRY, KISS та YAGNI: що вони насправді стверджують, як виглядають порушення на C# і в самому .NET і де принципи перестають допомагати. Перелічити літери вміє кожен, а на рівні Senior питають ціну кожного принципу та його межі.

Перед цією темою: ООП.

Junior+

Що означають принцип єдиної відповідальності (SRP) і принцип відкритості/закритості (OCP)?

Section titled “Що означають принцип єдиної відповідальності (SRP) і принцип відкритості/закритості (OCP)?”
Відповідь

Що це

  • SRP, Single Responsibility Principle. Клас має одну причину для зміни: ним керує одне джерело вимог.
  • OCP, Open/Closed Principle. Клас відкритий для розширення й закритий для зміни: нову поведінку додають, не переписуючи готовий код.

Навіщо

Правка однієї функції не ламає сусідню, а перевірений код не чіпають заради нової фічі.

Як користуватися

  1. Знайдіть у класі частини, які змінюються з різних причин: розрахунок, формат, збереження. Винесіть їх в окремі класи.
  2. 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 захищають від каскаду змін, а вісь для них обирають за реальною історією змін, а не вгадують.

До: дві причини для зміни і switch за форматом
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;200
Console.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));
}
Junior+

Що таке 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")); // True
Console.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;
}
Middle+

Що таке принцип підстановки Лісков (LSP) і принцип розділення інтерфейсів (ISP)?

Section titled “Що таке принцип підстановки Лісков (LSP) і принцип розділення інтерфейсів (ISP)?”
Відповідь

Що це

  • LSP, принцип підстановки Лісков. Код, який працює з базовим типом, правильно працює й з будь-яким нащадком, не знаючи про нього.
  • ISP, принцип розділення інтерфейсів. Клієнт залежить лише від методів, які викликає.

Навіщо

  • LSP дає змогу покладатися на поліморфізм. Перевірка if (shape is Square) у клієнтському коді — ознака порушення.
  • ISP зменшує зв’язок: зміна чужого методу клієнта не зачіпає, а заглушку для тесту писати простіше.

Як користуватися

  • Успадковуйтеся, лише якщо нащадок виконує все, що обіцяє базовий тип.
  • Якщо реалізація змушена кидати NotImplementedException, інтерфейс надто широкий: розділіть його.

Типова помилка

Успадковувати за принципом «є» з реального життя. У геометрії квадрат — прямокутник, але змінюваний Square не є підтипом змінюваного Rectangle: встановлення ширини змінює висоту.

Як влаштовано

LSP — про контракт методу: передумови (що істинне до виклику), післяумови (що гарантовано після) та інваріанти (що істинне завжди). Підтип зобов’язаний:

  1. не посилювати передумови — не відкидати аргументи, які базовий метод приймає;
  2. не послаблювати післяумови — не обіцяти менше за базовий;
  3. зберігати інваріанти базового типу;
  4. не кидати винятків, яких клієнт базового типу не чекає.

Сигнатури перевіряє компілятор, поведінку — ні.

Класичні порушення

  • 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 здешевлює його дотримання, звужуючи контракт до ролі клієнта.

Порушення LSP: квадрат і прямокутник
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; }
}
}
LSP і ISP у колекціях .NET
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 })); // 3
Console.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;
}
Middle+

Що таке принцип інверсії залежностей (DIP) і чим він відрізняється від впровадження залежностей (DI) та IoC?

Section titled “Що таке принцип інверсії залежностей (DIP) і чим він відрізняється від впровадження залежностей (DI) та IoC?”
Відповідь

Що це

DIP — літера D у SOLID. Дві частини за Мартіном:

  1. Модулі верхнього рівня не залежать від модулів нижнього рівня. Обидва залежать від абстракцій.
  2. Абстракції не залежать від деталей. Деталі залежать від абстракцій.

Верхній рівень — бізнес-правила, наприклад InvoiceService. Нижній — деталі: база, пошта, платіжний шлюз.

Навіщо

  • Бізнес-логіку можна протестувати із заглушкою замість справжнього шлюзу.
  • Зміна бази чи провайдера не зачіпає правила.

Як користуватися

  1. Оголосіть інтерфейс поруч із бізнес-логікою: IPaymentGateway.
  2. Нехай клас залежить від інтерфейсу, а не від клієнта конкретного провайдера.
  3. Реалізацію передайте ззовні — через конструктор.

Типова помилка

Вважати 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-виклик провайдера
}
}
Senior+

Коли дотримання SOLID шкодить і як ухвалювати рішення щодо проєктування?

Section titled “Коли дотримання SOLID шкодить і як ухвалювати рішення щодо проєктування?”
Відповідь

Що це

SOLID — поради, як зробити код зручним для змін. Вони не безкоштовні: кожен інтерфейс і новий клас — ще одне місце, яке треба прочитати.

Коли SOLID шкодить

  • Інтерфейс на кожен клас, хоча реалізація одна і підміняти її не потрібно.
  • Клас розбитий на п’ять дрібних, і логіку однієї операції доводиться збирати з п’яти файлів.
  • «Розширюваність» під вимоги, яких немає.

Як користуватися

  1. Напишіть просте рішення.
  2. Покрийте його тестами.
  3. Коли з’явилася друга реалізація або правки стали болісними, виділіть абстракцію.

Типова помилка

Обґрунтовувати ускладнення словами «так вимагає 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;
}