Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Принципи SOLID». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**SOLID** - п'ять принципів об'єктно-орієнтованого дизайну, які дозволяють змінювати одну частину коду, не ламаючи іншу. - **S**RP: одна причина для зміни на клас - **O**CP: розширюй поведінку, не редагуючи існуючий код - **L**SP: підтипи замінюють базовий тип без зміни очікуваної поведінки - **I**SP: клієнт залежить тільки від того, що реально використовує - **D**IP: залежати від абстракцій, а не від конкретних класів **Ключове:** кожен принцип захищає від окремого способу, яким код ламається під час змін.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**SOLID** - п'ять принципів об'єктно-орієнтованого дизайну, які дозволяють змінювати одну частину коду, не ламаючи іншу. ## Теорія ### TL;DR - П'ять принципів: SRP, OCP, LSP, ISP, DIP - кожен захищає від окремого способу, яким код ламається під час змін - Без них правка в одному класі тягне за собою п'ять інших - Аналогія з кухнею: кожен кухар (клас) відповідає за свою страву (зону відповідальності), інструменти взаємозамінні (DIP), будь-який кухар може підмінити іншого без плутанини (LSP) - Застосовуй, коли клас має більше однієї причини для зміни або додавання фічі постійно ламає існуючі тести - Сформульовані Робертом К. Мартіном ("Uncle Bob") ### Швидкий приклад ```javascript // Погано: UserService має дві причини змінюватись (схема БД І шаблон листа) class UserService { save(user) { /* збереження в БД */ } email(user) { /* відправка листа */ } } // Добре: розділяємо відповідальності (SRP) + передаємо залежності (DIP) class UserRepository { save(user) { /* БД */ } } class EmailService { send(user) { /* лист */ } } class UserController { constructor(repo, mailer) { this.repo = repo; this.mailer = mailer; } register(user) { this.repo.save(user); this.mailer.send(user); } } ``` Змінився шаблон листа? Відкриваємо тільки `EmailService`. Змінилась база даних? Тільки `UserRepository`. Жодна зміна не торкається іншого класу. ### Принцип єдиної відповідальності (SRP) Клас повинен мати одну причину для зміни. Не один метод, не один файл - одну *причину*. Якщо і зміна схеми бази даних, і зміна шаблону листа змушують відкривати один файл - у цього файлу дві відповідальності. Типова пастка тут - надмірне дроблення. `GetUser` і `SaveUser` як окремі класи - це не SRP, це фрагментація. `UserRepository` з методами `get()` і `save()` - нормально. Обидва методи змінюються з однієї причини: логіка роботи з базою даних. ```javascript // Погано: Circle сам себе виводить - дві причини для зміни class Circle { constructor(radius) { this.radius = radius; } area() { return Math.PI * this.radius ** 2; } print() { console.log(`Area: ${this.area()}`); } // логіка відображення тут } // Добре: відображення виокремлено class Circle { constructor(radius) { this.radius = radius; } area() { return Math.PI * this.radius ** 2; } } const print = (shape) => console.log(`Area: ${shape.area()}`); // Output: "Area: 78.54" - той самий результат, ізольована відповідальність ``` ### Принцип відкритості/закритості (OCP) Класи повинні бути відкриті для розширення, але закриті для модифікації. Мета - додавати поведінку без редагування вже написаного коду. Паттерн Strategy - найчистіший спосіб реалізувати це в JavaScript. Замість ланцюга `if/else` кожен варіант стає окремим класом. ```javascript // Погано: додаєш новий тип користувача? Редагуєш цю функцію. function getDiscount(user) { if (user.type === 'regular') return 5; if (user.type === 'vip') return 10; // кожен новий тип потрапляє сюди } // Добре: новий тип - новий клас class RegularUser { getDiscount() { return 5; } } class VipUser { getDiscount() { return 10; } } class ProUser { getDiscount() { return 15; } } // новий тип, нічого не редагуємо function showDiscount(user) { return user.getDiscount(); // ніколи не змінюється } ``` З практики: OCP через спадкування само по собі породжує приховані LSP-порушення. Підкласи, що перевизначають поведінку батька, ламають код, який розраховував на оригінальну поведінку. Strategy або композиція зазвичай надійніші. ### Принцип підстановки Лісков (LSP) Якщо код очікує `Rectangle`, він повинен працювати коректно і з `Square` замість нього - не знаючи різниці. Це вся суть. Класичний контрприклад - саме цей випадок. `Square`, успадкований від `Rectangle`, перевизначає `setWidth` так, що заодно змінює висоту. Це ламає будь-який код, що встановлює ширину і висоту незалежно і перевіряє площу. ```javascript class Rectangle { constructor(w, h) { this.w = w; this.h = h; } setWidth(w) { this.w = w; } setHeight(h) { this.h = h; } area() { return this.w * this.h; } } class Square extends Rectangle { setWidth(w) { this.w = this.h = w; } // порушення LSP setHeight(h) { this.w = this.h = h; } } // Код, що розраховує на поведінку Rectangle: const shape = new Square(5, 5); shape.setWidth(5); shape.setHeight(4); console.log(shape.area()); // Очікуємо 20, отримуємо 25 - тихий баг ``` Виправлення: не успадковуй `Square` від `Rectangle`. Використовуй спільний базовий клас `Shape` тільки з `area()`, або застосуй композицію. Першопричина в тому, що квадрат і прямокутник мають різні інваріанти: у квадрата `width === height`, у прямокутника - ні. Спадкування це не вирішує. Ще одне поширене LSP-порушення в JavaScript: базовий клас `Bird` з методом `fly()`, а `Ostrich` кидає виключення з `fly()`. Як тільки з'являється `birds.forEach(b => b.fly())`, страуси ламають виконання. ### Принцип сегрегації інтерфейсів (ISP) Клієнти не повинні залежати від інтерфейсів (контрактів), які вони не використовують. В JavaScript це означає: не змушуй клас реалізовувати методи, які йому не потрібні. Проблема "товстого" інтерфейсу помітна в Express middleware і React prop types. Компонент, що отримує величезний об'єкт `UIProps` і читає з нього два поля, технічно залежить від всього вмісту цього об'єкта. ```javascript // Погано: "інтерфейс" Animal - Dog не вміє літати, Bird не ходить на чотирьох class Animal { eat() {} walk() {} swim() {} fly() {} } // Добре: ділимо за здатністю class Eatable { eat() {} } class Walkable { walk() {} } class Flyable { fly() {} } class Dog extends Walkable { /* успадковує Eatable за потреби */ } class Bird extends Flyable { /* не несе walk() або swim() */ } ``` В термінах React: `ButtonProps` з `onClick` і `label` краще за гігантський `UIElementProps`, що містить стан модального вікна, значення форми та конфіг анімацій. ### Принцип інверсії залежностей (DIP) Модулі вищого рівня не повинні залежати від модулів нижчого рівня. Обидва залежать від абстракцій. На практиці: не інстанціюй залежності всередині класу. Передавай їх ззовні. Структурна типізація JavaScript робить це простим - формального опису інтерфейсу не потрібно. Підійде будь-який об'єкт з потрібними методами. ```javascript // Погано: UserService прив'язаний до MySQL class UserService { constructor() { this.db = new MySQLDatabase(); // жорстко закодовано } getUsers() { return this.db.query('SELECT * FROM users'); } } // Добре: передаємо будь-що, що відповідає контракту class UserService { constructor(database) { this.database = database; } getUsers() { return this.database.query('SELECT * FROM users'); } } // Продакшен const service = new UserService(new MySQLDatabase()); // Заміна на Mongo: const mongoService = new UserService(new MongoDatabase()); // В тестах - звичайний мок-об'єкт: const testService = new UserService({ query: () => [{ id: 1, name: 'Alice' }] }); ``` Саме так працюють NestJS-контролери. Вони отримують репозиторії через constructor injection і ніколи самі не інстанціюють конкретні класи. Перехід з Postgres на DynamoDB стає зміною одного рядка конфігурації. ### Правила вибору - Клас має 2+ причини змінюватись: SRP, розбий на окремі класи - Додавання фічі вимагає редагування існуючої логіки: OCP, винеси в стратегію - Підклас змінює очікувану поведінку батька: LSP, перебудуй ієрархію - Клас реалізує методи, які ніколи не викликає: ISP, розділи інтерфейс - Клас сам створює залежності через `new`: DIP, передавай їх ззовні ### Типові помилки **Помилка 1: SRP означає один метод на клас** Неправильно. `HttpClient` має `get()`, `post()`, `put()`, `delete()` - кілька методів, одна відповідальність (HTTP-комунікація). Надмірне дроблення створює крихкі мікрокласи без жодної зв'язності. ```javascript // Крихко: кожна операція - окремий клас class GetUser { get(id) {} } class SaveUser { save(u) {} } // Правильно: згуртованість за відповідальністю class UserRepository { get(id) {} save(u) {} } ``` **Помилка 2: OCP тільки через спадкування** Підкласи, що перевизначають логіку батька, часто створюють приховані LSP-порушення. Паттерн Strategy або композиція дає OCP без цього ризику. **Помилка 3: DIP означає інтерфейс для кожної залежності** В TypeScript/JavaScript структурна типізація означає, що будь-який об'єкт з потрібними методами задовольняє контракт. Простий `{ query: fn }`, переданий як `database`, - це вже DIP. Надмірне обгортання уповільнює розробку без реальної користі. **Помилка 4: ISP стосується кількості методів, а не зв'язності** ISP порушується, коли клієнт *залежить від* об'єкта з методами, які не використовує - а не просто коли клас має багато методів. `UserService` з 15 методами нормальний, якщо всі його клієнти використовують усі 15. **Помилка 5: LSP "працює", значить все гаразд** Приклад з Square/Rectangle проходить перевірки типів у JavaScript. Баг виявляється під час виконання (runtime), а не при компіляції. Саме тому LSP-порушення найважче помітити без тестів. Якщо підклас перевизначає метод так, що змінює очікуваний результат - це порушення, навіть якщо поки нічого не падає. ### Де зустрічається - React: пропси компонентів через малі інтерфейси (ISP) - `ButtonProps`, а не `UIProps`; context providers передають спільний стан (DIP) - Express/NestJS: контролери отримують репозиторії через constructor injection (DIP); кожен middleware відповідає за одне завдання (SRP) - Redux: один action creator на доменну подію (SRP); редюсери розширюють поведінку без зміни базової логіки (OCP) - Node.js модуль `fs`: передавай стріми як аргументи замість жорсткого кодування шляхів (паттерн DIP) - Вибір підходу: SOLID для кодових баз з 10+ класами; YAGNI/GRASP для ранніх прототипів, де абстракції додають зайвий шум ### Запитання для співбесіди **Q:** Наведи нетривіальний SRP-приклад з e-commerce. **A:** `OrderService` оформлює замовлення. Він не повинен надсилати листи підтвердження. Коли змінюється провайдер email, ти хочеш торкнутися рівно одного файлу: `NotificationService`. Якщо обидва живуть в `OrderService`, будь-яка міграція email-провайдера вимагає повторного тестування логіки оформлення замовлень. **Q:** Як OCP працює без спадкування? **A:** Паттерн Strategy. `payment.process(new StripeStrategy())` додає новий спосіб оплати через новий клас. Сам клас `Payment` не змінюється. Це безпечніше за підкласи, бо немає спільного стану, який можна випадково зламати. **Q:** Яке правильне виправлення LSP-порушення в прикладі Square/Rectangle? **A:** Не успадковуй `Square` від `Rectangle`. Використовуй спільний базовий клас `Shape` тільки з `area()`, або застосуй композицію. Першопричина: квадрат і прямокутник мають різні інваріанти - у квадрата `width === height`, у прямокутника ні. Спадкування це не вирішує. **Q:** Як ISP застосовується в REST API? **A:** Розділи `UserPublicApi` (тільки читання) від `UserAdminApi` (мутації, видалення). Клієнтські програми реалізують тільки інтерфейс читання, адмін-панелі - обидва. Жоден клієнт не несе зайвих методів. **Q:** Як застосувати DIP у vanilla JS без DI-контейнера? **A:** Фабричні функції. `const service = new UserService(createRepo('postgres'))` де `createRepo` повертає об'єкт з потрібними методами. Замінюєш `'postgres'` на `'mongo'` - все нижче адаптується само. **Q (senior):** Маю клас `Bird` з методом `fly()` і підклас `Ostrich`, що кидає виключення з `fly()`. Тести проходять, бо ніхто поки не викликає `fly()` на `Ostrich`. Це LSP-порушення? **A:** Так. LSP стосується можливості підстановки, а не поточного використання. Як тільки будь-який код викличе `fly()` через посилання на `Bird` - `Ostrich` зламає виконання. Виправлення: забери `fly()` з базового `Bird` і створи підклас `FlyingBird`. `Ostrich` успадковує `Bird` напряму. ## Приклади ### Базовий: SRP у процесі реєстрації користувача ```javascript // Один клас, три відповідальності class UserService { register(user) { if (!user.email) throw new Error('No email'); // валідація db.insert('users', user); // збереження mailer.send(user.email, 'Welcome!'); // сповіщення } } // Три відповідальності, три класи class UserValidator { validate(user) { if (!user.email) throw new Error('No email'); } } class UserRepository { save(user) { db.insert('users', user); } } class WelcomeMailer { send(user) { mailer.send(user.email, 'Welcome!'); } } class UserService { constructor(validator, repo, mailer) { this.validator = validator; this.repo = repo; this.mailer = mailer; } register(user) { this.validator.validate(user); this.repo.save(user); this.mailer.send(user); } } ``` Змінився шаблон листа: відкриваємо тільки `WelcomeMailer`. Перейшли на PostgreSQL: тільки `UserRepository`. Нове правило валідації: тільки `UserValidator`. Інші класи про це нічого не знають. ### Середній: OCP у платіжній системі ```javascript // Погано: кожен новий спосіб оплати вимагає редагування processPayment function processPayment(order, method) { if (method === 'stripe') { /* логіка Stripe */ } if (method === 'paypal') { /* логіка PayPal */ } if (method === 'crypto') { /* крипта - нова вимога, редагуємо тут */ } } // Добре: кожна стратегія ізольована class StripePayment { process(order) { console.log(`Stripe: $${order.total}`); } } class PayPalPayment { process(order) { console.log(`PayPal: $${order.total}`); } } class CryptoPayment { // Нова вимога - новий файл, існуючий код не чіпаємо process(order) { console.log(`Crypto: ${order.total} BTC`); } } function processPayment(order, strategy) { strategy.process(order); } processPayment({ total: 100 }, new StripePayment()); // Stripe: $100 processPayment({ total: 100 }, new CryptoPayment()); // Crypto: 100 BTC ``` Новий спосіб оплати - новий файл. `processPayment` не змінюється. Це OCP через паттерн Strategy. ### Складний: LSP-порушення, що проходить перевірку типів ```javascript class Rectangle { constructor(w, h) { this.w = w; this.h = h; } setWidth(w) { this.w = w; } setHeight(h) { this.h = h; } area() { return this.w * this.h; } } class Square extends Rectangle { setWidth(w) { this.w = this.h = w; } // "розумне" перевизначення - LSP-порушення setHeight(h) { this.w = this.h = h; } } function assertRectangleArea(rect) { rect.setWidth(5); rect.setHeight(4); console.log(rect.area()); // Очікуємо 20 } assertRectangleArea(new Rectangle(1, 1)); // 20 - правильно assertRectangleArea(new Square(1, 1)); // 25 - тихий баг, без помилки типів // Виправлення: без спільної ієрархії мутабельного стану class Shape { area() {} } class Rectangle extends Shape { constructor(w, h) { super(); this.w = w; this.h = h; } area() { return this.w * this.h; } } class Square extends Shape { constructor(side) { super(); this.side = side; } area() { return this.side ** 2; } } // Обидва мають area(), без спільного мутабельного стану // Підстановка працює, бо жоден не нав'язує свої інваріанти іншому ``` Оригінальний баг жоден type checker не зловить - тільки тест. Саме тому LSP-порушення найдорожчі для виявлення в продакшені.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.