SOLID принципы в JavaScript: от теории к практике на Node.js
SOLID — это пять фундаментальных принципов объектно-ориентированного проектирования, сформулированных Робертом Мартином (дядюшкой Бобом). Эти принципы помогают создавать гибкий, поддерживаемый и масштабируемый код. Хотя многие связывают SOLID с классическими ООП-языками (Java, C#), они прекрасно применимы и в JavaScript, особенно при разработке на Node.js. В этой статье мы разберем каждый принцип на конкретных примерах.
Принцип единственной ответственности (Single Responsibility Principle, SRP)
SRP гласит: «У класса (или модуля) должна быть только одна причина для изменения». Это означает, что каждый модуль отвечает за одну конкретную задачу.
Плохой пример (нарушение SRP):
class UserService {
createUser(data) { /* логика создания */ }
sendWelcomeEmail(email) { /* отправка письма */ }
logActivity(action) { /* логирование */ }
}
Здесь один класс отвечает за работу с пользователями, отправку писем и логирование. Если изменится формат логов, придется менять UserService — хотя его основная задача не менялась.
Хороший пример (соблюдение SRP):
class UserRepository {
createUser(data) { /* только работа с БД */ }
}
class EmailService {
sendWelcomeEmail(email) { /* только отправка */ }
}
class Logger {
logActivity(action) { /* только логирование */ }
}
class UserService {
constructor(userRepo, emailService, logger) {
this.userRepo = userRepo;
this.emailService = emailService;
this.logger = logger;
}
createUser(data) {
const user = this.userRepo.createUser(data);
this.emailService.sendWelcomeEmail(user.email);
this.logger.logActivity('User created');
return user;
}
}
Теперь каждый класс отвечает за свою зону ответственности. Комбинируя их в UserService, мы получаем гибкость и изолированность изменений.
Принцип открытости/закрытости (Open/Closed Principle, OCP)
OCP утверждает: «Программные сущности должны быть открыты для расширения, но закрыты для модификации». То есть мы должны иметь возможность добавлять новую функциональность без изменения существующего кода.
Плохой пример (нарушение OCP):
class PaymentProcessor {
processPayment(type, amount) {
if (type === 'card') { /* обработка карты */ }
else if (type === 'paypal') { /* обработка PayPal */ }
// При добавлении нового типа — нужно менять этот метод
}
}
Хороший пример (соблюдение OCP):
class PaymentStrategy {
pay(amount) { throw new Error('Must be implemented'); }
}
class CardPayment extends PaymentStrategy {
pay(amount) { console.log(`Paid ${amount} via Card`); }
}
class PayPalPayment extends PaymentStrategy {
pay(amount) { console.log(`Paid ${amount} via PayPal`); }
}
class PaymentProcessor {
constructor(strategy) {
this.strategy = strategy;
}
processPayment(amount) {
this.strategy.pay(amount);
}
}
// Использование
const cardPayment = new PaymentProcessor(new CardPayment());
cardPayment.processPayment(100);
const paypalPayment = new PaymentProcessor(new PayPalPayment());
paypalPayment.processPayment(200);
Теперь для добавления нового способа оплаты достаточно создать новый класс, унаследованный от PaymentStrategy, и передать его в PaymentProcessor. Исходный код не меняется.
Принцип подстановки Барбары Лисков (Liskov Substitution Principle, LSP)
LSP гласит: «Объекты в программе должны быть заменяемыми экземплярами их подтипов без изменения правильности выполнения программы». Другими словами, если у вас есть базовый класс, то любой его подкласс должен корректно работать везде, где используется базовый класс.
Плохой пример (нарушение LSP):
class Rectangle {
setWidth(w) { this.width = w; }
setHeight(h) { this.height = h; }
getArea() { return this.width * this.height; }
}
class Square extends Rectangle {
setWidth(w) { this.width = w; this.height = w; }
setHeight(h) { this.width = h; this.height = h; }
}
function printArea(rect) {
rect.setWidth(5);
rect.setHeight(10);
console.log(rect.getArea()); // Ожидаем 50, но для Square получим 100
}
Здесь Square нарушает поведение Rectangle, так как изменяет обе стороны при установке одной. Это приводит к неожиданным результатам.
Хороший пример (соблюдение LSP):
class Shape {
getArea() { throw new Error('Must be implemented'); }
}
class Rectangle extends Shape {
constructor(width, height) {
super();
this.width = width;
this.height = height;
}
getArea() { return this.width * this.height; }
}
class Square extends Shape {
constructor(side) {
super();
this.side = side;
}
getArea() { return this.side * this.side; }
}
function printArea(shape) {
console.log(shape.getArea()); // Работает корректно для обеих фигур
}
Теперь оба класса наследуются от абстрактного Shape и предоставляют свою реализацию getArea(). Они взаимозаменяемы без нарушения логики.
Принцип разделения интерфейса (Interface Segregation Principle, ISP)
ISP утверждает: «Клиенты не должны зависеть от интерфейсов, которые они не используют». В JavaScript это означает, что класс не должен реализовывать методы, которые ему не нужны.
Плохой пример (нарушение ISP):
class Worker {
work() { console.log('Working'); }
eat() { console.log('Eating'); }
sleep() { console.log('Sleeping'); }
}
class Robot extends Worker {
eat() { throw new Error('Robot does not eat'); }
sleep() { throw new Error('Robot does not sleep'); }
}
Робот вынужден реализовывать методы eat и sleep, которые ему не нужны.
Хороший пример (соблюдение ISP):
class Workable {
work() { throw new Error('Must be implemented'); }
}
class Eatable {
eat() { throw new Error('Must be implemented'); }
}
class Sleepable {
sleep() { throw new Error('Must be implemented'); }
}
class Human extends Workable {
work() { console.log('Working'); }
}
// Human также может реализовать Eatable и Sleepable
class Robot extends Workable {
work() { console.log('Working'); }
// Robot не реализует Eatable и Sleepable
}
Разделив интер