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

Откуда взялись пять принципов и что они обещают

Роберт Мартин собрал принципы проектирования в работе 2000 года, посвящённой гниению программ, а аббревиатуру SOLID около 2004 года придумал Майкл Фезерс, сложив первые буквы названий. Сами идеи старше букв: Барбара Лисков сформулировала принцип подстановки в 1987 году, Бертран Мейер описал открытость и закрытость в 1988 году в книге об объектно-ориентированном конструировании ПО, принцип единственной обязанности Мартин оформил в книге 2002 года.

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

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

Единая ответственность и типичная ошибка с классами на всё

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

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

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

Открытость для расширения в растущих и стабильных системах

Принцип открытости и закрытости требует открывать сущности для расширения и закрывать для изменения: новое поведение добавляется новым кодом, старый не правится. Классика потребности в нём выглядит так:

// до: новый тариф заставляет править общий метод
public BigDecimal discount(String plan, BigDecimal price) {
    switch (plan) {
        case "gold":   return price.multiply(new BigDecimal("0.80"));
        case "silver": return price.multiply(new BigDecimal("0.90"));
        default:       return price;
    }
}

// после: новый тариф это новый класс, старый код не трогаем
public interface Discount {
    BigDecimal apply(BigDecimal price);
}

public final class GoldDiscount implements Discount {
    public BigDecimal apply(BigDecimal price) {
        return price.multiply(new BigDecimal("0.80"));
    }
}

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

Вторая половина правды про закрытые множества. Если видов возврата два и новых не планируется, иерархия с лишними файлами только мешает, компактное ветвление честнее. В Java для таких случаев язык даёт прямой инструмент, запечатанные иерархии: Java 17 закрепил sealed, Java 21 довёл до зрелости сопоставление с образцом, и компилятор сам проверяет полноту разбора.

public sealed interface Refund permits CardRefund, CashRefund {
}

public BigDecimal settle(Refund refund) {
    return switch (refund) {
        case CardRefund r -> r.settle();
        case CashRefund r -> r.count();
    };
}

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

Подстановка Лисков как контракт, а не иерархия ради иерархии

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

public class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int value) {
        width = value;
    }

    public void setHeight(int value) {
        height = value;
    }

    public int area() {
        return width * height;
    }
}

public class Square extends Rectangle {
    public void setWidth(int value) {
        width = value;
        height = value;
    }

    public void setHeight(int value) {
        width = value;
        height = value;
    }
}

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

Живой пример того же греха хранит сама платформа Java: класс Properties наследует Hashtable, поэтому у объекта настроек торчат табличные методы, принимающие любые ключи и значения, а при сохранении в файл приложение падает, потому что формат рассчитан только на строки. Правило вывода простое: если наследник бросает исключение "операция не поддерживается" или молча портит инварианты родителя, наследование отменяется и заменяется композицией. Квадрат не наследует прямоугольник, квадрат содержит сторону. Иерархия выражает "ведёт себя как", а не переиспользует строчки.

Контракт проверяется простыми средствами: единый набор тестов супертипа запускают против подтипа. Если линейка проверок проходит для обоих, подстановка честная; если тест прямоугольника падает на квадрате, первой умирает иерархия. Такой приём, контрактные тесты, стоит дешевле любых рассуждений о формальном определении подтипов и защищает от ложных иерархий лучше ревью.

Разделение интерфейсов и инверсия зависимостей в живом коде

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

public interface NotificationChannel {
    void send(String recipient, String message);
}

public final class OrderService {
    private final NotificationChannel channel;

    public OrderService(NotificationChannel channel) {
        this.channel = channel;
    }

    public void place(Order order) {
        // сохранение заказа
        channel.send(order.email(), "заказ принят");
    }
}

Конструктор принимает абстракцию, рабочая реализация приходит снаружи: контейнер Spring Framework 6 собирает такие зависимости автоматически, в ASP.NET Core из коробки .NET 8 встроен собственный контейнер, который делает то же самое. В сложной системе инверсия это не украшение, а тестируемость и параллельная работа: команда уведомлений меняет реализацию, команда заказов не замечает. В простом CRUD-приложении интерфейс с единственной реализацией это лишний файл, лишний клик навигации и лишний вопрос на онбординге. Практическое правило: абстракция оправдана вторым случаем использования, первый обходится без неё. Роль интерфейса здесь важнее его размера: вместо одного толстого шлюза уведомлений с методами почты, пушей и SMS заводят три узких контракта, и каждый клиент видит только свой. Контейнер не заменяет проектирование: он лишь доставляет то, что спроектировано вручную, и слабый дизайн разносит так же послушно, как сильный.

Когда формальное соблюдение принципов вредит простому проекту

У простого CRUD-приложения другая экономика. Три сущности, один потребитель, полгода жизни, изменения редки и предсказуемы. Если такую систему собрать по учебнику, с репозиториями ради репозиториев, сервисами ради сервисов и фабриками ради фабрик, получится много файлов и ни одного места, где принципы что-то спасли. Слои нужны там, где за ними стоят разные причины изменения; без причин слои только замедляют чтение. Тот же бюджетный вопрос про интерфейсы: каждый интерфейс это обещание, обещание без второго исполнения пустое.

Симптом переделки узнаётся на код-ревью: ревьюер спрашивает, зачем здесь абстракция, автор отвечает "по принципу". Принцип отвечает на конкретную боль, а не живёт сам по себе. Если в проекте один клиент, одна СУБД и одна команда, деньги лучше вложить в тесты, наблюдаемость и понятные имена. Классы из сорока строк с ясными именами переживают рефакторинг лучше, чем лес интерфейсов при пустыне тестов.

Признаки системы, которой SOLID действительно нужен

Есть устойчивые сигналы, что дисциплина окупится:

  1. вариации одной сущности размножаются: способы оплаты, каналы уведомлений, форматы выгрузок добавляются каждый квартал;
  2. продукт живёт дольше года, требования меняются, и старые модули приходится открывать снова;
  3. над общей кодовой базой работают несколько команд, общие классы стали полем конфликтов слияния;
  4. внешние потребители завязаны на публичные контракты, изменение сигнатур обходится дорого;
  5. тесты требуют подмены внешних сервисов, а прямые вызовы из логики это запрещают.

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

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