Введение: SOLID в экосистеме JavaScript
SOLID — это акроним пяти фундаментальных принципов объектно-ориентированного проектирования, сформулированных Робертом Мартином. В контексте JavaScript, особенно при разработке на Node.js с использованием TypeScript и современных фреймворков (NestJS, Express), эти принципы становятся критически важными для построения масштабируемых и поддерживаемых приложений. На продвинутом уровне SOLID выходит за рамки простых классов и интерфейсов, затрагивая архитектуру модулей, внедрение зависимостей и тестирование.
В этой статье мы рассмотрим каждый принцип SOLID на примерах кода для Node.js, акцентируя внимание на практических нюансах и типичных ошибках. Вы узнаете, как применять SOLID в реальных проектах, используя возможности JavaScript (ES6+) и TypeScript.
1. Принцип единственной ответственности (SRP) в JavaScript
Теория: Класс или модуль должен иметь только одну причину для изменения. В JavaScript это часто нарушается, когда в одном файле смешиваются бизнес-логика, работа с БД и форматирование ответов.
Практика (Node.js): Разделяем ответственность на уровне сервисов и репозиториев.
// ❌ Нарушение SRP: класс обрабатывает запросы, валидирует и сохраняет
class UserService {
createUser(data) {
if (!data.email) throw new Error('Email required');
const db = new Database();
return db.save('users', data);
}
}
// ✅ Соблюдение SRP: разделение на валидатор, репозиторий и сервис
class UserValidator {
validate(data) {
if (!data.email) throw new Error('Email required');
}
}
class UserRepository {
constructor(db) {
this.db = db;
}
save(data) {
return this.db.save('users', data);
}
}
class UserService {
constructor(validator, repository) {
this.validator = validator;
this.repository = repository;
}
createUser(data) {
this.validator.validate(data);
return this.repository.save(data);
}
}
Используйте внедрение зависимостей (DI) для передачи валидатора и репозитория в сервис. Это упрощает тестирование и замену компонентов.
2. Принцип открытости/закрытости (OCP) через полиморфизм
Теория: Классы должны быть открыты для расширения, но закрыты для модификации. В JavaScript это достигается через наследование, композицию или стратегии.
Практика (Node.js с TypeScript): Используем абстрактные классы или интерфейсы для определения контракта.
// Абстрактный класс для отчётов
abstract class ReportGenerator {
abstract generate(data: any): string;
}
class PDFReport extends ReportGenerator {
generate(data: any): string {
return `PDF: ${JSON.stringify(data)}`;
}
}
class CSVReport extends ReportGenerator {
generate(data: any): string {
return `CSV: ${data.join(',')}`;
}
}
// Клиентский код не меняется при добавлении новых форматов
class ReportService {
constructor(private generator: ReportGenerator) {}
createReport(data: any) {
return this.generator.generate(data);
}
}
Для чистого JavaScript используйте фабрики или стратегии (паттерн Strategy), чтобы избежать модификации существующего кода.
3. Принцип подстановки Лисков (LSP) в асинхронном коде
Теория: Объекты базового класса должны быть заменяемы объектами производного класса без нарушения работы программы. В JavaScript LSP часто нарушается при переопределении методов с разными контрактами (например, возвращаемые типы или исключения).
Практика (Node.js с async/await): Следите за тем, чтобы подклассы не ослабляли предусловия и не усиливали постусловия.
class Database {
async connect() { return 'connected'; }
async query(sql) { return []; }
}
class MySQLDatabase extends Database {
async connect() {
// OK: возвращает тот же тип
return 'mysql_connected';
}
async query(sql) {
// ❌ Нарушение: выбрасывает ошибку, если sql не строка
if (typeof sql !== 'string') throw new Error('Invalid SQL');
return super.query(sql);
}
}
// ✅ Соблюдение LSP: подкласс не добавляет новых проверок
class PostgresDatabase extends Database {
async connect() { return 'postgres_connected'; }
async query(sql) {
// Используем те же предусловия
return await super.query(sql);
}
}
Избегайте усложнения контракта в подклассах. Используйте TypeScript для явного описания типов возвращаемых значений.
4. Принцип разделения интерфейсов (ISP) для микросервисов
Теория: Клиенты не должны зависеть от интерфейсов, которые они не используют. В JavaScript это актуально для API-клиентов и модулей.
Практика (Node.js): Разделяем большие интерфейсы на специализированные.
// ❌ Нарушение ISP: один интерфейс для всех операций
class FileManager {
read(path) { /* ... */ }
write(path, data) { /* ... */ }
delete(path) { /* ... */ }
execute(path) { /* ... */ } // Не нужен для чтения
}
// ✅ Соблюдение ISP: выделяем отдельные интерфейсы
class ReadOnlyFileManager {
read(path) { /* ... */ }
}
class WriteFileManager {
write(path, data) { /* ... */ }
}
class DeleteFileManager {
delete(path) { /* ... */ }
}
// Клиент использует только нужный интерфейс
class ReportReader {
constructor(private fileManager: ReadOnlyFileManager) {}
getReport(path) {
return this.fileManager.read(path);
}
}
В Node.js это особенно полезно при работе с адаптерами для внешних API (например, разделение на IEmailSender и ISMSSender).
5. Принцип инверсии зависимостей (DIP) с DI-контейнерами
Теория: Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций. В JavaScript это реализуется через внедрение зависимостей и контейнеры.
Практика (Node.js с Inversify или Awilix): Используем DI-контейнер для управления зависимостями.
// Абстракция (интерфейс)
class Logger {
log(message) { throw new Error('Not implemented'); }
}
// Конкретная реализация
class ConsoleLogger extends Logger {
log(message) { console.log(message); }
}
// Модуль верхнего уровня зависит от абстракции
class OrderService {
constructor(private logger: Logger) {}
createOrder(order) {