SOLID — пять принципов проектирования классов, сформулированных Робертом Мартином. Их часто рассказывают на абстрактных «фигурах и прямоугольниках», отчего они кажутся теорией ради теории. На самом деле лучший учебник по SOLID давно лежит в каждом проекте — это сам Spring. Фреймворк построен на этих принципах, и его API читается легко именно поэтому.
Каждый принцип ниже показан дважды: как его воплощает Spring — чтобы узнавать принцип в знакомых API, и как писать свой код — чтобы применять его в сервисе.
SRP — единственная ответственность
У класса должна быть одна причина для изменения.
Как это делает Spring
Посмотрите на метод, который выполняет бизнес-операцию в типичном Spring-сервисе:
@Transactional
@Cacheable("products")
@PreAuthorize("hasRole('MANAGER')")
public Product findProduct(ProductId id) {
return productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
В теле метода — только бизнес-логика. Управление транзакцией, кеширование, авторизация — отдельные ответственности, вынесенные в инфраструктуру через AOP-прокси. Без этого каждый метод начинался бы с connection.setAutoCommit(false) и if (!currentUser.hasRole(...)) — три причины для изменения в одном месте.
Тот же принцип — в архитектуре Spring MVC: DispatcherServlet не делает почти ничего сам, а делегирует HandlerMapping (найти обработчик), HandlerAdapter (вызвать его), HttpMessageConverter (сериализовать ответ), HandlerExceptionResolver (обработать ошибку). Каждый интерфейс — одна ответственность, и каждую можно заменить независимо.
Как писать свой код
Главный нарушитель SRP в Spring-проектах — толстый @Service:
@Service
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 строк */ }
}
У такого класса десяток причин для изменения: меняется правило создания заказа — правим его, меняется формат экспорта — снова его. Конфликты при слиянии веток, тесты на сотни строк, страх что-то задеть.
Лекарство — один класс на одну бизнес-операцию. В Use Case Pattern это правило встроено в саму структуру: одна команда = один UseCase = один Handler:
@Component
@RequiredArgsConstructor
public class CreateOrderUseCaseHandler implements UseCaseHandler<OrderDto, CreateOrderUseCase> {
private final OrderRepository orderRepository;
private final PricingPolicy pricingPolicy;
@Override
@Transactional
public OrderDto handle(CreateOrderUseCase useCase) {
var order = Order.create(useCase.customerId(), useCase.lines(), pricingPolicy);
orderRepository.save(order);
return OrderDto.from(order);
}
}
Причина для изменения у этого класса одна — изменилось правило создания заказа. Отправка письма покупателю — отдельная ответственность: она уезжает в listener на domain event, а не дописывается в конец handle.
OCP — открыт для расширения, закрыт для изменения
Поведение системы расширяется добавлением кода, а не правкой существующего.
Как это делает Spring
Spring расширяется в сотнях точек, и ни одна не требует править код фреймворка:
BeanPostProcessor— вмешаться в создание любого бина (так работают@Autowired, AOP-прокси,@Validated).Converter<S, T>— научить MVC принимать свой тип в@PathVariable.HandlerMethodArgumentResolver— свой тип аргумента контроллера.WebMvcConfigurer,ObjectMapper-кастомизаторы,HealthIndicator, фильтры вSecurityFilterChain— всюду одна схема: фреймворк объявляет интерфейс, вы добавляете реализацию-бин, контейнер её подхватывает.
@Component
public class OrderIdConverter implements Converter<String, OrderId> {
@Override
public OrderId convert(String source) {
return new OrderId(UUID.fromString(source));
}
}
Ни одной строки Spring при этом не изменилось — поведение расширено добавлением класса.
Как писать свой код
Симптом нарушения 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 по тому же признаку. Решение то же, что у Spring: объявить точку расширения и собирать реализации через контейнер:
public interface DiscountPolicy {
boolean supports(Customer customer);
BigDecimal discount(Order order);
}
@Component
@RequiredArgsConstructor
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);
}
}
Spring инжектирует в List<DiscountPolicy> все бины-реализации. Новая скидка — новый класс @Component, существующий код не трогается. Когда вариантов два и новых не предвидится — switch честнее, OCP не повод плодить интерфейсы на всякий случай.
LSP — подстановка Лисков
Реализация должна быть взаимозаменяема с любой другой реализацией того же контракта — без сюрпризов для вызывающего кода.
Как это делает Spring
PlatformTransactionManager — эталон LSP. DataSourceTransactionManager, JpaTransactionManager, JtaTransactionManager — у всех одинаковая семантика getTransaction/commit/rollback. Поэтому @Transactional-код не знает и не хочет знать, какой именно менеджер под ним: замена JPA на jOOQ не меняет ни строки бизнес-кода.
Вторая иллюстрация — иерархия DataAccessException. Spring переводит исключения JDBC, JPA, Hibernate в единую иерархию: DuplicateKeyException означает одно и то же поверх любой технологии. Любой репозиторий можно подставить вместо другого, и обработка ошибок не сломается — это и есть «без сюрпризов».
Как писать свой код
Типичное нарушение — наследование ради переиспользования с ломкой контракта:
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("кеш не поддерживает удаление");
}
}
Класс объявляет себя ProductRepository, но delete бросает исключение, а findById может вернуть устаревший объект — код, работавший с базовым классом, со «специализированным» ломается. Это нарушение LSP в чистом виде: подтип сузил контракт.
Правильная форма — композиция, decorator с честным соблюдением контракта:
@Component
@Primary
@RequiredArgsConstructor
public class CachingProductRepository implements ProductRepository {
private final JpaProductRepository 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);
}
}
Отдельная ловушка LSP в Spring — наследование классов с @Transactional и AOP: прокси не перехватывает final-методы и self-invocation, поэтому переопределённый метод может молча потерять транзакционную семантику родителя. Подробно — в статье про @Transactional.
ISP — разделение интерфейсов
Клиент не должен зависеть от методов, которые не использует.
Как это делает Spring
Иерархия Spring Data — учебная картинка ISP:
public interface Repository<T, ID> {}
public interface CrudRepository<T, ID> extends Repository<T, ID> { /* save, findById, delete */ }
public interface PagingAndSortingRepository<T, ID> extends Repository<T, ID> { /* findAll(Pageable) */ }
public interface JpaRepository<T, ID> extends CrudRepository<T, ID>, PagingAndSortingRepository<T, ID> { /* flush, batch */ }
Репозиторий, которому нужны только CRUD-операции, расширяет CrudRepository и не таскает за собой flush и пагинацию. По той же причине у Spring десяток узких Aware-интерфейсов (BeanNameAware, EnvironmentAware, ApplicationContextAware) вместо одного SpringAware с десятью методами: бин объявляет ровно ту зависимость от контейнера, которая ему нужна.
Как писать свой код
Антипод ISP — интерфейс-«комбайн», в который годами дописывали методы:
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);
}
Handler команды использует два метода из пяти, но зависит от всех: любое изменение сигнатуры экспорта пересобирает и его. Mock такого интерфейса в тестах — простыня ненужных заглушек.
Режем по потребителям, а не по таблице:
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);
}
Командная сторона зависит от OrderRepository, читающая — от OrderViewRepository; одна реализация вправе имплементировать оба. Это то же разделение, что в CQRS, и тот же принцип, по которому в гексагональной архитектуре порты объявляются узкими, по потребителю.
DIP — инверсия зависимостей
Модули верхнего уровня не зависят от модулей нижнего уровня. Оба зависят от абстракций.
Как это делает Spring
DIP — причина существования Spring. Весь контейнер — механизм, который позволяет коду зависеть от интерфейса, а реализацию получать снаружи:
@Component
@RequiredArgsConstructor
public class CancelOrderUseCaseHandler implements UseCaseHandler<Void, CancelOrderUseCase> {
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
}
Handler не знает, что OrderRepository реализован на jOOQ, а PaymentGateway ходит по HTTP, — он зависит от абстракций, контейнер подставляет реализации. Конструкторная инжекция здесь принципиальна: зависимости видны в сигнатуре, объект не существует в полусобранном виде, в unit-тесте он создаётся new без контейнера. Инжекция в поле через @Autowired прячет зависимости и приучает классы разрастаться — десятый параметр конструктора колет глаза, десятое поле — нет.
Как писать свой код
Нарушение DIP — когда направление зависимости совпадает с направлением вызова: домен напрямую зависит от инфраструктуры.
public class Order {
public void cancel(SmtpMailSender mailSender) {
this.status = Status.CANCELLED;
mailSender.send(customer.email(), "Заказ отменён");
}
}
Доменная модель знает про SMTP — теперь её нельзя ни протестировать без почты, ни переключить на push-уведомления без правки домена. Инверсия: домен объявляет порт, инфраструктура его реализует.
public interface NotificationPort {
void orderCancelled(Order order);
}
@Component
@RequiredArgsConstructor
public class SmtpNotificationAdapter implements NotificationPort {
private final JavaMailSender mailSender;
@Override
public void orderCancelled(Order order) {
mailSender.send(buildMessage(order));
}
}
Интерфейс NotificationPort лежит рядом с доменом и говорит на его языке (orderCancelled, не sendEmail); адаптер лежит в инфраструктурном слое и зависит от домена, а не наоборот. Доведённая до конца, эта идея становится гексагональной архитектурой, где направление зависимостей контролируется структурой модулей и ArchUnit-тестами.
Принципы вместе
| Принцип | Где в Spring | Как применять у себя |
|---|---|---|
| SRP | @Transactional/@Cacheable выносят ответственности из метода; DispatcherServlet делегирует узким интерфейсам | Один UseCase = один Handler; побочные действия — в listener-ы domain event-ов |
| OCP | BeanPostProcessor, Converter, HandlerMethodArgumentResolver — расширение без правки фреймворка | Стратегии через инжекцию List<T> вместо разрастающихся switch |
| LSP | Реализации PlatformTransactionManager взаимозаменяемы; единая иерархия DataAccessException | Decorator через композицию вместо наследования с ломкой контракта |
| ISP | CrudRepository → JpaRepository; узкие Aware-интерфейсы | Интерфейсы по потребителю: OrderRepository для команд, OrderViewRepository для чтения |
| DIP | Сам DI-контейнер; конструкторная инжекция интерфейсов | Домен объявляет порты, инфраструктура реализует; зависимости направлены к домену |
Принципы не существуют поодиночке: Handler с одной ответственностью (SRP) зависит от узкого порта (ISP), объявленного в домене (DIP), реализации которого взаимозаменяемы (LSP), а новые варианты поведения добавляются классами, не правками (OCP). Spring даёт механику — контейнер, прокси, точки расширения; принципы определяют, как этой механикой пользоваться.
Что почитать дальше
- Паттерны GoF в Spring — все 23 паттерна тем же методом: где в Spring и как у себя.
- GRASP на примерах Spring — кому отдать ответственность, которую SOLID велит держать единственной.
- DI/IoC, bean scopes — механика контейнера, на которой держится DIP.
- Spring AOP — как
@Transactionalи@Cacheableвыносят ответственности (SRP) и где прокси ломает ожидания (LSP). - Гексагональная архитектура — DIP и ISP, доведённые до структуры модулей.
- Use Case Pattern — методология, в которой SRP встроен в структуру кода.