Книге «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 | Не писать руками — это работа контейнера |
| Prototype | Scope prototype, ObjectProvider | Редко; чаще локальная переменная |
| Factory Method | @Bean-методы, FactoryBean<T> | Да — статические фабрики агрегатов и VO |
| Abstract Factory | BeanFactory/ApplicationContext | Не нужен — конфигурации собирает @Profile |
| Builder | UriComponentsBuilder, RestClient.builder(), HttpSecurity | Да, через Lombok @Builder |
| Adapter | HandlerAdapter, MessageListenerAdapter | Да — out-adapter-ы в hexagonal |
| Bridge | Resource/ResourceLoader, spring-jcl | Почти никогда; «интерфейс + DI» покрывает |
| Composite | Composite*: PropertySource, CacheManager, HealthContributor | Да — составные порты (рассылка в N каналов) |
| Decorator | Request-wrappers, TransactionAware*Proxy, обёртки из BeanPostProcessor | Да — обёртки репозиториев; сперва проверить готовые аннотации |
| Facade | JdbcTemplate, KafkaTemplate, RestClient | Да — adapter-фасад над чужим SDK |
| Flyweight | Кеши метаданных, ResolvableType | Почти никогда; идею несут immutable VO |
| Proxy | AOP: @Transactional, @Cacheable, @Lazy | Не писать — использовать аннотации и @Aspect |
| Chain of Responsibility | SecurityFilterChain, interceptor-ы | Редко — хватает готовых цепочек фреймворка |
| Command | Runnable + TaskExecutor, @Scheduled | Да — UseCase + Handler, command-сторона CQRS |
| Interpreter | SpEL: @PreAuthorize, @Value("#{...}") | Не изобретать своих языков |
| Iterator | Iterable, Streamable, Page/Slice | Растворён в языке |
| Mediator | ApplicationEventPublisher, DispatcherServlet | Да — события вместо прямых вызовов между контекстами |
| Memento | Savepoint, propagation NESTED | Почти никогда — откат делает транзакция |
| Observer | @EventListener, @TransactionalEventListener | Да — domain events, постоянно |
| State | Spring Statemachine | Да, в облегчённой форме — статусная модель агрегата |
| Strategy | PasswordEncoder, PlatformTransactionManager, AuthenticationProvider | Да — против разрастающихся switch |
| Template Method | OncePerRequestFilter, AbstractRoutingDataSource | Колбэк-вариант; наследование — при расширении фреймворка |
| Visitor | ASM в 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, выросший в методологию.