← назад к разделу

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-ов
OCPBeanPostProcessor, Converter, HandlerMethodArgumentResolver — расширение без правки фреймворкаСтратегии через инжекцию List<T> вместо разрастающихся switch
LSPРеализации PlatformTransactionManager взаимозаменяемы; единая иерархия DataAccessExceptionDecorator через композицию вместо наследования с ломкой контракта
ISPCrudRepositoryJpaRepository; узкие 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 встроен в структуру кода.