Skip to main content

Принципи SOLID

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-порушення найдорожчі для виявлення в продакшені.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?