Зачем SOLID
SOLID — пять принципов объектно-ориентированного дизайна, собранных Робертом Мартином в начале 2000-х из более ранних работ (LSP — Барбары Лисков, 1987). Это не правила компилятора и не метрики, а эвристики: они отвечают на вопрос «куда положить эту ответственность, чтобы завтра её было дёшево менять».
Общая идея всех пяти — управление зависимостями. Код меняется постоянно; больно становится тогда, когда одно изменение тянет за собой каскад правок в местах, которые логически ни при чём. SOLID расставляет швы так, чтобы изменение оставалось локальным.
Важная оговорка, которую ценят на senior-интервью: SOLID — это trade-off, а не абсолютное добро. Каждый принцип покупает гибкость ценой лишней абстракции. Применять их «на всякий случай» до того, как появилась реальная ось изменений, — это спекулятивная общность, и она вредит не меньше, чем жёсткий код.
S — Single Responsibility Principle
Формулировка: у класса должна быть одна и только одна причина для изменения. Ключевое слово — «причина», а не «действие».
Самое частое искажение: «класс должен делать одну вещь». По этой трактовке начинают дробить код на классы с одним методом, получают анемичную россыпь и связность хуже исходной. Мартин позже переформулировал точнее: модуль должен отвечать перед одним актором — одной группой заинтересованных лиц, которая может потребовать изменений.
Классический пример нарушения: класс Report, который одновременно считает бизнес-метрику и форматирует её в PDF. У него два актора: финансисты (меняют формулу) и отдел маркетинга (меняет вёрстку). Правка вёрстки рискует задеть расчёт. Разделяем: ReportCalculator и ReportRenderer.
Практический признак нарушения SRP в реальном коде: у класса зависимости из разных слоёв сразу (репозиторий + HTTP-клиент + шаблонизатор), название содержит «And» или заканчивается на Manager/Helper/Util, а в тестах приходится мокать половину приложения, чтобы проверить одну формулу.
// Нарушение: две причины меняться — формула и формат.
class InvoiceReport {
BigDecimal total(List<Item> items) { /* правит бухгалтерия */ }
byte[] toPdf(BigDecimal total) { /* правит маркетинг */ }
}
// SRP: у каждого класса свой актор.
class InvoiceTotals { BigDecimal total(List<Item> items) { ... } }
class InvoicePdfView { byte[] render(BigDecimal total) { ... } }
O — Open/Closed Principle
Формулировка: программные сущности должны быть открыты для расширения, но закрыты для изменения. Новое поведение добавляется новым кодом, а не правкой уже работающего и оттестированного.
Рабочий признак нарушения — растущий switch/if-else по типу, который приходится дополнять при каждой новой разновидности. Причём в нескольких местах сразу: посчитать комиссию, отрисовать иконку, выбрать шлюз. Добавление нового способа оплаты превращается в охоту за всеми такими ветвлениями.
Механизм закрытия в Java — полиморфизм: общий интерфейс и по реализации на вариант, выбор реализации отдаётся контейнеру или Map<PaymentType, PaymentHandler>. В Spring это идиоматично: внедрив List<PaymentHandler>, вы получаете все реализации, и добавление нового бина не трогает существующий код.
Честная оговорка: полностью «закрыть» модуль от всех возможных изменений нельзя. Закрываются от конкретной, ожидаемой оси изменений — той, которая уже проявилась. Отсюда практическое правило: первый раз пишите прямо, на втором-третьем варианте выделяйте абстракцию.
Отдельно стоит знать про sealed-иерархии из Java 17. Они сознательно жертвуют OCP: набор наследников закрыт и фиксирован. Взамен switch по типу становится исчерпывающим, и компилятор сам укажет все места, требующие правки при добавлении варианта. Это законная альтернатива, когда вариантов конечное число и они меняются редко.
// Нарушение: каждый новый тип — правка этого метода (и ещё трёх похожих).
BigDecimal fee(Payment p) {
if (p.type() == CARD) return p.amount().multiply(RATE_CARD);
if (p.type() == SBP) return BigDecimal.ZERO;
throw new IllegalArgumentException();
}
// OCP: новый способ оплаты = новый бин, старый код не трогаем.
interface FeePolicy {
boolean supports(PaymentType type);
BigDecimal fee(Payment payment);
}
@Service
class FeeService {
private final List<FeePolicy> policies; // Spring внедрит все реализации
}
L — Liskov Substitution Principle
Формулировка Барбары Лисков: если S — подтип T, то объекты типа T можно заменить объектами типа S, не нарушая корректности программы. Это принцип не про синтаксис (компилятор подстановку и так разрешит), а про контракт поведения.
Правила, которые обязан соблюдать подтип: предусловия нельзя усиливать (нельзя требовать от вызывающего больше, чем базовый тип); постусловия нельзя ослаблять (нельзя гарантировать меньше); инварианты базового типа сохраняются; и history rule — подтип не должен разрешать изменения состояния, запрещённые базовым типом. Плюс подтип не должен бросать новые проверяемые исключения.
Хрестоматийный пример — Square extends Rectangle. Прямоугольник обещает: после setWidth(5) ширина равна 5, а высота не изменилась. Квадрат, чтобы сохранить свой инвариант, вынужден менять и высоту — постусловие базового типа нарушено, и любой код, полагавшийся на независимость сторон, ломается.
Сильный ход на собеседовании — привести нарушение LSP из самого JDK. Arrays.asList(...) возвращает List, у которого add бросает UnsupportedOperationException: постусловие List.add ослаблено. То же делают Collections.unmodifiableList и List.of. Формально это нарушение LSP, и живёт оно только потому, что документировано в контракте интерфейса как опциональная операция — но заметить и назвать это ценится.
Практический вывод: если реализация интерфейса вынуждена бросать UnsupportedOperationException или молча игнорировать вызов — абстракция подобрана неверно. Чаще всего это сигнал, что вместо наследования нужна композиция или интерфейс надо разделить (см. ISP).
// Нарушение LSP: подтип усиливает предусловие.
class Account {
void withdraw(BigDecimal amount) { ... } // требует amount > 0
}
class SavingsAccount extends Account {
@Override void withdraw(BigDecimal amount) {
if (amount.compareTo(MIN) < 0) // требование строже базового
throw new IllegalArgumentException();
...
}
}
// Код, написанный против Account, внезапно падает на SavingsAccount.
I — Interface Segregation Principle
Формулировка: клиенты не должны зависеть от методов, которыми не пользуются. Лучше несколько узких интерфейсов под конкретные роли, чем один «толстый».
Цена толстого интерфейса — ложные зависимости. Клиент, которому нужен один метод, при перекомпиляции и при изменении сигнатуры любого другого метода всё равно затрагивается; реализация обязана предоставить заглушки для ненужного; в тестах приходится мокать метод, который никогда не вызывается.
Примеры толстых интерфейсов есть в самой платформе: java.sql.Connection насчитывает несколько десятков методов, и большинству клиентов нужны два-три. Из-за этого JDBC-обёртки вынуждены реализовывать гору методов-заглушек.
ISP тесно связан с ролями: интерфейс лучше называть по тому, что клиент от него хочет (OrderReader, OrderWriter), а не по сущности (OrderService со всеми операциями). Одна реализация вполне может имплементировать оба — разделение нужно на стороне клиента, а не реализации.
Частая путаница: default-методы из Java 8 не «решают» проблему ISP. Они убирают боль реализации (не нужно писать заглушки), но клиент по-прежнему видит и зависит от раздутого контракта. ISP — про клиента, а не про удобство имплементора.
D — Dependency Inversion Principle
Формулировка из двух частей: (1) модули верхнего уровня не должны зависеть от модулей нижнего уровня — оба зависят от абстракций; (2) абстракции не должны зависеть от деталей — детали зависят от абстракций.
Ключевая и часто упускаемая мысль — слово «инверсия». Инвертируется не только направление зависимости, но и владение абстракцией: интерфейс принадлежит модулю верхнего уровня и написан на его языке. Не «репозиторий отдал нам интерфейс», а «доменный слой объявил, что ему нужен OrderRepository, а слой персистентности его реализовал». Именно это лежит в основе гексагональной архитектуры (порты и адаптеры) и чистой архитектуры.
Отсюда критерий: если ваш «доменный» интерфейс говорит save/findById и возвращает JPA-сущности — инверсии не произошло, вы просто добавили прослойку над деталью. Настоящий порт формулируется в терминах домена и не протекает технологией.
Самый частый senior-вопрос по этой букве: чем DIP отличается от DI и IoC. DIP — принцип дизайна (куда смотрит зависимость и кому принадлежит абстракция). DI (dependency injection) — техника: зависимость передаётся снаружи, через конструктор, а не создаётся через new внутри. IoC (inversion of control) — более широкая идея «управление вызывает нас, а не мы его», частным случаем которой является DI. Можно использовать DI и при этом нарушать DIP: если вы через конструктор внедряете конкретный класс PostgresOrderRepository, инъекция есть, а инверсии нет.
// Нет инверсии: домен знает про JDBC.
class OrderService {
private final JdbcOrderDao dao = new JdbcOrderDao(); // деталь + new внутри
}
// DIP: интерфейс объявлен доменом, реализация живёт в инфраструктуре.
package com.app.domain;
public interface OrderRepository { // порт, язык домена
Optional<Order> byNumber(OrderNumber number);
}
package com.app.infrastructure.jdbc;
class JdbcOrderRepository implements OrderRepository { ... } // адаптер
Частые заблуждения
Считать SRP правилом «один класс — одно действие». SRP про одну причину для изменения (одного актора), а не про размер класса.
Понимать OCP как запрет когда-либо править существующий код. Закрывают от конкретной оси изменений, а не от всех сразу.
Сводить LSP к тому, что «подкласс должен компилироваться на месте базового». Компилятор это и так гарантирует; LSP — про контракт поведения: предусловия, постусловия, инварианты.
Путать DIP с DI: инъекция конкретного класса через конструктор — это DI без инверсии зависимости.
Применять все пять принципов сразу и заранее. Интерфейс с единственной реализацией «на будущее» — это не DIP, а лишняя абстракция; она усложняет навигацию по коду и ничего не покупает.
Как отвечать на собеседовании
Не пересказывайте пять определений подряд — это ответ уровня junior. Дайте короткую расшифровку и сразу привяжите к коду: назовите принцип, типичный признак нарушения и как чинится.
Заготовьте по одному живому примеру: SRP — расчёт и рендер в одном классе; OCP — растущий switch по типу оплаты; LSP — Square/Rectangle или Arrays.asList().add(); ISP — толстый Connection; DIP — репозиторий как порт домена.
Сильный ответ добавляет два акцента. Первый: связка принципов — LSP и ISP обслуживают OCP (подставимость и узкие роли — то, что делает расширение безопасным), а DIP определяет, куда смотрят стрелки зависимостей между слоями. Второй: цена — каждая абстракция стоит сложности, поэтому вводить её нужно на реальной, а не воображаемой оси изменений.
Если спросят про Spring, свяжите явно: внедрение List<Strategy> — это OCP, интерфейс репозитория в доменном пакете — DIP, а @Transactional через прокси работает только потому, что прокси подставим вместо оригинала, то есть опирается на LSP.