Аббревиатура SOLID — пять принципов проектирования классов, которые сформулировал Роберт Мартин. Их часто объясняют на примерах с геометрическими фигурами, и они кажутся теорией ради теории. На деле принципы — это ответы на конкретные боли: «почему так тяжело менять этот код?», «почему один класс правят сразу несколько команд?», «почему замена библиотеки ломает половину системы?».
Разберём каждый принцип с нуля: в чём проблема, как её видно в коде и как исправить.
SRP — одна ответственность
У класса должна быть одна причина для изменения.
Представьте сервис, который со временем вырос в «свалку»:
public class OrderService {
public OrderDto createOrder(CreateOrderRequest request) { /* 120 строк */ }
public OrderDto cancelOrder(OrderId id) { /* 80 строк */ }
public Page<OrderDto> searchOrders(OrderFilter filter) { /* 60 строк */ }
public byte[] exportOrders(OrderFilter filter) { /* 90 строк */ }
public void recalculateStatistics() { /* 70 строк */ }
}
Когда меняется правило создания заказа — правим этот класс. Когда меняется формат экспорта — снова этот класс. Когда меняется алгоритм статистики — опять он. У класса несколько несвязанных причин для изменения, и любая правка рискует задеть остальное.
SRP говорит: у класса должна быть одна причина для изменения. На практике это значит — один класс решает одну задачу.
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);
}
}
Этот класс меняется только когда меняется правило создания заказа. Отправка письма покупателю — другая ответственность, она живёт в отдельном обработчике события.
Хороший сигнал нарушения SRP — когда класс содержит слово «and» в устном описании: «он создаёт заказ и отправляет письмо и пересчитывает статистику».
OCP — открыт для расширения, закрыт для изменения
Поведение системы расширяется добавлением кода, а не правкой существующего.
Типичная картина нарушения — switch или цепочка if, в которую нужно дописывать ветку каждый раз, когда появляется новый вариант:
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 по тому же признаку в других местах кода.
Решение — объявить точку расширения (интерфейс), и добавлять новое поведение новым классом, не трогая существующий:
public interface DiscountPolicy {
boolean supports(Customer customer);
BigDecimal discount(Order order);
}
public class DiscountCalculator {
private final List<DiscountPolicy> policies;
public BigDecimal discount(Order order) {
return policies.stream()
.filter(p -> p.supports(order.customer()))
.findFirst()
.map(p -> p.discount(order))
.orElse(BigDecimal.ZERO);
}
}
Новый тип скидки — новый класс, реализующий DiscountPolicy. Существующий код не трогается.
Оговорка: OCP не значит «везде плодить интерфейсы на всякий случай». Если вариантов заведомо два и новых не ожидается — switch честнее. Принцип применяется там, где расширение реально ожидается.
LSP — принцип подстановки Лисков
Реализацию можно заменить на любую другую реализацию того же контракта — и вызывающий код не заметит разницы.
Нарушение обычно выглядит как наследование ради повторного использования кода, когда подкласс нарушает ожидания родителя:
public class CachedProductRepository extends JpaProductRepository {
private final Map<ProductId, Product> cache = new ConcurrentHashMap<>();
@Override
public Optional<Product> findById(ProductId id) {
return Optional.ofNullable(cache.computeIfAbsent(id,
key -> super.findById(key).orElse(null)));
}
@Override
public void delete(ProductId id) {
throw new UnsupportedOperationException("кеш не поддерживает удаление");
}
}
Класс называет себя репозиторием, но delete бросает исключение. Код, который работал с базовым классом, со «специализированным» ломается — это и есть нарушение LSP: подкласс сузил контракт.
Правильная форма — не наследование, а композиция. Новый класс реализует тот же интерфейс и честно выполняет весь его контракт:
public class CachingProductRepository implements ProductRepository {
private final ProductRepository delegate;
private final Cache cache;
@Override
public Optional<Product> findById(ProductId id) {
return Optional.ofNullable(cache.get(id, () -> delegate.findById(id).orElse(null)));
}
@Override
public void delete(ProductId id) {
delegate.delete(id); // удаление выполняется
cache.evict(id); // и кеш инвалидируется
}
}
Теперь CachingProductRepository подставляется вместо любого другого ProductRepository без сюрпризов.
Правило-подсказка: если видите UnsupportedOperationException в переопределённом методе — почти наверняка нарушен LSP.
ISP — разделение интерфейсов
Клиент не должен зависеть от методов, которые он не использует.
Интерфейсы имеют свойство разрастаться: сначала был save и findById, потом добавили findForListing, потом exportOrders, потом 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);
}
Одна реализация может реализовывать оба интерфейса — это нормально. Важно, что каждый потребитель зависит только от тех методов, которые реально использует.
DIP — инверсия зависимостей
Модули верхнего уровня не зависят от модулей нижнего уровня. Оба зависят от абстракций.
Простыми словами: бизнес-логика не должна напрямую знать про конкретные инструменты (базу данных, почтовый сервер, внешний API). Иначе при смене инструмента придётся трогать бизнес-логику.
Типичное нарушение — доменная модель знает про конкретный транспорт уведомлений:
public class Order {
public void cancel(SmtpMailSender mailSender) {
this.status = Status.CANCELLED;
mailSender.send(customer.email(), "Заказ отменён");
}
}
Доменная модель зависит от SmtpMailSender — конкретной инфраструктурной детали. Захотите перейти на push-уведомления — придётся менять Order. Захотите протестировать отмену без реального SMTP — сложно.
Инверсия: домен объявляет что ему нужно (интерфейс), а инфраструктура решает как это реализовать:
// интерфейс объявлен рядом с доменом, говорит на языке домена
public interface NotificationPort {
void orderCancelled(Order order);
}
// реализация — в инфраструктурном слое
public class SmtpNotificationAdapter implements NotificationPort {
private final JavaMailSender mailSender;
@Override
public void orderCancelled(Order order) {
mailSender.send(buildMessage(order));
}
}
Order больше ничего не знает про SMTP. В тесте NotificationPort легко подменяется заглушкой. Смена транспорта — новый адаптер, домен не трогается.
Обратите внимание: зависимость идёт от SmtpNotificationAdapter к NotificationPort (который живёт рядом с доменом), а не наоборот. Направление зависимостей перевёрнуто относительно направления вызова — отсюда название «инверсия».
Коротко
- SRP: у класса одна причина для изменения. Если класс делает «и то, и это» — его пора разделить.
- OCP: новое поведение добавляется новым классом, не правкой существующего. Интерфейс как точка расширения.
- LSP: подкласс или реализация заменяют оригинал без сюрпризов.
UnsupportedOperationExceptionв переопределённом методе — красный флаг. - ISP: интерфейс содержит только то, что нужно конкретному потребителю. Большой интерфейс режется на несколько узких.
- DIP: бизнес-логика зависит от интерфейсов, а не от конкретных классов. Направление зависимостей — к домену, не от него.
Принципы работают вместе: класс с одной ответственностью (SRP) зависит от узкого интерфейса (ISP), объявленного в домене (DIP), реализации которого взаимозаменяемы (LSP), а новые варианты поведения добавляются новыми классами (OCP).
Что почитать дальше
- Паттерны GoF — конкретные приёмы, которые реализуют идеи SOLID на практике.
- GRASP на примерах — принципы распределения ответственности между классами.
- Гексагональная архитектура — DIP и ISP, доведённые до структуры модулей.