Що таке принцип єдиної відповідальності (SRP)?
Single Responsibility Principle (SRP) - клас повинен мати тільки одну причину для зміни. Кожен клас відповідає за одну конкретну річ.
Теорія
TL;DR
- Аналогія: ресторанна кухня - кухар готує, офіціант подає, посудомийник миє. Змішай ролі і будь-яка зміна зламає все разом.
- SRP розділяє код за причиною зміни, а не за кількістю методів.
- Клас змінюється з двох непов'язаних причин? Розділи на два.
- Правило: якщо збій БД і збій email обидва змушують відкрити один файл - клас порушує SRP.
Швидкий приклад
// ПОГАНО: 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".
class User {
getName() { return this.name; }
getEmail() { return this.email; }
save() { db.insert(this); } // порушує SRP - друга причина для змін
}Три методи, дві причини для змін. Винеси save() до UserRepository. Проблема зникає.
Помилка: god object заради зручності.
З того, що я бачив, це майже завжди починається з "потім приберу". Приховані залежності між відповідальностями ростуть, поки міграція БД не зламає email-тест і ніхто не розуміє чому. Перевір git log - якщо один клас з'являється в непов'язаних комітах, розділяй.
Помилка: 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
// Три відповідальності в одному класі: дані, збереження, 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-обробник маршруту (продакшен-патерн)
// ПОГАНО: один обробник мішає валідацію, БД та 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 продовжують проходити. Кожен клас має рівно одну причину, щоб його відкрити.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.