SOLID — набор правил, которые помогают писать устойчивый, понятный и сопровождаемый объектно-ориентированный код. В этой статье я объясню каждое правило на доступных примерах и покажу, как небольшие изменения архитектуры делают код чище и проще в поддержке. Материал ориентирован на разработчиков, которые хотят применять принципы в реальных проектах, а не только заучивать определения.

Что такое SOLID и зачем это нужно

Аббревиатура SOLID состоит из первых букв пяти принципов: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion. Каждый принцип решает определённый класс проблем, возникающих в масштабируемых системах. Вместе они уменьшают связность компонентов и повышают гибкость кода при изменениях.

Нередко люди воспринимают SOLID как набор догм, но это не так. Принципы — инструмент, который помогает принимать решения при проектировании: когда использовать абстракции, а когда проще оставить конкретику. Я делюсь практическими рекомендациями и короткими примерами, чтобы вы могли применять идеи сразу после чтения.

Single Responsibility Principle (SRP)

Принцип единственной ответственности гласит: класс должен иметь одну причину для изменения. Это означает, что каждый класс отвечает ровно за одну сферу поведения. Разделение обязанностей упрощает тестирование и уменьшает вероятность побочных эффектов при доработках.

Ниже пример на C#, где один класс неправильно совмещает логику модели и сохранение в базу. После примера показано, как разделить обязанности.

// Плохо: одна сущность делает и валидацию, и сохранение
class User {
    public string Name { get; set; }
    public bool IsValid() {
        return !string.IsNullOrEmpty(Name);
    }
    public void Save() {
        // код сохранения в базу
    }
}

// Лучше: разделяем модель и репозиторий
class User {
    public string Name { get; set; }
    public bool IsValid() {
        return !string.IsNullOrEmpty(Name);
    }
}
interface IUserRepository {
    void Save(User user);
}
class SqlUserRepository : IUserRepository {
    public void Save(User user) {
        // конкретный код сохранения
    }
}

Отделив репозиторий от модели, мы получили возможность менять способы хранения без правок модели и тестировать валидацию отдельно. Такой подход уменьшает плотность изменений в классах.

Open/Closed Principle (OCP)

Открытость/закрытость означает: сущности должны быть открыты для расширения, но закрыты для модификации. Идея в том, чтобы добавлять новую функциональность без правки уже существующего, проверенного кода. Для этого используют абстракции и полиморфизм.

Простой пример на Java: вместо изменения условия при добавлении нового типа логики, вводим интерфейс и добавляем реализацию.

// Плохо: каждый новый тип требует изменения метода
class DiscountCalculator {
    double calculate(String type, double price) {
        if (type.equals("season")) return price * 0.9;
        if (type.equals("vip")) return price * 0.8;
        return price;
    }
}

// Лучше: интерфейс и новые реализации добавляются без правки калькулятора
interface Discount {
    double apply(double price);
}
class SeasonDiscount implements Discount {
    public double apply(double price) { return price * 0.9; }
}
class VipDiscount implements Discount {
    public double apply(double price) { return price * 0.8; }
}
class DiscountCalculator {
    double calculate(Discount discount, double price) {
        return discount.apply(price);
    }
}

Такой подход делает систему предсказуемой: новые стратегии добавляются в виде классов, тесты остаются актуальными, а базовый код не ломается.

Liskov Substitution Principle (LSP)

Принцип подстановки Лисков требует, чтобы объекты подклассов могли заменять объекты базового класса без изменения корректности программы. На практике это значит: подкласс не должен усиливать пред- или постусловия и менять ожидаемое поведение.

Типичный антипаттерн — наследование для расширения, которое ломает контракты базового класса. Пример ниже демонстрирует проблему и способ её устранить.

// Плохо: прямое наследование приводит к неожиданному поведению
class Rectangle {
    protected int width, height;
    public void setWidth(int w) { width = w; }
    public void setHeight(int h) { height = h; }
    public int area() { return width * height; }
}
class Square extends Rectangle {
    public void setWidth(int w) {
        width = w; height = w;
    }
    public void setHeight(int h) {
        width = h; height = h;
    }
}
// Код, который ожидает Rectangle, может сломаться при работе с Square

// Лучше: отделяем интерфейсы или используем композицию
interface Shape {
    int area();
}
class Rectangle2 implements Shape {
    private int width, height;
    public Rectangle2(int w, int h) { width = w; height = h; }
    public int area() { return width * height; }
}
class Square2 implements Shape {
    private int side;
    public Square2(int s) { side = s; }
    public int area() { return side * side; }
}

Я сталкивался с подобной ошибкой при рефакторинге графических элементов: тесты падали из‑за того, что Square изменял состояние Rectangle. Переход на композицию снял ограничения и упростил логику.

Interface Segregation Principle (ISP)

ISP утверждает: лучше иметь несколько специализированных интерфейсов, чем один большой. Клиенты не должны зависеть от методов, которые они не используют. Это уменьшает шум в реализациях и делает контракты понятнее.

Рассмотрим пример на TypeScript, где один интерфейс заставляет классы реализовывать лишние методы, а затем разделим интерфейс на более узкие.

// Плохо: монолитный интерфейс
interface Worker {
    work(): void;
    eat(): void;
}
class HumanWorker implements Worker {
    work() { /* работает */ }
    eat() { /* перекус */ }
}
class RobotWorker implements Worker {
    work() { /* работает */ }
    eat() { throw new Error("Robots don't eat"); }
}

// Лучше: разделяем интерфейсы
interface Workable { work(): void; }
interface Eatable { eat(): void; }
class Human implements Workable, Eatable {
    work() { /* */ }
    eat() { /* */ }
}
class Robot implements Workable {
    work() { /* */ }
}

Разделение убирает необходимость писать заглушки и бросать исключения в местах, где функциональность не применима. Это особенно важно в крупных командах с разными типами компонентов.

Dependency Inversion Principle (DIP)

DIP говорит: высокоуровневые модули не должны зависеть от низкоуровневых напрямую; оба должны зависеть от абстракций. Это уменьшает связанность и позволяет подменять реализации без правки потребителя. Часто для достижения DIP используются интерфейсы и внедрение зависимостей.

Ниже пример на Python, демонстрирующий прямую зависимость и затем исправление через абстракцию и внедрение.

# Плохо: класс напрямую создаёт конкретный объект
class FileLogger:
    def log(self, msg): 
        print("file:", msg)

class Service:
    def __init__(self):
        self.logger = FileLogger()
    def do(self):
        self.logger.log("start")

# Лучше: зависимость передаётся извне через интерфейс (протокол)
class Logger:
    def log(self, msg): pass

class FileLogger2(Logger):
    def log(self, msg): print("file:", msg)

class ConsoleLogger(Logger):
    def log(self, msg): print("console:", msg)

class Service2:
    def __init__(self, logger: Logger):
        self.logger = logger
    def do(self):
        self.logger.log("start")
# Теперь Service2 можно использовать с разными логгерами

В одном моём проекте переход к внедрению зависимостей упростил интеграционные тесты: стало легко подставлять заглушки и симулировать ошибки внешних систем.

Краткая сводка и практические советы

Подытожим: SRP уменьшает области изменений, OCP даёт путь для расширения без правки кода, LSP сохраняет предсказуемость при наследовании, ISP делает интерфейсы компактными, а DIP снижает связанность между модулями. Вместе эти идеи облегчают поддержку и развитие системы.

Несколько практических правил, которые помогают применять принципы эффективно:

  • Начинайте с простоты: не вводите абстракции преждевременно.
  • Рефакторьте по мере роста требований: маленькие изменения легче контролировать.
  • Пишите тесты: они дадут уверенность при выделении интерфейсов и композиций.
  • Проверяйте, не создаёт ли новая абстракция избыточную сложность.

Таблица: когда применять и когда осторожничать

Ситуация Применять Осторожно
Малый прототип Минимум абстракций, фокус на результате Чрезмерное разделение интерфейсов
Растущий проект SRP и DIP для тестируемости Слишком ранняя генерализация
Командная разработка ISP и OCP для разделения обязанностей Сильная связанность модулей

Как начать применять принципы прямо сейчас

Начните с поиска классов, которые делают «слишком много». Это явная подсказка для SRP. Попробуйте вынести части логики в отдельные сервисы или репозитории. Маленькие шаги работают лучше крупных рефакторингов.

Далее обратите внимание на точки, где приходится менять один и тот же файл для разных задач — это сигнал к введению абстракций. Наконец, пользуйтесь тестами как защитой: они позволят реорганизовать код без страха сломать функциональность.

Применение принципов не означает слепого следования правилам. Важнее понимать, какую проблему решает каждый принцип и какие компромиссы вы делаете. Если вы начнёте с простых, понятных изменений, вскоре увидите, что код стал гибче, а команда — спокойнее при внедрении новых фич.