Почему так тяжело менять этот код? Почему один класс правят сразу несколько команд? Почему замена библиотеки ломает половину системы? На эти три боли отвечают пять принципов проектирования классов, сложенных в аббревиатуру SOLID.
Три из них — SRP, ISP и DIP — сформулировал Роберт Мартин, открытость-закрытость — Бертран Мейер (1988), подстановку — Барбара Лисков (1987; строгую формулировку она опубликовала вместе с Жаннетт Винг в 1994 году); в аббревиатуру их сложил Майкл Фезерс. Принципы часто объясняют на примерах с геометрическими фигурами, и они кажутся теорией ради теории; здесь каждый идёт от боли, которую снимает.
Требование приходит одно, а принять его можно двумя способами: дописать ветку внутрь существующего класса — и перепроверять его целиком; или добавить новый класс за интерфейсом — и не трогать старые. Это общая механика OCP и DIP.
SRP — одна ответственностьспросят на собеседовании
У класса должна быть одна причина для изменения.
Представьте сервис, который со временем вырос в «свалку»:
public class OrderService {
public OrderDto createOrder(CreateOrderRequest request) { /* 120 строк */ }
public byte[] exportOrders(OrderFilter filter) { /* 90 строк */ }
public void recalculateStatistics() { /* 70 строк */ }
}
Когда меняется правило создания заказа — правим этот класс. Когда меняется формат экспорта — снова он. У класса несколько несвязанных причин для изменения, и любая правка рискует задеть остальное.
Три заказчика тянут правки в один класс: причин для изменения три, и любая из них задевает чужую часть.
SRP говорит: у класса должна быть одна причина для изменения. Позже Мартин уточнил, что причина — это не «действие», а человек: модуль отвечает перед одним и только одним заказчиком. Правила экспорта просит бухгалтерия, правила создания заказа — отдел продаж, формулу статистики — маркетинг: три заказчика, три причины, три класса.
@RequiredArgsConstructor
public class CreateOrderHandler {
private final OrderRepository orders;
private final PricingPolicy pricing;
public OrderDto handle(CreateOrderCommand cmd) {
var order = Order.create(cmd.customerId(), cmd.lines(), pricing);
orders.save(order);
return OrderDto.from(order);
}
}
Этот класс меняется только когда меняется правило создания заказа. Отправка письма покупателю — другая ответственность и другой обработчик.
Разницу видно ещё до запуска бизнес-логики — достаточно посмотреть, сколько всего нужно принести, чтобы такой класс вообще собрался:
живой пример
public class SrpDemo {
record Order(String id, int total) {}
interface PricingPolicy { int apply(int total); }
interface ExportFormat { String render(Order order); }
interface StatsSink { void record(Order order); }
static class OrderEverything {
private final PricingPolicy pricing;
private final ExportFormat format;
private final StatsSink stats;
OrderEverything(PricingPolicy pricing, ExportFormat format, StatsSink stats) {
this.pricing = pricing;
this.format = format;
this.stats = stats;
}
Order create(String id, int total) {
Order order = new Order(id, pricing.apply(total));
stats.record(order);
return order;
}
}
static class CreateOrderHandler {
private final PricingPolicy pricing;
CreateOrderHandler(PricingPolicy pricing) {
this.pricing = pricing;
}
Order handle(String id, int total) {
return new Order(id, pricing.apply(total));
}
}
public static void main(String[] args) {
PricingPolicy tenPercentOff = total -> total - total / 10;
System.out.println("свалке нужно заглушек: "
+ OrderEverything.class.getDeclaredConstructors()[0].getParameterCount());
System.out.println("обработчику создания: "
+ CreateOrderHandler.class.getDeclaredConstructors()[0].getParameterCount());
System.out.println(new OrderEverything(tenPercentOff, o -> "csv", o -> {}).create("A-1", 1000));
System.out.println(new CreateOrderHandler(tenPercentOff).handle("A-2", 1000));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Печатает 3 и 1: чтобы создать один заказ в классе-свалке, надо принести экспорт и статистику, которые к созданию отношения не имеют. Эту цену платит каждый, кто пишет тест.
Хороший сигнал нарушения SRP — когда за союзом «и» в устном описании стоят разные заказчики: «он создаёт заказ и отправляет письмо и пересчитывает статистику». А вот «проверил, сохранил и вернул ответ» — это один сценарий одного заказчика, делить тут нечего.
OCP — открыт для расширения, закрыт для измененияспросят на собеседовании
Появился новый тип клиента, и снова правим тот же метод расчёта скидки, и снова перепроверяем весь класс, потому что тронули общий код. Типичная картина — switch или цепочка if, в которую дописывают ветку при каждом новом варианте. Принцип открытости-закрытости запрещает ровно это:
Поведение системы расширяется добавлением кода, а не правкой существующего.
Вот такой switch:
// так не надо
public BigDecimal discount(Order order) {
return switch (order.customer().type()) {
case VIP -> order.total().multiply(new BigDecimal("0.10"));
case EMPLOYEE -> order.total().multiply(new BigDecimal("0.20"));
case REGULAR -> BigDecimal.ZERO;
};
}
Новый тип клиента — правка этого метода. И всех остальных switch по тому же признаку в других местах кода.
Решение — объявить точку расширения (интерфейс) и добавлять новое поведение новым классом, не трогая существующий:
живой пример
import java.math.BigDecimal;
import java.util.List;
public class DiscountDemo {
interface DiscountPolicy {
boolean supports(String customerType);
BigDecimal discount(BigDecimal total);
}
record VipPolicy() implements DiscountPolicy {
public boolean supports(String customerType) { return "VIP".equals(customerType); }
public BigDecimal discount(BigDecimal total) { return total.multiply(new BigDecimal("0.10")); }
}
// новый тип клиента — новый класс, старые не тронуты
record PartnerPolicy() implements DiscountPolicy {
public boolean supports(String customerType) { return "PARTNER".equals(customerType); }
public BigDecimal discount(BigDecimal total) { return total.multiply(new BigDecimal("0.15")); }
}
static BigDecimal discount(List<DiscountPolicy> policies, String customerType, BigDecimal total) {
return policies.stream()
.filter(p -> p.supports(customerType))
.findFirst()
.map(p -> p.discount(total))
.orElse(BigDecimal.ZERO);
}
public static void main(String[] args) {
List<DiscountPolicy> policies = List.of(new VipPolicy(), new PartnerPolicy());
BigDecimal total = new BigDecimal("1000");
System.out.println("VIP: " + discount(policies, "VIP", total));
System.out.println("PARTNER: " + discount(policies, "PARTNER", total));
System.out.println("REGULAR: " + discount(policies, "REGULAR", total));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Печатает 100.00, 150.00 и 0: партнёрская скидка появилась новым классом, а метод discount не изменился.
Оговорка: OCP не значит «плодить интерфейсы на всякий случай». Если вариантов заведомо два и новых не ожидается — switch честнее.
Оговорка важнее первой: закрыть класс можно только по одной, заранее выбранной оси изменения. Мы закрылись по оси «тип клиента» — новый тип приходит новым классом, метод расчёта не трогаем. Но если завтра потребуют считать скидку в другой валюте, эта ось не поможет: валюта проходит поперёк всех политик сразу, и править придётся каждую.
Это не провал принципа, а его природа — OCP защищает от изменений того вида, который вы предугадали, и ни от каких других. Отсюда практическое правило: ось выбирают не по догадке, а по истории правок. То, что уже менялось дважды, скорее всего изменится и в третий раз; то, что не менялось ни разу, скорее всего и не будет.
LSP — принцип подстановки Лисковспросят на собеседовании
Класс называет себя репозиторием, наследует готовую реализацию, а неудобный delete объявляет неподдерживаемым и бросает исключение: код, который работал с базовым классом, со «специализированным» ломается. Так выглядит наследование ради повторного использования кода, и принцип подстановки его запрещает:
Реализацию можно заменить на любую другую реализацию того же контракта — и вызывающий код не заметит разницы.
Контракт подстановки проверяют по четырём пунктам. Наследник не вправе требовать от вызывающего больше, чем базовый тип (предусловия не усиливаются); обязан пообещать не меньше (постусловия не ослабляются); должен сохранять инварианты базового типа; и не может добавлять новые проверяемые исключения. delete, который перестал удалять, ослабляет постусловие — отсюда и поломка.
Честная форма — реализовать тот же интерфейс и выполнить весь его контракт, а нужное поведение добавить обёрткой поверх. В программе ниже оба варианта подставляются в один и тот же метод removeAndReport:
живой пример
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public class LspDemo {
interface ProductRepository {
Optional<String> findById(int id);
void delete(int id);
}
static class MapRepository implements ProductRepository {
private final Map<Integer, String> rows = new HashMap<>(Map.of(1, "кофемолка"));
public Optional<String> findById(int id) { return Optional.ofNullable(rows.get(id)); }
public void delete(int id) { rows.remove(id); }
}
// так надо: композиция — кеш делегирует и чистит себя
static class CachingRepository implements ProductRepository {
private final Map<Integer, String> cache = new HashMap<>();
private final ProductRepository delegate;
CachingRepository(ProductRepository delegate) { this.delegate = delegate; }
public Optional<String> findById(int id) { return Optional.ofNullable(cache.computeIfAbsent(id, key -> delegate.findById(key).orElse(null))); }
public void delete(int id) { delegate.delete(id); cache.remove(id); }
}
// так не надо: наследник сузил контракт
static class ReadOnlyRepository extends MapRepository {
@Override public void delete(int id) { throw new UnsupportedOperationException("только чтение"); }
}
static void removeAndReport(String name, ProductRepository repo) {
try {
repo.delete(1);
System.out.println(name + ": после удаления " + repo.findById(1));
} catch (UnsupportedOperationException e) {
System.out.println(name + ": контракт сломан — " + e.getMessage());
}
}
public static void main(String[] args) {
removeAndReport("композиция", new CachingRepository(new MapRepository()));
removeAndReport("наследник", new ReadOnlyRepository());
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Первая строка — Optional.empty: товар ушёл и из хранилища, и из кеша. Вторая — сообщение о сломанном контракте: вызывающий код тот же, а падает только вторая реализация. Так LSP и ловится — не в классе-нарушителе, а у того, кто им пользуется.
Вызывающий код не менялся, а результат разный: одна реализация выполняет контракт, вторая бросает исключение.
Обёртка лечит первый случай, но не второй: репозиторий «только на чтение» точно так же обязан что-то сделать с delete, хоть наследником, хоть обёрткой. Если удаление здесь не нужно вовсе — контракт надо не сужать, а объявлять отдельный, узкий: ровно об этом следующий принцип.
Правило-подсказка: UnsupportedOperationException в переопределённом методе — повод присмотреться, но не приговор. Сам JDK бросает его штатно: List.of(1, 2).add(3), Arrays.asList(1, 2).add(3), Collections.unmodifiableList(list).remove(0) — все три падают, и LSP цел, потому что Collection прямо объявляет add и remove необязательными операциями. Ломает контракт не исключение само по себе, а исключение, которого нет в контракте базового типа — того, на который рассчитывал вызывающий.
ISP — разделение интерфейсовспросят на собеседовании
Интерфейсы имеют свойство разрастаться: к save и findById со временем добавляются findForListing, findForExport, archiveOlderThan, и сервис оформления заказа, которому нужны два метода, зависит от семи и меняется при каждой правке экспорта. Принцип разделения интерфейсов:
Клиент не должен зависеть от методов, которые он не использует.
Вот такой интерфейс:
public interface OrderStorage {
void save(Order order);
Optional<Order> findById(OrderId id);
Page<OrderListRow> findForListing(OrderFilter filter, Pageable pageable);
List<OrderExportRow> findForExport(LocalDate from, LocalDate to);
void archiveOlderThan(LocalDate date);
}
Обработчик команды использует два метода из пяти, но зависит от всех. Изменение сигнатуры метода экспорта вынуждает пересобирать и его. В тестах — приходится заглушать все пять методов, хотя нужны только два.
Каждому потребителю нужны свои два метода, а зависит он от всех пяти: правка экспорта пересобирает и обработчик команды.
Решение — разрезать по потребителям, не по таблице:
// для команд — только то, что нужно
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(OrderId id);
}
// для чтения и отчётов — отдельно
public interface OrderViewRepository {
Page<OrderListRow> findForListing(OrderFilter filter, Pageable pageable);
List<OrderExportRow> findForExport(LocalDate from, LocalDate to);
}
Одна реализация может закрывать оба интерфейса — это нормально. Важно, что потребитель зависит только от методов, которые реально использует.
Почему это вообще что-то даёт, если реализация всё равно одна? Потому что цену зависимости платит не реализация, а потребитель, и платит трижды.
Первое — пересборка: изменили сигнатуру метода экспорта, и пересобирается всё, что видело широкий интерфейс, включая обработчик команды, которому до экспорта нет дела. Второе — тест: заглушка узкого интерфейса это два метода, заглушка широкого — пять, и три из них пишутся «лишь бы компилировалось», а потом молча возвращают null в тесте, который их и не звал. Третье — чтение: по полям класса видно, что он умеет, и OrderRepository честно говорит «этот класс пишет и читает заказы», а OrderStorage не говорит ничего.
живой пример
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public class IspDemo {
record Order(String id, int total) {}
interface OrderStorage {
void save(Order order);
Optional<Order> findById(String id);
String findForListing();
String findForExport();
void archiveOlderThan(int days);
}
interface OrderRepository {
void save(Order order);
Optional<Order> findById(String id);
}
static class CreateOrderHandler {
private final OrderRepository orders;
CreateOrderHandler(OrderRepository orders) {
this.orders = orders;
}
Order handle(String id, int total) {
Order order = new Order(id, total);
orders.save(order);
return order;
}
}
public static void main(String[] args) {
Map<String, Order> rows = new HashMap<>();
OrderRepository stub = new OrderRepository() {
public void save(Order order) { rows.put(order.id(), order); }
public Optional<Order> findById(String id) { return Optional.ofNullable(rows.get(id)); }
};
System.out.println("методов в широком интерфейсе: " + OrderStorage.class.getDeclaredMethods().length);
System.out.println("методов в узком: " + OrderRepository.class.getDeclaredMethods().length);
System.out.println(new CreateOrderHandler(stub).handle("A-1", 1000));
System.out.println("в базе: " + stub.findById("A-1"));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Печатает 5 и 2: заглушка обработчика в тесте уместилась в два метода вместо пяти. При этом в рабочем коде обе роли спокойно закрывает один класс — разрезан не класс, а зависимость потребителя.
DIP — инверсия зависимостейспросят на собеседовании
Order шлёт письмо через SMTP-клиент напрямую: чтобы протестировать оформление заказа, нужен почтовый сервер, а смена транспорта на очередь трогает домен. Направление зависимости здесь перевёрнуто относительно того, каким оно должно быть: не домен должен зависеть от инструмента, а инструмент от домена, отсюда и слово «инверсия».
Модули верхнего уровня не зависят от модулей нижнего уровня. Оба зависят от абстракций.
Простыми словами: бизнес-логика не должна напрямую знать про конкретные инструменты — базу данных, почтовый сервер, внешний API.
// так не надо
public class Order {
public void cancel(JavaMailSender mailSender) {
this.status = Status.CANCELLED;
mailSender.send(customer.email(), "Заказ отменён");
}
}
Доменная модель зависит от JavaMailSender — конкретной инфраструктурной детали. Смена транспорта на push-уведомления означает правку Order, а проверить отмену без реального SMTP тяжело.
Инверсия: домен объявляет что ему нужно (интерфейс), а инфраструктура решает как это реализовать:
// интерфейс объявлен рядом с доменом, говорит на языке домена
public interface NotificationPort {
void orderCancelled(Order order);
}
// реализация — в инфраструктурном слое
@RequiredArgsConstructor
public class SmtpNotificationAdapter implements NotificationPort {
private final JavaMailSender mailSender;
@Override
public void orderCancelled(Order order) {
mailSender.send(buildMessage(order));
}
}
Order больше ничего не знает про SMTP. В тесте NotificationPort легко подменяется заглушкой. Смена транспорта — новый адаптер, домен не трогается.
Зависимость идёт от SmtpNotificationAdapter к NotificationPort, который живёт рядом с доменом, а не наоборот: это и есть инверсия, вызов идёт в одну сторону, зависимость в другую.
Здесь стоит развести два разных «инверсии»: их путают постоянно, потому что оба сокращают до одного слова.
Инверсия зависимости — это и есть DIP, и она про то, кто на кого ссылается в коде: интерфейс объявлен рядом с доменом, реализация лежит в инфраструктуре и зависит от него, а не наоборот. Видно в импортах, никакого фреймворка не требует: DIP соблюдается и в программе без Spring, если интерфейс объявлен в нужном месте.
Инверсия управления и внедрение зависимостей — про то, кто создаёт объекты и подставляет их в поля: не класс вызывает new SmtpNotificationAdapter(...), а контейнер приносит готовую реализацию снаружи. Это механика времени выполнения, и разобрана она отдельно — в статье внедрение зависимостей и жизненный цикл бина.
Разница видна на двух ошибках. Можно внедрять через конструктор (внедрение есть), но объявить тип поля конкретным классом SmtpNotificationAdapter — DIP нарушен, домен снова знает про почту. И наоборот: можно вообще не иметь контейнера, собирать объекты руками в main и полностью соблюдать DIP. Контейнер — удобный способ соблюсти принцип, но не сам принцип.
Когда принципы конфликтуют друг с другомспросят на собеседовании
Пять принципов удобно читать по очереди, но в коде они встречаются одновременно и тянут в разные стороны. Развилки, которые попадаются чаще всего.
SRP против одной транзакции. Разделили «свалку» на три класса — и обнаружили, что операция, которая раньше была одним методом, теперь ходит в базу трижды, а транзакция размазана по трём обработчикам. Выход не в том, чтобы склеить обратно: координацию забирает один обработчик операции, он же владеет транзакцией, а три класса остаются разделёнными по своим причинам изменения. Если же разделение действительно приводит к лишним походам в базу на горячем пути — это тот случай, когда производительность перевешивает чистоту, и решение записывают в комментарий к задаче, а не оставляют угадывать.
ISP против простоты. На сервисе из десяти классов два интерфейса вместо одного — это два файла, которые надо открыть, чтобы понять одно. Узкие интерфейсы окупаются там, где у зависимости несколько разных потребителей и они меняются вразнобой; там, где потребитель один, OrderRepository с пятью методами честнее двух интерфейсов «на будущее».
OCP против YAGNI. Точка расширения — это интерфейс, который нужно придумать заранее, а придуманная заранее ось изменения чаще всего оказывается не той. Пока вариант один, switch из двух веток честнее фабрики стратегий. Правило трёх работает и здесь: абстракцию вводят на третьем варианте, а не на первом.
LSP против переиспользования. Наследование даёт даровой код, и соблазн «дописать один метод и переопределить второй» силён именно поэтому. Принцип подстановки этот путь закрывает: если наследник не может выполнить весь контракт родителя, переиспользовать надо композицией, даже когда это на двадцать строк длиннее.
DIP против прямолинейности. Каждый порт — это ещё один переход при чтении: щёлкнул по методу, попал в интерфейс, ищешь реализацию. Для зависимости, которая заведомо одна и вряд ли изменится (скажем, генератор идентификаторов), прямой вызов читается лучше. Инверсия окупается там, где реализация действительно меняется или где её надо подменить в тесте.
Общее правило выбора одно: побеждает тот принцип, чью боль вы уже чувствуете. Если код правят раз в год, а читают каждый день — выигрывает читаемость. Если один класс правят три команды — выигрывает SRP. «Так красивее» аргументом не является ни для одной стороны.
Чего SOLID не покрываетспросят на собеседовании
Полезно помнить рамку: все пять принципов — про устройство класса и связи между классами. Это один уровень системы, и далеко не самый дорогой.
Границы модулей SOLID не задаёт. Из того, что каждый класс хорош, не следует, что модули разделены правильно: можно собрать безупречные классы и разложить их так, что любое изменение затрагивает три модуля сразу. Этим занимаются другие инструменты — ограниченные контексты и гексагональная архитектура.
Про данные в SOLID нет ничего. Схема таблиц, индексы, форма хранимого документа, миграции — отдельная область, где ошибки живут годами и стоят дороже любого класса.
Многопоточность тоже мимо. Класс с одной ответственностью и узким интерфейсом прекрасно ломается на гонке за общим состоянием; неизменяемость, владение данными и синхронизация — тема многопоточности, а не SOLID.
И последнее: принципы ничего не говорят о том, правильную ли задачу вы решаете. Хорошо спроектированный класс, который не нужен, остаётся ненужным классом.
Коротко
- SRP: у класса одна причина для изменения, а причина — это заказчик. Если за «и то, и это» стоят разные заказчики — класс пора разделить.
- OCP: новое поведение добавляется новым классом, не правкой существующего. Интерфейс как точка расширения.
- LSP: подкласс или реализация заменяют оригинал без сюрпризов: предусловия не усиливаются, постусловия не ослабляются, инварианты сохраняются.
UnsupportedOperationExceptionв переопределённом методе — сигнал проверить, объявлен ли отказ в контракте базового типа. - ISP: интерфейс содержит только то, что нужно конкретному потребителю. Большой интерфейс режется на несколько узких.
- DIP: бизнес-логика зависит от интерфейсов, а не от конкретных классов. Направление зависимостей — к домену, не от него.
- Принципы работают вместе: класс с одной ответственностью (SRP) зависит от узкого интерфейса (ISP), объявленного в домене (DIP), реализации которого взаимозаменяемы (LSP), а новые варианты поведения добавляются новыми классами (OCP).
- Принципы конфликтуют: SRP спорит с одной транзакцией, ISP — с простотой маленького сервиса, OCP — с YAGNI. Побеждает тот, чью боль вы уже чувствуете.
- Рамка: SOLID — про устройство класса. Границы модулей, схему данных и многопоточность он не покрывает вовсе.
Что почитать дальше
- Паттерны GoF — конкретные приёмы, которые реализуют идеи SOLID на практике.
- GRASP на примерах — принципы распределения ответственности между классами.
- DRY, KISS, YAGNI и другие принципы — где у правила «не повторяйся» проходит граница.
- Гексагональная архитектура — DIP и ISP, доведённые до структуры модулей.