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

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

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