SOLID

SOLID — пять принципов объектно-ориентированного проектирования, которые помогают создавать гибкие, понятные и поддерживаемые системы.

Зачем нужно

SOLID-принципы решают главную проблему ООП — неуправляемую сложность. Без них классы превращаются в «божественные объекты», изменение в одном месте ломает десятки других, а тестирование становится кошмаром.

Где используется

  • Проектирование классов и модулей
  • Архитектура фронтенд-приложений (React-компоненты)
  • Серверная архитектура (Express middleware, сервисы)
  • Любой ООП-код

Предпосылки

S — Single Responsibility Principle (Принцип единственной ответственности)

Класс должен иметь одну и только одну причину для изменения.

// ПЛОХО: класс делает всё
class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }

  save {
    // сохранение в БД
    db.query('INSERT INTO users ...', [this.name, this.email]);
  }

  sendWelcomeEmail {
    // отправка email
    mailer.send(this.email, 'Добро пожаловать!');
  }

  generateReport {
    // генерация отчёта
    return `Отчёт по пользователю ${this.name}`;
  }
}

// ХОРОШО: каждый класс — одна ответственность
class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }
}

class UserRepository {
  save(user) {
    db.query('INSERT INTO users ...', [user.name, user.email]);
  }

  findById(id) {
    return db.query('SELECT * FROM users WHERE id = $1', [id]);
  }
}

class EmailService {
  sendWelcome(user) {
    mailer.send(user.email, 'Добро пожаловать!');
  }
}

class UserReportGenerator {
  generate(user) {
    return `Отчёт по пользователю ${user.name}`;
  }
}

O — Open/Closed Principle (Принцип открытости/закрытости)

Сущности должны быть открыты для расширения, но закрыты для модификации.

// ПЛОХО: для нового типа скидки нужно менять существующий код
function calculateDiscount(order, type) {
  if (type === 'percentage') {
    return order.total * (order.discountValue / 100);
  } else if (type === 'fixed') {
    return order.discountValue;
  } else if (type === 'bogo') {   // каждый раз добавляем else if
    return order.total / 2;
  }
}

// ХОРОШО: новые скидки — новые классы, старый код не меняется
class PercentageDiscount {
  constructor(percent) { this.percent = percent; }
  calculate(total) { return total * (this.percent / 100); }
}

class FixedDiscount {
  constructor(amount) { this.amount = amount; }
  calculate(total) { return Math.min(this.amount, total); }
}

class BogoDiscount {
  calculate(total) { return total / 2; }
}

// Новая скидка — просто новый класс, ничего не ломается
class LoyaltyDiscount {
  constructor(years) { this.years = years; }
  calculate(total) { return total * Math.min(this.years * 0.02, 0.2); }
}

function applyDiscount(order, discount) {
  return order.total - discount.calculate(order.total);
}

L — Liskov Substitution Principle (Принцип подстановки Лисков)

Объекты дочернего класса должны быть заменяемы объектами родительского класса без нарушения работы программы.

// ПЛОХО: Square нарушает поведение Rectangle
class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  setWidth(w) { this.width = w; }
  setHeight(h) { this.height = h; }
  area { 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;
  }
}

// Код, ожидающий Rectangle, сломается с Square
function doubleWidth(rect) {
  const oldHeight = rect.height;
  rect.setWidth(rect.width * 2);
  // Ожидаем: area = width*2 * oldHeight
  // С Square: area = (width*2)^2 — НЕВЕРНО
  console.assert(rect.height === oldHeight); // Fails for Square!
}

// ХОРОШО: общий интерфейс без наследования, нарушающего контракт
class Shape {
  area { throw new Error('Not implemented'); }
}

class Rectangle extends Shape {
  constructor(width, height) {
    super;
    this.width = width;
    this.height = height;
  }
  area { return this.width * this.height; }
}

class Square extends Shape {
  constructor(side) {
    super;
    this.side = side;
  }
  area { return this.side ** 2; }
}

I — Interface Segregation Principle (Принцип разделения интерфейсов)

Клиент не должен зависеть от методов, которые он не использует.

// ПЛОХО: один гигантский "интерфейс" для всех
class Animal {
  fly { throw new Error('Не умею летать'); }
  swim { throw new Error('Не умею плавать'); }
  run { throw new Error('Не умею бегать'); }
  speak { throw new Error('Не умею говорить'); }
}

class Dog extends Animal {
  run { return 'Бегу!'; }
  swim { return 'Плыву!'; }
  speak { return 'Гав!'; }
  // fly — бросает ошибку, но собака знает о методе fly
}

// ХОРОШО: маленькие миксины (в JS нет интерфейсов, но есть композиция)
const canRun = {
  run { return `${this.name} бежит`; },
};

const canSwim = {
  swim { return `${this.name} плывёт`; },
};

const canFly = {
  fly { return `${this.name} летит`; },
};

class Dog {
  constructor(name) { this.name = name; }
}
Object.assign(Dog.prototype, canRun, canSwim);

class Eagle {
  constructor(name) { this.name = name; }
}
Object.assign(Eagle.prototype, canFly);

const dog = new Dog('Рекс');
dog.run;  // "Рекс бежит"
dog.swim; // "Рекс плывёт"
// dog.fly — undefined, не бросает ошибку

D — Dependency Inversion Principle (Принцип инверсии зависимостей)

Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций.

// ПЛОХО: бизнес-логика напрямую зависит от конкретной БД
class UserService {
  constructor {
    // жёсткая привязка к MySQL
    this.db = new MySQLDatabase('localhost', 'users_db');
  }

  getUser(id) {
    return this.db.query(`SELECT * FROM users WHERE id = ${id}`);
  }
}

// ХОРОШО: зависимость передаётся извне (Dependency Injection)
class UserService {
  constructor(database) {
    this.db = database; // любой объект с методом findById
  }

  getUser(id) {
    return this.db.findById('users', id);
  }
}

// В продакшене
const service = new UserService(new PostgresDatabase(config));

// В тестах
const service = new UserService(new InMemoryDatabase);

// При миграции на MongoDB — меняем только конфигурацию
const service = new UserService(new MongoDatabase(config));

Dependency Injection в Express

// Инверсия зависимостей через фабрику роутов
function createUserRouter(userService, emailService) {
  const router = express.Router;

  router.post('/users', async (req, res) => {
    const user = await userService.create(req.body);
    await emailService.sendWelcome(user);
    res.status(201).json(user);
  });

  return router;
}

// Сборка приложения — единственное место, где создаются зависимости
const userService = new UserService(new PostgresDatabase(config));
const emailService = new EmailService(new SMTPTransport(smtpConfig));
app.use('/api', createUserRouter(userService, emailService));

Сводная таблица

Принцип Что делать Что не делать
SRP Один класс = одна задача God Object
OCP Расширять через новые классы Править if/switch при каждой фиче
LSP Подклассы заменяемы Переопределять с побочными эффектами
ISP Маленькие интерфейсы Гигантский базовый класс
DIP Внедрять зависимости Создавать зависимости внутри класса

Частые ошибки

  1. Применять SOLID к каждому классу. Для простых утилит и скриптов SOLID — overkill
  2. Создавать интерфейс ради интерфейса. Если реализация одна — абстракция не нужна
  3. Путать SRP с «один метод на класс». SRP — про одну причину изменения, не про размер
  4. Забывать про тестируемость. SOLID улучшает тестируемость — если код нельзя протестировать, скорее всего нарушен DIP

Практика

  1. Возьмите один «большой» класс в проекте и разбейте по SRP
  2. Замените if/switch с типами на полиморфизм (OCP)
  3. Внедрите хотя бы одну зависимость через конструктор вместо создания внутри класса (DIP)

🎓 Источники

  • 🎓 [SOLID принципы для JavaScript, TypeScript, Node.js] · 2024-06-08 · YouTube
    • Тезисы: автор расширяет SOLID на мультипарадигменный JS — на процедурное, функциональное, реактивное программирование. TypeScript не проверяет соответствие SOLID. SOLID не автоматизируется — нужен другой программист на review. Active Record ломает весь SOLID разом. GPT-4 для рефакторинга по SOLID работает плохо, потому что обучался на массе неоптимального JS-кода.
    • Альтернативная позиция: «Ларавель имеет другую идеологию абсолютно. Он абсолютно отвергает SOLID. Active Record против SOLID». Frameworks вроде Laravel/Rails концептуально несовместимы с SOLID.
  • 🎓 [Dependency Inversion Principle for JavaScript] · 2024-11-23 · YouTube
    • Тезисы: DIP — самый сложный, недопонятый и самый полезный из SOLID. Открывает путь к архитектуре. DIP мультипарадигменный — работает в ООП, процедурном, ФП, реактивном. Service Locator и DI — частные подчинённые DIP механизмы.
    • «Модули верхних уровней не должны зависеть от модулей нижних. Оба должны зависеть от интерфейсов. Абстракции не должны зависеть от деталей, а детали — от абстракций».

Связанные темы

Ресурсы

  • Robert C. Martin — Clean Architecture
  • refactoring.guru/design-patterns — паттерны проектирования
  • wikipedia.org — SOLID