🏗️ Принципы SOLID

5 основных правил объектно-ориентированного дизайна

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

Суть: У класса должна быть только одна причина для изменения. Каждый класс должен отвечать за одну задачу.

Проблема:

Класс Invoice делает слишком много: считает сумму, сохраняет в БД и отправляет Email. Если изменится логика сохранения или формат письма — придется менять один класс.

❌ Плохо

class Invoice {
    public void calculateTotal() { 
        /* ... */ 
    }
    
    public void saveToDatabase() { 
        /* сохранение в БД */ 
    }
    
    public void sendEmailNotification() { 
        /* отправка email */ 
    }
}
                        
✅ Хорошо

class Invoice {
    public double calculateTotal() { 
        return 100.0; 
    }
}

class InvoiceRepository {
    public void save(Invoice invoice) { 
        /* БД */ 
    }
}

class EmailService {
    public void sendInvoice(Invoice invoice) { 
        /* email */ 
    }
}
                        
Преимущества:
  • Легче тестировать каждый класс отдельно
  • Проще добавлять новый функционал
  • Меньше зависимостей между классами
  • Код становится читаемым и поддерживаемым
Принцип 2
O — Open/Closed Principle (OCP)
Принцип открытости/закрытости

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

В чем проблема:

Каждая новая фича заставляет переписывать существующий if/else или switch, что рискует сломать уже работающий код. Каждый раз при добавлении новой фигуры придется менять метод calculate().

❌ Плохо — if/else ад

class AreaCalculator {
    public double calculate(Object shape) {
        if (shape instanceof Circle) {
            Circle circle = (Circle) shape;
            return Math.PI * circle.radius * 
                   circle.radius;
        } else if (shape instanceof Rectangle) {
            Rectangle rect = (Rectangle) shape;
            return rect.width * rect.height;
        }
        return 0;
    }
}
                        

⚠️ Проблемы:

  • При добавлении новой фигуры нужно менять этот класс
  • Код становится всё больше и больше
  • Риск сломать существующую функциональность
  • Нарушает принцип Single Responsibility
✅ Хорошо — через интерфейсы

interface Shape {
    double area();
}

class Circle implements Shape {
    private double radius;
    public double area() {
        return Math.PI * radius * radius;
    }
}

class Rectangle implements Shape {
    private double width, height;
    public double area() {
        return width * height;
    }
}

class AreaCalculator {
    public double calculate(Shape shape) {
        return shape.area();
    }
}
                        

✓ Преимущества:

  • Расширяем поведение, не меняя существующий код
  • Добавлять новые фигуры очень просто
  • Существующий код не рискует сломаться
  • Следует принципу Liskov Substitution
Как добавить новую фигуру (без изменения AreaCalculator):

class Triangle implements Shape {
    private double base, height;
    
    public Triangle(double base, double height) {
        this.base = base;
        this.height = height;
    }
    
    public double area() {
        return (base * height) / 2;
    }
}

// Использование
AreaCalculator calculator = new AreaCalculator();
calculator.calculate(new Circle(5));
calculator.calculate(new Rectangle(4, 6));
calculator.calculate(new Triangle(3, 4)); // новая фигура!
                
Ключевой момент:

Класс AreaCalculator больше никогда не нужно менять! Он "закрыт для модификации", но "открыт для расширения" через новые реализации интерфейса Shape.

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

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

В чем проблема:

Класс Ostrich (Страус) наследуется от Bird, но бросает исключение в fly(), меняя ожидаемое поведение. Это нарушает контракт базового класса!

❌ Плохо — Нарушение контракта

class Bird {
    public void fly() {
        System.out.println("Лечу!");
    }
}

class Ostrich extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException(
            "Страусы не умеют летать!"
        );
    }
}

// Код сломается!
Bird bird = new Ostrich();
bird.fly(); // ❌ Исключение!
                        

⚠️ Проблемы:

  • Клиентский код ожидает, что Bird летает
  • Страус нарушает этот контракт
  • Возникают runtime исключения
  • Нельзя безопасно использовать полиморфизм
✅ Хорошо — Разделяем по способностям

interface Bird {
    void eat();
}

interface FlyingBird extends Bird {
    void fly();
}

class Sparrow implements FlyingBird {
    public void eat() { 
        System.out.println("Воробей клюёт"); 
    }
    public void fly() { 
        System.out.println("Воробей летит"); 
    }
}

class Ostrich implements Bird {
    public void eat() { 
        System.out.println("Страус ест"); 
    }
    public void run() { 
        System.out.println("Страус бежит"); 
    }
}
                        

✓ Преимущества:

  • Каждый класс реализует только свои способности
  • Нет неожиданных исключений
  • Контракт не нарушается
  • Безопасный полиморфизм
Правильное использование:

// Функция ожидает летающую птицу
public void makeBirdFly(FlyingBird bird) {
    bird.fly(); // Безопасно! Только FlyingBird
}

makeBirdFly(new Sparrow()); // ✅ OK
makeBirdFly(new Ostrich()); // ❌ Ошибка на этапе компиляции

// Функция работает с любой птицей
public void feedBird(Bird bird) {
    bird.eat(); // Безопасно! Все птицы едят
}

feedBird(new Sparrow()); // ✅ OK
feedBird(new Ostrich()); // ✅ OK
                
Ключевой момент LSP:

"Если S является подтипом T, то объекты типа S могут безопасно использоваться везде, где ожидается объект типа T."

Иными словами: если Ostrich реализует Bird, то везде, где нужна Bird, можно передать Ostrich без риска ошибки.

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

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

В чем проблема:

Обычному рабочему принудительно заставляют реализовывать методы code(), хотя он не программист. Робот не может есть, но вынужден реализовывать eat().

❌ Плохо — Один огромный интерфейс

interface Worker {
    void work();
    void eat();
    void code();      // Не все работают программистами!
    void design();    // Не все дизайнеры!
}

class Programmer implements Worker {
    public void work() { 
        System.out.println("Пишу код"); 
    }
    public void eat() { 
        System.out.println("Обедаю"); 
    }
    public void code() { 
        System.out.println("Кодирую"); 
    }
    public void design() {
        throw new Exception(
            "Я не дизайнер!"
        ); // ❌ Нужно реализовать!
    }
}

class RobotWorker implements Worker {
    public void work() { 
        System.out.println("Робот работает"); 
    }
    public void eat() {
        throw new Exception(
            "Робот не ест!"
        ); // ❌ Ненужный метод!
    }
    public void code() { 
        System.out.println("Кодирую"); 
    }
    public void design() {
        throw new Exception(
            "Робот не дизайнит!"
        ); // ❌ Ненужный метод!
    }
}
                        

⚠️ Проблемы:

  • Класс должен реализовать методы, которые не использует
  • Много пустых реализаций и исключений
  • Нарушается принцип Single Responsibility
  • Код неправильно отражает возможности класса
✅ Хорошо — Маленькие специализированные интерфейсы

// Разбили на маленькие контекстные интерфейсы
interface Workable {
    void work();
}

interface Eatable {
    void eat();
}

interface Codeable {
    void code();
}

interface Designable {
    void design();
}

class Programmer implements Workable, 
                             Codeable, 
                             Eatable {
    public void work() { 
        System.out.println("Пишу код"); 
    }
    public void eat() { 
        System.out.println("Обедаю"); 
    }
    public void code() { 
        System.out.println("Кодирую"); 
    }
}

class Designer implements Workable, 
                          Designable, 
                          Eatable {
    public void work() { 
        System.out.println("Дизайню"); 
    }
    public void design() { 
        System.out.println("Создаю дизайн"); 
    }
    public void eat() { 
        System.out.println("Обедаю"); 
    }
}

class RobotWorker implements Workable, 
                              Codeable {
    public void work() { 
        System.out.println("Робот работает"); 
    }
    public void code() { 
        System.out.println("Робот кодирует"); 
    }
}
                        

✓ Преимущества:

  • Каждый класс реализует только нужные методы
  • Нет пустых реализаций
  • Код отражает реальные возможности
  • Гибкость - легко комбинировать интерфейсы
Правильное использование:

// Функция работает только с кодирующими
public void codeReview(Codeable codeable) {
    codeable.code();
}

// Функция работает только с едящими
public void lunchBreak(Eatable eatable) {
    eatable.eat();
}

// Функция работает с любым работником
public void assignTask(Workable worker) {
    worker.work();
}

// Всё работает правильно!
codeReview(new Programmer()); // ✅ OK
codeReview(new RobotWorker()); // ✅ OK
codeReview(new Designer()); // ❌ Ошибка компиляции - хорошо!

lunchBreak(new Programmer()); // ✅ OK
lunchBreak(new RobotWorker()); // ❌ Ошибка - робот не ест!
                
Ключевой момент ISP:

"Лучше иметь много узкопрофильных интерфейсов, чем один толстый интерфейс, который никто полностью не использует."

Это делает код:

  • Более гибким (легко комбинировать интерфейсы)
  • Более чистым (нет лишних методов)
  • Более типобезопасным (ошибки на этапе компиляции)
Принцип 5
D — Dependency Inversion Principle (DIP)
Принцип инверсии зависимостей

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

В чем проблема:

Класс Car жестко завязан на конкретный класс GasEngine. Поменять его на ElectricEngine без переписывания Car не выйдет.

❌ Плохо — Жесткая связь (Tight Coupling)

class GasEngine {
    public void start() {
        System.out.println("Запуск ДВС");
    }
}

class Car {
    // ❌ Жесткая зависимость!
    private GasEngine engine = 
        new GasEngine();
    
    public void start() {
        engine.start();
    }
}

// Проблема: как поменять двигатель?
Car car = new Car(); // Только бензиновый!
// car.engine = new ElectricEngine();
// ❌ Нельзя!
                        

⚠️ Проблемы:

  • Car зависит от GasEngine напрямую
  • Нельзя использовать ElectricEngine
  • Сложно тестировать
  • Новые типы требуют изменения Car
✅ Хорошо — Зависимость от абстракции (Loose Coupling)

interface Engine {
    void start();
}

class GasEngine implements Engine {
    public void start() {
        System.out.println("Запуск ДВС");
    }
}

class ElectricEngine implements Engine {
    public void start() {
        System.out.println(
            "Запуск электродвигателя"
        );
    }
}

class Car {
    private final Engine engine;
    
    // Dependency Injection
    public Car(Engine engine) {
        this.engine = engine;
    }
    
    public void start() {
        engine.start();
    }
}
                        

✓ Преимущества:

  • Car зависит от Engine (интерфейс)
  • Легко менять двигатель
  • Просто тестировать (mock Engine)
  • Гибкая и расширяемая
Правильное использование (Dependency Injection):

// Легко менять двигатели!

Car gasCar = new Car(new GasEngine());
gasCar.start(); 
// "Запуск ДВС"

Car electricCar = new Car(new ElectricEngine());
electricCar.start(); 
// "Запуск электродвигателя"

// Тестирование с mock двигателем
class MockEngine implements Engine {
    public void start() {
        System.out.println("Mock двигатель");
    }
}

Car testCar = new Car(new MockEngine());
testCar.start(); // Тест с mock!
                
Зависимости в обоих подходах:
Аспект ❌ Плохо ✅ Хорошо
Car зависит от GasEngine (конкретный) Engine (интерфейс)
Создание двигателя Car создает сам Внедряется через конструктор
Смена двигателя Требует изменения Car Достаточно другого аргумента
Тестирование Невозможно mock Легко MockEngine
Ключевой момент DIP:

"Зависимости должны течь от конкретных классов к абстракциям, а не наоборот."

Это называется Dependency Injection — мощный паттерн для гибких и тестируемых приложений!