Skip to main content

Що таке принцип єдиної відповідальності (SRP)?

Single Responsibility Principle (SRP) - клас повинен мати тільки одну причину для зміни. Кожен клас відповідає за одну конкретну річ.

Теорія

TL;DR

  • Аналогія: ресторанна кухня - кухар готує, офіціант подає, посудомийник миє. Змішай ролі і будь-яка зміна зламає все разом.
  • SRP розділяє код за причиною зміни, а не за кількістю методів.
  • Клас змінюється з двох непов'язаних причин? Розділи на два.
  • Правило: якщо збій БД і збій email обидва змушують відкрити один файл - клас порушує SRP.

Швидкий приклад

javascript
// ПОГАНО: User змінюється і через БД, і через email - дві причини class User { constructor(name, email) { this.name = name; this.email = email; } save() { db.insert(this); } // причина 1: БД sendEmail() { mailer.send(this); } // причина 2: email } // ДОБРЕ: кожен клас змінюється з однієї причини class User { constructor(name, email) { this.name = name; this.email = email; } } class UserRepository { save(user) { db.insert(user); } } class EmailService { send(user) { mailer.send(user.email); } }

Після розділення збій email-сервісу не зачіпає UserRepository. Тести залишаються ізольованими.

Головна різниця

SRP - про те, чому змінюється клас, а не про кількість методів. Клас User з 10 гетерами для імені, email і адреси повністю відповідає SRP - він змінюється тільки коли змінюються правила даних користувача. Додаєш save() - вже дві причини. Тепер зміна схеми БД змушує відкривати файл з логікою даних, що не має до неї жодного стосунку.

Коли застосовувати

  • Клас перевалив за 50 рядків і продовжує рости. Витягуй за причиною зміни, а не за кількістю рядків.
  • Два незалежних підрозділи (команда API, команда звітів) редагують один клас. Розділяй на межі їх відповідальності.
  • Тестовий набір одного класу покриває непов'язані сценарії. Кожна група тестів - окремий клас.
  • Хтось у команді каже "цей клас робить усе". Перевір git blame, знайди дві найчастіші причини змін і розділи.

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

Жодної магії в рантаймі. SRP - це правило дизайну, яке застосовуєш на code review і під час рефакторингу. Але є практичний бонус: V8 краще оптимізує менші, сфокусовані класи. Великі god classes можуть спричиняти промахи в inline cache (megamorphic IC) на критичних шляхах виконання, що сповільнює роботу. Тобто розділення - це не тільки про читабельність.

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

Помилка: "мало методів = SRP".

javascript
class User { getName() { return this.name; } getEmail() { return this.email; } save() { db.insert(this); } // порушує SRP - друга причина для змін }

Три методи, дві причини для змін. Винеси save() до UserRepository. Проблема зникає.

Помилка: god object заради зручності.

З того, що я бачив, це майже завжди починається з "потім приберу". Приховані залежності між відповідальностями ростуть, поки міграція БД не зламає email-тест і ніхто не розуміє чому. Перевір git log - якщо один клас з'являється в непов'язаних комітах, розділяй.

Помилка: TypeScript інтерфейс зі змішаними відповідальностями.

typescript
// Виглядає чисто, але порушує SRP interface UserService extends UserRepository, Emailer {}

TypeScript дозволяє таке структурно. Lint не зловить. Але коли зміниться БД, ти редагуєш інтерфейс, який також описує поведінку email.

Помилка: надмірне дроблення на нано-класи.

SRP не означає один метод на клас. Якщо два методи завжди змінюються разом з однієї причини, вони мають бути в одному класі. Надмірне дроблення додає зайву непряму залежність без користі.

Де зустрічається

  • Express.js: тонкі обробники маршрутів делегують до UserRepository і EmailService окремо. Контролери не торкаються логіки БД напряму.
  • React: компоненти тільки рендерять. Завантаження даних іде в кастомний хук або HOC. Аналітика - в useTrack. Кожна частина змінюється незалежно.
  • NestJS: @Controller, @Injectable сервіс і репозиторій - три окремих класи за конвенцією. Фреймворк сам веде до SRP.
  • Prisma: клієнт Prisma обробляє запити. Бізнес-логіка живе в окремому шарі сервісів. Їх не мішають.

Питання на співбесіді

Q: Чим SRP відрізняється від "один метод на клас"?
A: SRP групує за причиною зміни, а не за кількістю методів. Клас з 10 гетерами для даних користувача повністю відповідає SRP. Додати save() - вже порушення, бо зміна схеми БД тепер вимагає правки файлу, де нічого БД-шного немає.

Q: Як рефакторити legacy-клас, що порушує SRP, без повного переписування?
A: Патерн Strangler. Витягуй одну відповідальність за раз за feature flag. Починай з тих, що змінюються найчастіше - git log по імені класу покаже де тиск найбільший.

Q: Які компроміси SRP в мікросервісній архітектурі?
A: Більше класів, більше файлів, більше залежностей для з'єднання. Але кожен сервіс масштабується і деплоїться незалежно. Міра: чи змінюються непов'язані речі в одному файлі. Якщо ні - SRP працює.

Q: Як перевірити SRP тестами?
A: Один набір тестів на клас. Якщо тести для User мокають і БД, і email-клієнт, клас робить занадто багато.

Приклади

Базовий: розділення класу User

javascript
// Три відповідальності в одному класі: дані, збереження, email class BadUser { constructor(name, email) { this.name = name; this.email = email; } save() { db.insert(this); } // змінюється через БД sendWelcome() { mailer.send(this.email); } // змінюється через email } // Після розділення за SRP class User { constructor(name, email) { this.name = name; this.email = email; } } class UserRepository { save(user) { db.insert(user); } // змінюється тільки через БД } class WelcomeEmailService { send(user) { mailer.send(user.email); } // змінюється тільки через email } const user = new User('Alice', 'alice@example.com'); new UserRepository().save(user); // "Saved to DB" new WelcomeEmailService().send(user); // "Email sent"

Збій БД тепер стосується тільки UserRepository. Email-тести не ламаються. Ось у чому практична перевага розділення.

Середній рівень: Express-обробник маршруту (продакшен-патерн)

javascript
// ПОГАНО: один обробник мішає валідацію, БД та email app.post('/users', (req, res) => { const user = req.body; // без валідації db.users.insert(user); // логіка БД email.sendWelcome(user.email); // логіка email res.json({ success: true }); }); // ДОБРЕ: обробник делегує до сфокусованих класів class UserValidator { static validate(data) { if (!data.email) throw new Error('Email required'); return data; } } class UserRepository { async save(data) { return db.users.create(data); } } class WelcomeEmailService { async send(email) { /* логіка Nodemailer */ } } app.post('/users', async (req, res) => { const validated = UserValidator.validate(req.body); // кидає помилку при невалідних даних await new UserRepository().save(validated); await new WelcomeEmailService().send(validated.email); res.json({ success: true }); });

Якщо БД переходить з Postgres на MongoDB, змінюється тільки UserRepository. Тести валідації і email продовжують проходити. Кожен клас має рівно одну причину, щоб його відкрити.

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

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

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