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

В этой статье рассмотрим, как внедрять эти принципы с умом, и да, будет немного котиков — куда без них.

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

SOLID — это пять принципов проектирования, которые помогают писать читаемый, сопровождаемый и расширяемый код.

  • S (Single Responsibility): один класс — одна ответственность.

  • O (Open/Closed): открыт для расширения, закрыт для изменения.

  • L (Liskov Substitution): дочерние классы заменяют родительские без сюрпризов.

  • I (Interface Segregation): узкие интерфейсы лучше широких.

  • D (Dependency Inversion): зависимость от абстракций, а не реализаций.

Зачем это нужно?

  • Изменение одной части приложения ломает всё (нарушение S).

  • Добавление новой фичи требует переписывать старый код (нарушение O).

  • Наследники ведут себя непредсказуемо (нарушение L).

  • Перегруженные интерфейсы заставляют писать лишний код (нарушение I).

  • Зависимость от конкретных реализаций делает код негибким (нарушение D).

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

Теперь разберем каждый принцип отдельно.

S: принцип единственной ответственности

Каждый класс должен отвечать за одну-единственную задачу. Т.е он должен быть о чём-то одном.

Вы наверняка видели такой код:

public class Cat {
    private String name;

    public Cat(String name) {
        this.name = name;
    }

    public void eat() {
        System.out.println(name + " ест.");
    }

    public void sleep() {
        System.out.println(name + " спит.");
    }

    public void cleanLitterBox() {
        System.out.println("Убираем лоток для " + name + ".");
    }
}

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

Разделим всё на отдельные классы:

public class Cat {
    private final String name;

    public Cat(String name) {
        this.name = name;
    }

    public void eat() {
        System.out.println(name + " ест.");
    }

    public void sleep() {
        System.out.println(name + " спит.");
    }
}

public class LitterBoxService {
    public void cleanLitterBox(String animalName) {
        System.out.println("Убираем лоток для " + animalName + ".");
    }
}

Теперь Cat занимается только своим поведением, а уборка делегирована сервису. Хотите добавить собачку? Это получится легко.

O: принцип открытости/закрытости

Код должен быть открыт для расширения, но закрыт для изменения. То есть можно добавлять новый функционал без изменения существующего кода.

Допустим, нужно добавить новых котов и пишем код вот так:

public class CatService {
    public void makeSound(String catType) {
        if (catType.equals("домашний")) {
            System.out.println("Мяу!");
        } else if (catType.equals("дикий")) {
            System.out.println("Ррр!");
        } else {
            throw new IllegalArgumentException("Неизвестный тип кота.");
        }
    }
}

Каждый новый тип кота — новая ветка в if-else. Этот код плохо тестируется, и его сложно поддерживать.

Используем интерфейсы и создаём классы для каждого типа кота:

public interface Cat {
    void makeSound();
}

public class DomesticCat implements Cat {
    @Override
    public void makeSound() {
        System.out.println("Мяу!");
    }
}

public class WildCat implements Cat {
    @Override
    public void makeSound() {
        System.out.println("Ррр!");
    }
}

А теперь сервис:

public class CatService {
    public void playWithCat(Cat cat) {
        cat.makeSound();
    }
}

При добавлении нового кота мы просто пишем новый класс. Сервис остаётся неизменным. Это и есть принцип открытости/закрытости.

L: принцип подстановки Барбары Лисков

Дочерний класс должен заменять родительский без изменений в поведении программы. Если замена ломает систему — вы нарушили принцип.

Пример:

public class Cat {
    public void eat() {
        System.out.println("Кот ест.");
    }
}

public class ToyCat extends Cat {
    @Override
    public void eat() {
        throw new UnsupportedOperationException("Игрушечный кот не ест.");
    }
}

Если метод работает с Cat, то передача ToyCat вызовет ошибку.

Используем интерфейс для общего поведения:

public interface Cat {
    void makeSound();
}

public class RealCat implements Cat {
    @Override
    public void makeSound() {
        System.out.println("Мяу!");
    }
}

public class ToyCat implements Cat {
    @Override
    public void makeSound() {
        System.out.println("Пи-пи!");
    }
}

Теперь ToyCat никогда не вызовет UnsupportedOperationException.

I: принцип разделения интерфейсов

Большие интерфейсы — зло. Лучше несколько маленьких, чем один огромный.

Раздутый интерфейс:

public interface Cat {
    void eat();
    void sleep();
    void climbTree();
}

Не все коты лазают по деревьям. Если HouseCat имплементирует этот интерфейс, ему придётся писать пустую реализацию climbTree.

Разделим интерфейсы по ролям:

public interface BasicCat {
    void eat();
    void sleep();
}

public interface TreeClimbingCat {
    void climbTree();
}

Теперь классы реализуют только то, что им нужно:

public class HouseCat implements BasicCat {
    @Override
    public void eat() {
        System.out.println("Мяу, я ем.");
    }

    @Override
    public void sleep() {
        System.out.println("Я дремлю.");
    }
}

public class ForestCat implements BasicCat, TreeClimbingCat {
    @Override
    public void eat() {
        System.out.println("Я ем мышей.");
    }

    @Override
    public void sleep() {
        System.out.println("Дремлю на дереве.");
    }

    @Override
    public void climbTree() {
        System.out.println("Лазаю по деревьям.");
    }
}

D: принцип инверсии зависимостей

Высокоуровневые модули не должны зависеть от низкоуровневых. Всё должно зависеть от абстракций.

Прямые зависимости:

public class Cat {
    private final DryFood food = new DryFood();

    public void eat() {
        food.consume();
    }
}

public class DryFood {
    public void consume() {
        System.out.println("Кот ест сухой корм.");
    }
}

Теперь Cat жёстко завязан на DryFood.

Внедрение зависимостей:

public class Cat {
    private final Food food;

    public Cat(Food food) {
        this.food = food;
    }

    public void eat() {
        food.consume();
    }
}

public interface Food {
    void consume();
}

public class DryFood implements Food {
    @Override
    public void consume() {
        System.out.println("Кот ест сухой корм.");
    }
}

public class WetFood implements Food {
    @Override
    public void consume() {
        System.out.println("Кот ест влажный корм.");
    }
}

Теперь вы можно передавать любой тип еды, а Cat останется неизменным.


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

Код, построенный на принципах SOLID, переживёт не только первый релиз, но и бесконечные «давайте добавим ещё вот это» от бизнеса. Он станет тем проектом, который другие разработчики будут вспоминать с теплотой, а не с дрожью.

Так что, следующий раз, когда будете писать класс или проектировать систему, подумайте о SOLID. Не потому, что это красиво звучит, а потому что это спасёт вас от десятков бессонных ночей.

Статья подготовлена в рамках специализации "Java-разработчик". Прочитать полную программу и посмотреть записи открытых уроков можно на странице курса.

Комментарии (22)


  1. AntonLarinLive
    16.01.2025 18:21

    Очередная перепевка SOLID от Рабиновича по телефону.


    1. deema35
      16.01.2025 18:21

      Такие вещи лучше повторять почаще, что-бы их точно не забыли.


    1. rukhi7
      16.01.2025 18:21

      по моему все гораздо хуже, это стиль какого-то праздника для младших школьников а-ля взрослый корпаратив - вроде бы серьезная тема но с котиками и, видимо, для котиков.

      Как по мне это просто не уважение к аудитории, или аудитория уже достаточно пушистая?


  1. LeshaRB
    16.01.2025 18:21

  1. dyadyaSerezha
    16.01.2025 18:21

    Каждый новый тип кота — новая ветка в if-else. Этот код плохо тестируется, и его сложно поддерживать

    Это прекрасно тестируется и поддерживается, если в требованиях было всего 2 типа котов и ничего не было сказано о создании новых, но вдруг понадобилось добавить третий вид через год.

    При добавлении нового кота мы просто пишем новый класс. 

    Привет, а кто же создаст и вызовет методы этого нового кота? Таки нужны изменения где-то?

    Если замена ломает систему — вы нарушили принцип.

    Это прежде всего сломает принцип полиморфизма - основной принцип ООП, а вовсе не какой-то там Лисков.

    Теперь ToyCat никогда не вызовет UnsupportedOperationException

    Только потому что автор изменил код и банально убрал исключение. Уберите его из исходного варианта и тоже всё будет ок.

    Всё должно зависеть от абстракций.

    Прямые зависимости:

    Да вы что? А как насчёт вездесущего в примерах system.out.println? Это же прямая зависимость от супернизкого уровня! Срочно написать интерфейс!

    В общем, кроме этого SOLIDа есть ещё куча всяких принципов, один из которых, кстати, мой любимый KISS. Ну и надо иметь общее чувство реальности, меры и общего понимания требований на настоящее и будущее программы. То есть, никогда не следует слепо исповедовать тот или иной принцип и всегда использовать его.


    1. maratxat
      16.01.2025 18:21

      Да вы что? А как насчёт вездесущего в примерах system.out.println? Это же прямая зависимость от супернизкого уровня! Срочно написать интерфейс!

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

      В общем, кроме этого SOLIDа есть ещё куча всяких принципов, один из которых, кстати, мой любимый KISS. Ну и надо иметь общее чувство реальности, меры и общего понимания требований на настоящее и будущее программы. То есть, никогда не следует слепо исповедовать тот или иной принцип и всегда использовать его.

      каждый принцип решает свою задачу, поэтому - да, принципы иногда друг другу противоречат, например, YAGNI и OCP, LSP и SRP и т.д.

      это все и так всем понятно. с другой стороны, не понимаю - зачем писать тысячную шаблонную статью, в которой перемалываются одни и те же истины. есть классическая литература на этот счет. без этой базы даже примеры с котиками не смогут дать понимание как должен быть устроен дизайн


      1. dyadyaSerezha
        16.01.2025 18:21

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

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

        Выделение же функционала в отдельный интерфейс ничего не даёт в плане неизменности, если вся система ещё в разработке - интерфейс будет меняться неоднократно до, а часто и после релиза.


        1. maratxat
          16.01.2025 18:21

          Мой ответ - а не будет надо, потому что не было таких требований и не предвидится в будущем. И только это определяет, нужен ли специальный интерфейс для печати или нет.

          подумал, что вы про возможный breaking change в структурах ЯПа писали.

          а так да, согласен - если не ожидается использование разных UI, то нет смысла отдельный компонент под него создавать


          1. dyadyaSerezha
            16.01.2025 18:21

            Не очень понимаю, что за breaking change в данном случае.


      1. yamakasy267
        16.01.2025 18:21

         не стоит так буквально воспринимать эти принципы

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

        У меня например много вопрос по каждому принципу из разряда :"а если..."
        Один из которых упомянули выше, а именно, по поводу ToyCat. Там просто заменили одну функцию на другую. Так что с предыдущей?? Кот теперь не умеет есть? Но нужно что бы он ел. Или выделять эту функцию в отдельный класс-сервис, потому что не только же кот может есть. В общем все это если честно очень специфично и ситуативно и напоминает кота шредингера, что код соответствует принципам ровно до момента пока расширение требований к этому коду не докажут обратно.

        Это все чем то напоминает уровни нормализации БД. Но там хотя бы есть уровни, и каждый сам решает на каком остановиться и каждый уровень имеет свои требования. Потому что там так же, что если совсем упороться ими то БД будет напоминать Огромное количество таблиц и в каждой будет помоему только 2-3 столбца, просто потому что каждая новая форма требует дополнительной декомпозиции


        1. Andrey_Solomatin
          16.01.2025 18:21

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

          Именно так. "Принципы" слишком сильное слово.

          Та же инверсия зависимости не может работать с внешними зависимостями. Можно вывод на консоль вынести в отдельный интерфейс-обёртку и тогда будет инверсия, а вот реализации этого интерфейса всё равно будет зависить от библиотек.

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

          Делать или не делать инверсию зависимостей это вопрос. И ответ зависит от того, насколько вы ожидаете изменения в коде от которого вы зависите. Зависить от меняющегося кода неприятно и от таких зависимостей стоит избавлятся.

          И так по каждому принципу, есть места где он принесёт сложность и пользу, а есть места где сложность приходит одна.


        1. Andrey_Solomatin
          16.01.2025 18:21

          Это все чем то напоминает уровни нормализации БД. Но там хотя бы есть уровни, и каждый сам решает на каком остановиться и каждый уровень имеет свои требования. Потому что там так же, что если совсем упороться ими то БД будет напоминать Огромное количество таблиц и в каждой будет помоему только 2-3 столбца, просто потому что каждая новая форма требует дополнительной декомпозиции

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

          Оверинженириг это другая крайность и с SOLID такое тоже случается.


        1. dyadyaSerezha
          16.01.2025 18:21

          Никто никогда не думает про уровни нормализации, а просто дублирует данные, согласуясь со здравым смыслом и опытом. А потом, если что-то где-то проседает по скорости, то ещё и дополнительно денормализует.


    1. Aleus1249355
      16.01.2025 18:21

      Привет, а кто же создаст и вызовет методы этого нового кота? Таки нужны изменения где-то?

      Меня тоже всегда удивляло, что этот пункт замалчивается. Если кто найдёт ответ - дайте знать.