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

Книге «Design Patterns» банды четырёх — больше тридцати лет, и читать её сегодня как каталог рецептов уже не нужно: половина паттернов растворилась в языке и фреймворках. Но узнавать их по-прежнему необходимо — Spring разговаривает именно этим словарём. HandlerAdapter, CompositeHealthContributor, DelegatingPasswordEncoder, AbstractRoutingDataSource — это не случайные имена, это паттерны GoF, вынесенные в названия классов.

Ниже — все 23 паттерна по классическим группам. Для каждого: суть в одну строку, где он живёт в Spring и нужен ли он в прикладном коде. Честная оценка важнее полноты каталога: часть паттернов вы используете каждый день, не замечая; часть стоит писать руками; а часть в Java 21 заменена средствами языка.

Порождающие

Singleton

Один экземпляр на всё приложение, единая точка доступа.

Где в Spring. Дефолтный scope каждого бина. Но это контейнерный синглтон, а не GoF-синглтон со static getInstance(): один экземпляр на ApplicationContext, а не на classloader. Разница принципиальна — контейнерный синглтон внедряется через конструктор, подменяется в тестах и не тащит глобальное состояние:

@Service
public class PricingService {
}

@Test
void calculatesPrice() {
    var service = new PricingService();
}

GoF-вариант со static-полем не позволил бы ни того, ни другого.

У себя. Никогда не писать руками private static final INSTANCE для сервисов — это работа контейнера. Единственное законное применение static-синглтона — константы-объекты (Comparator, ObjectMapper в утилитном коде вне контейнера). И помнить главное следствие: singleton-бин разделяется всеми потоками, мутабельное поле в нём — гонка.

Prototype

Новый экземпляр при каждом запросе объекта.

Где в Spring. Scope prototype: каждый getBean() — новый объект. Ловушка — инжекция prototype в singleton: внедрение происходит один раз, и «прототип» застывает. Решение — ObjectProvider:

@Component
@RequiredArgsConstructor
public class ReportController {

    private final ObjectProvider<ReportBuilder> reportBuilders;

    @PostMapping("/reports")
    public ReportDto create(@RequestBody ReportRequest request) {
        var builder = reportBuilders.getObject();
        return builder.with(request).build();
    }
}

У себя. Нужен редко: stateless-сервисы покрывают большинство задач. Если объект накапливает состояние в рамках одной операции — чаще это просто локальная переменная (new ReportBuilder()), а не бин.

Factory Method

Создание объекта делегируется методу, скрывающему конкретный класс.

Где в Spring. Каждый @Bean-метод — фабричный метод: вызывающий код знает интерфейс, метод решает, какую реализацию собрать:

@Configuration
public class ClockConfig {

    @Bean
    @Profile("!integration-test")
    Clock clock() {
        return Clock.systemUTC();
    }

    @Bean
    @Profile("integration-test")
    Clock fixedClock() {
        return Clock.fixed(Instant.parse("2026-01-15T10:00:00Z"), ZoneOffset.UTC);
    }
}

Вторая форма — FactoryBean<T>: бин, чья работа — производить другой бин (SqlSessionFactoryBean в MyBatis, JndiObjectFactoryBean). Контейнер регистрирует не фабрику, а её продукт.

У себя. @Bean-методы в @Configuration — это и есть ваш Factory Method, отдельный паттерн городить не нужно. Статические фабричные методы на доменных типах (Order.create(...), Money.of(...)) — тоже он, и это предпочтительный способ создавать агрегаты и value objects: имя выражает намерение, внутри — валидация инвариантов.

Abstract Factory

Фабрика семейства связанных объектов, скрывающая их конкретные классы.

Где в Spring. BeanFactory — буквально абстрактная фабрика всего приложения: по типу или имени выдаёт готовые объекты, скрывая, какой класс, как сконфигурирован и в каком scope:

var transactionManager = beanFactory.getBean(PlatformTransactionManager.class);

Каким он будет — JpaTransactionManager или DataSourceTransactionManager — решает конфигурация, не вызывающий код.

У себя. Прямой вызов getBean() в прикладном коде — антипаттерн (service locator вместо инжекции). Руками Abstract Factory пишут редко — обычно достаточно Factory Method; семейства объектов под разные конфигурации собирает @Profile/@Conditional.

Builder

Пошаговая сборка сложного объекта с читаемым DSL.

Где в Spring. Повсюду, это самый видимый паттерн фреймворка:

var uri = UriComponentsBuilder.fromUriString("https://api.example.com/orders")
    .queryParam("status", "PAID")
    .queryParam("page", 2)
    .build()
    .toUri();

var client = RestClient.builder()
    .baseUrl("https://payment.example.com")
    .defaultHeader("X-Api-Version", "2")
    .build();

HttpSecurity в Spring Security — тоже builder, только мутирующий: цепочка http.authorizeHttpRequests(...).oauth2ResourceServer(...) собирает SecurityFilterChain.

У себя. Для своих типов builder не пишут руками — его генерирует Lombok @Builder. Уместен на объектах с тремя и больше необязательными полями (тестовые генераторы данных, конфигурационные объекты). Для агрегатов предпочтительнее статический фабричный метод с обязательными аргументами: builder позволяет «забыть» поле, фабричный метод — нет.

Структурные

Adapter

Преобразует интерфейс класса в интерфейс, который ожидает клиент.

Где в Spring. DispatcherServlet не знает, чем написан ваш обработчик — @RequestMapping-методом, функциональным эндпоинтом или старым HttpRequestHandler. Он работает с единым HandlerAdapter, а адаптеры приводят каждый стиль к общему контракту:

public interface HandlerAdapter {
    boolean supports(Object handler);
    ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler);
}

Та же роль у MessageListenerAdapter в JMS/AMQP: ваш POJO-метод адаптируется к интерфейсу слушателя брокера.

У себя. Это хлеб гексагональной архитектуры: out-adapter переводит доменный порт в язык внешнего SDK.

@Component
@RequiredArgsConstructor
public class S3DocumentStorageAdapter implements DocumentStoragePort {

    private final S3Client s3Client;

    @Override
    public DocumentRef store(Document document) {
        var key = document.id().value().toString();
        s3Client.putObject(b -> b.bucket("documents").key(key),
            RequestBody.fromBytes(document.content()));
        return new DocumentRef(key);
    }
}

Домен знает DocumentStoragePort, адаптер знает AWS SDK, и менять одно без другого можно свободно.

Bridge

Абстракция и реализация развиваются в независимых иерархиях.

Где в Spring. Resource/ResourceLoader: одна абстракция «ресурс», независимые реализации под classpath:, file:, https:, и код, читающий ресурс, не меняется при смене источника. Второй пример — spring-jcl: API логирования отделён от бэкенда, под ним может оказаться Logback или Log4j2.

У себя. В чистом виде почти не встречается: его место заняла комбинация «интерфейс + DI». Когда у вас порт с несколькими реализациями и DTO-маппер между ними — вы уже получили эффект Bridge, не называя его. Специально проектировать две параллельные иерархии стоит только в библиотечном коде.

Composite

Группа объектов используется так же, как один объект.

Где в Spring. Узнаётся по префиксу Composite: CompositePropertySource (несколько источников свойств выглядят как один), CompositeCacheManager, CompositeHealthContributor в Actuator, CompositeMeterRegistry в Micrometer — метрики пишутся «в один реестр», а уходят в несколько систем сразу.

У себя. Полезен, когда потребитель не должен знать, один получатель или много:

@Component
@Primary
@RequiredArgsConstructor
public class CompositeNotificationAdapter implements NotificationPort {

    private final List<NotificationPort> channels;

    @Override
    public void orderCancelled(Order order) {
        channels.forEach(channel -> channel.orderCancelled(order));
    }
}

Handler по-прежнему зависит от одного NotificationPort, а уведомление уходит и в почту, и в push.

Decorator

Объект оборачивается в обёртку с тем же интерфейсом, добавляющую поведение.

Где в Spring. ContentCachingRequestWrapper — тот же HttpServletRequest, но с буферизацией тела для повторного чтения. TransactionAwareDataSourceProxy — тот же DataSource, но выдающий соединение текущей транзакции. DelegatingSecurityContextExecutor — тот же Executor, но переносящий SecurityContext в чужой поток. Сам BeanPostProcessor — фабрика декораторов: AOP-прокси, который он возвращает вместо бина, и есть обёртка с дополнительным поведением.

У себя. Главная альтернатива наследованию — и единственный способ добавить поведение, не нарушив LSP:

@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)));
    }
}

Прежде чем писать такой декоратор для транзакций, метрик или кеша — проверьте, нет ли готовой аннотации: @Transactional, @Cacheable, @Timed уже декорируют ваши бины через прокси.

Facade

Простой интерфейс над сложной подсистемой.

Где в Spring. Все *Template и *Client: JdbcTemplate прячет Connection/PreparedStatement/ResultSet/SQLException и протокол их закрытия, KafkaTemplate — producer-API Kafka, RestClient — HTTP-клиент с конвертацией тел. Пять строк вместо тридцати, и невозможно забыть закрыть ресурс:

var titles = jdbcTemplate.queryForList(
    "select title from product where category = ?", String.class, "books");

У себя. Фасад над чужим SDK — нормальная форма out-adapter-а: один метод порта может скрывать три вызова стороннего API, retry и маппинг ошибок. Фасад над своим кодом — сигнал тревоги: если для использования собственного модуля нужен упрощающий слой, у модуля плохой интерфейс.

Flyweight

Разделяемые неизменяемые объекты вместо тысяч одинаковых экземпляров.

Где в Spring. Внутри, в инфраструктуре: кеши ResolvableType, метаданных аннотаций, отражённых методов — Spring разбирает аннотации класса один раз и переиспользует результат. Из JDK сюда же — кеш Integer.valueOf и интернирование строк.

У себя. Руками почти никогда. Современный аналог той же идеи — неизменяемые value objects и enum-константы: Currency.RUB один на приложение именно потому, что неизменяем. Если профилировщик не показал миллионы одинаковых объектов — Flyweight вам не нужен.

Proxy

Объект-заместитель контролирует доступ к реальному объекту.

Где в Spring. Паттерн номер один всего фреймворка. JDK dynamic proxy и CGLIB — механизм, на котором работают @Transactional, @Cacheable, @Async, @PreAuthorize, @Lazy, scoped proxies. Аннотация на методе — это инструкция контейнеру: «заверни бин в прокси, который перед вызовом откроет транзакцию»:

@Service
public class TransferService {

    @Transactional
    public void transfer(AccountId from, AccountId to, Money amount) {
    }
}

Вызывающий код получает не TransferService, а прокси с тем же интерфейсом. Отсюда и все знаменитые ловушки: self-invocation идёт мимо прокси, final-методы CGLIB не перехватывает — подробно в статье про AOP.

У себя. Не писать: Proxy.newProxyInstance в прикладном коде означает, что вы строите свой маленький фреймворк. Всё, что нужно от паттерна, даёт @Aspect или готовые аннотации.

Поведенческие

Chain of Responsibility

Запрос идёт по цепочке обработчиков, пока кто-то его не обработает.

Где в Spring. SecurityFilterChain — эталон: запрос проходит фильтры аутентификации, CSRF, авторизации, и любой может остановить цепочку, вернув 401. Та же механика — HandlerInterceptor в MVC, ClientHttpRequestInterceptor в RestClient, цепочка HandlerExceptionResolver-ов, где исключение обрабатывает первый, кто умеет.

@Component
public class TraceIdFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        MDC.put("traceId", resolveTraceId(request));
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.remove("traceId");
        }
    }
}

У себя. Свою цепочку строить стоит редко — обычно хватает готовых точек (Filter, interceptor) или простого списка стратегий, где порядок задан @Order. Полноценный Chain of Responsibility оправдан, когда обработчики должны уметь прерывать прохождение — как фильтры безопасности.

Command

Операция упакована в объект: её можно передать, отложить, поставить в очередь.

Где в Spring. Runnable/Callable, уходящие в TaskExecutor, задачи @Scheduled, @Async-методы — везде операция стала объектом, который исполняется не там и не тогда, где создан.

У себя. Это структурная основа Use Case Pattern: UseCase — объект-команда, несущий данные операции, Handler — её исполнитель. Разделение позволяет диспетчеризовать, логировать и тестировать операции единообразно:

public record CancelOrderUseCase(OrderId orderId, CancelReason reason) implements UseCase<Void> {}

@Component
@RequiredArgsConstructor
public class CancelOrderUseCaseHandler implements UseCaseHandler<Void, CancelOrderUseCase> {

    private final OrderRepository orderRepository;

    @Override
    @Transactional
    public Void handle(CancelOrderUseCase useCase) {
        var order = orderRepository.findById(useCase.orderId()).orElseThrow();
        order.cancel(useCase.reason());
        orderRepository.save(order);
        return null;
    }
}

Command-сторона CQRS — тот же паттерн, выросший до архитектурного уровня.

Interpreter

Язык с грамматикой и интерпретатор выражений на нём.

Где в Spring. SpEL — полноценный интерпретатор внутри фреймворка:

@PreAuthorize("hasRole('MANAGER') or #order.customerId == authentication.name")
public OrderDto getOrder(@P("order") OrderRef order) { ... }

@Value("#{${app.retry.max-attempts} * 2}")
private int maxRetries;

Выражения в @Cacheable(key = "..."), @EventListener(condition = "..."), маршрутах Spring Integration — всё это SpEL.

У себя. Не изобретать собственные мини-языки: строковые выражения не проверяются компилятором, ломаются при рефакторинге и усложняют отладку. Даже SpEL стоит держать тривиальным; сложное условие авторизации лучше вынести в именованный метод @Component("access")-бина.

Iterator

Последовательный доступ к элементам без раскрытия внутренней структуры.

Где в Spring. Паттерн давно растворился в языке: Iterable/Iterator, for-each, Stream. Spring добавляет своё: Streamable в Spring Data, постраничные Page/Slice, потоковый Stream<T> из репозитория для больших выборок без загрузки всего в память.

У себя. Реализовывать Iterator руками не приходится. Единственное близкое решение — отдавать из доменных коллекций неизменяемые представления (List.copyOf), чтобы внутренняя структура агрегата не утекала наружу.

Mediator

Объекты общаются через посредника, не зная друг о друге.

Где в Spring. ApplicationEventPublisher: бин публикует событие, слушатели подписываются, и никто ни с кем не связан напрямую. DispatcherServlet — медиатор MVC: HandlerMapping, HandlerAdapter, ViewResolver взаимодействуют только через него и не знают о существовании друг друга.

У себя. Связь между агрегатами и bounded context-ами — через события, а не прямые вызовы: OrderCancelled публикуется, слушатель в другом контексте резервирует возврат. Прямой вызов refundService.refund(...) из Order связал бы контексты намертво. Подробнее — Spring Events.

Memento

Снимок состояния объекта для последующего отката.

Где в Spring. Savepoint в транзакциях: TransactionStatus.createSavepoint() фиксирует точку, rollbackToSavepoint() откатывает к ней — снимок и восстановление в чистом виде. Это же делает propagation NESTED.

У себя. Почти никогда: в серверном мире откат состояния — работа транзакции БД, история изменений — event sourcing или audit-таблица. Памятку руками пишут в редакторах с undo, не в REST-сервисах.

Observer

Подписчики получают уведомления об изменении состояния издателя.

Где в Spring. ApplicationEvent + @EventListener + @TransactionalEventListener — встроенная шина наблюдателей с привязкой к фазам транзакции:

@Component
public class OrderNotificationListener {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void on(OrderCancelled event) {
        notificationPort.orderCancelled(event.orderId());
    }
}

У себя. Самый используемый поведенческий паттерн: domain events — стандартный способ отделить побочные действия (письмо, метрика, outbox-запись) от бизнес-операции. Главное правило — слушатель с внешними эффектами вешается на AFTER_COMMIT, иначе письмо уйдёт по откаченной транзакции.

State

Поведение объекта меняется при смене его внутреннего состояния.

Где в Spring. В ядре фреймворка почти не виден; для сложных машин состояний существует отдельный проект Spring Statemachine.

У себя. Живёт в каждом агрегате со статусной моделью — но в облегчённой форме: не отдельный класс на состояние, как в GoF, а методы агрегата, проверяющие допустимость перехода:

public class Order {

    public void cancel(CancelReason reason) {
        if (status != Status.PAID && status != Status.CREATED) {
            throw new IllegalOrderStateException(id, status, "cancel");
        }
        this.status = Status.CANCELLED;
        registerEvent(new OrderCancelled(id, reason));
    }
}

Классический State с классом-на-состояние оправдан при больших машинах (десятки состояний, поведение различается радикально); для типичного агрегата достаточно enum + проверок переходов.

Strategy

Семейство алгоритмов за общим интерфейсом, выбираемых на лету.

Где в Spring. Самый частый паттерн после Proxy. PasswordEncoder и его DelegatingPasswordEncoder (выбирает алгоритм по префиксу {bcrypt} в хеше), PlatformTransactionManager, AuthenticationProvider, ContentNegotiationStrategy, LoadBalancer — всюду интерфейс с подменяемыми реализациями:

@Bean
PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

У себя. Основной инструмент против разрастающихся switch — стратегии, собираемые контейнером в List<T> или Map. Развёрнутый пример с DiscountPolicy — в статье про SOLID, это паттерн-воплощение OCP.

Template Method

Скелет алгоритма в базовом классе, переопределяемые шаги — в наследниках.

Где в Spring. OncePerRequestFilter: базовый класс гарантирует «один раз на запрос», вы переопределяете только doFilterInternal. AbstractRoutingDataSource: вся механика маршрутизации готова, ваш код — один метод determineCurrentLookupKey(). AbstractApplicationContext.refresh() — двенадцать шагов старта контейнера с хуком onRefresh() для наследников.

public class TenantRoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return TenantContext.currentTenant();
    }
}

JdbcTemplate — близкий родственник: скелет алгоритма тот же (открыть ресурс → выполнить → закрыть), но изменяемый шаг передаётся лямбдой-колбэком, а не наследником. Это «Template Method через композицию» — современный вариант паттерна.

У себя. Предпочитайте колбэк-вариант: лямбда вместо наследника не строит хрупкую иерархию. Классический вариант с abstract-методом оправдан, когда расширяете сам Spring (фильтры, базовые классы фреймворка ждут наследования).

Visitor

Новая операция над структурой объектов без изменения её классов.

Где в Spring. Component scanning читает классы не через рефлексию, а байткод-ридером ASM, построенным на visitor-ах: ClassVisitor/MethodVisitor обходят структуру class-файла. BeanDefinitionVisitor обходит определения бинов, резолвя плейсхолдеры в свойствах.

У себя. Двойная диспетчеризация громоздка, и в Java 21 её вытеснил pattern matching:

public sealed interface PaymentMethod permits Card, Sbp, Cash {}

public BigDecimal fee(PaymentMethod method) {
    return switch (method) {
        case Card card -> card.amount().multiply(new BigDecimal("0.02"));
        case Sbp sbp -> BigDecimal.ZERO;
        case Cash cash -> new BigDecimal("50");
    };
}

sealed + switch даёт то же, ради чего существовал Visitor — компилятор заставит обработать новый вариант, — без интерфейсов accept/visit. Классический Visitor остался в библиотеках, обходящих деревья (парсеры, AST).

Все 23 паттерна: сводка

ПаттернГде в SpringВ прикладном коде
SingletonДефолтный bean scopeНе писать руками — это работа контейнера
PrototypeScope prototype, ObjectProviderРедко; чаще локальная переменная
Factory Method@Bean-методы, FactoryBean<T>Да — статические фабрики агрегатов и VO
Abstract FactoryBeanFactory/ApplicationContextНе нужен — конфигурации собирает @Profile
BuilderUriComponentsBuilder, RestClient.builder(), HttpSecurityДа, через Lombok @Builder
AdapterHandlerAdapter, MessageListenerAdapterДа — out-adapter-ы в hexagonal
BridgeResource/ResourceLoader, spring-jclПочти никогда; «интерфейс + DI» покрывает
CompositeComposite*: PropertySource, CacheManager, HealthContributorДа — составные порты (рассылка в N каналов)
DecoratorRequest-wrappers, TransactionAware*Proxy, обёртки из BeanPostProcessorДа — обёртки репозиториев; сперва проверить готовые аннотации
FacadeJdbcTemplate, KafkaTemplate, RestClientДа — adapter-фасад над чужим SDK
FlyweightКеши метаданных, ResolvableTypeПочти никогда; идею несут immutable VO
ProxyAOP: @Transactional, @Cacheable, @LazyНе писать — использовать аннотации и @Aspect
Chain of ResponsibilitySecurityFilterChain, interceptor-ыРедко — хватает готовых цепочек фреймворка
CommandRunnable + TaskExecutor, @ScheduledДа — UseCase + Handler, command-сторона CQRS
InterpreterSpEL: @PreAuthorize, @Value("#{...}")Не изобретать своих языков
IteratorIterable, Streamable, Page/SliceРастворён в языке
MediatorApplicationEventPublisher, DispatcherServletДа — события вместо прямых вызовов между контекстами
MementoSavepoint, propagation NESTEDПочти никогда — откат делает транзакция
Observer@EventListener, @TransactionalEventListenerДа — domain events, постоянно
StateSpring StatemachineДа, в облегчённой форме — статусная модель агрегата
StrategyPasswordEncoder, PlatformTransactionManager, AuthenticationProviderДа — против разрастающихся switch
Template MethodOncePerRequestFilter, AbstractRoutingDataSourceКолбэк-вариант; наследование — при расширении фреймворка
VisitorASM в component scanning, BeanDefinitionVisitorВытеснен sealed + pattern matching

Итог по-честному: из 23 паттернов руками регулярно пишут шесть-семь (Adapter, Strategy, Observer, Command, Decorator, Composite, Factory Method), ещё столько же используют каждый день в готовом виде, не замечая (Proxy, Singleton, Builder, Facade, Template Method, Chain of Responsibility), остальные — словарь для чтения чужого кода и понимания, почему классы Spring называются так, как называются.

Что почитать дальше

  • SOLID на примерах Spring — принципы, ради которых эти паттерны существуют.
  • GRASP на примерах Spring — какому классу отдать ответственность, прежде чем выбирать паттерн.
  • Spring AOP — как устроен Proxy, паттерн номер один фреймворка.
  • DI/IoC, bean scopes — Singleton и Prototype как scope-ы контейнера.
  • Spring Events — Observer и Mediator в работе.
  • Use Case Pattern — Command, выросший в методологию.