Книге «Design Patterns» банды четырёх уже больше тридцати лет, но читать её как готовый каталог рецептов сегодня не нужно — половина паттернов давно растворилась в языке и фреймворках. Зачем тогда учить? Потому что Spring, Hibernate, любой HTTP-фреймворк разговаривают именно этим словарём. HandlerAdapter, CompositeHealthContributor, DelegatingPasswordEncoder — это не случайные имена, это паттерны GoF в названиях классов. Не зная паттерн, трудно понять, почему класс устроен так, а не иначе.
Ниже — все 23 паттерна по классическим группам. Для каждого: суть в одном предложении, где он уже живёт в готовых инструментах и нужно ли его писать самому.
Три самых частых паттерна — один и тот же приём: вызывающий код держится за интерфейс, а за интерфейсом стоит либо одна реализация из нескольких, либо цепочка обёрток, либо сразу несколько получателей.
Порождающие паттерны
Эта группа отвечает на один вопрос: как правильно создавать объекты?
Singleton
Если создавать объект в каждом месте, где он нужен, получается несколько несвязанных копий с разным состоянием. Singleton решает это: объект существует в одном экземпляре, и все обращаются к одному и тому же.
Суть: один экземпляр на всё приложение.
В Spring синглтон реализуется через контейнер — каждый @Service-бин по умолчанию создаётся в одном экземпляре. Это лучше классического GoF-варианта со static getInstance(): контейнерный синглтон внедряется через конструктор и легко подменяется в тестах.
Главное следствие: один экземпляр обслуживает все запросы параллельно, поэтому в singleton-бине не должно быть разделяемого состояния, которое не готово к параллельному доступу. Обычное поле, которое меняют запросы, — гонка. А вот AtomicLong-счётчик, ConcurrentHashMap-кеш или MeterRegistry живут в singleton-бинах постоянно и ничего не ломают: они рассчитаны на несколько потоков сразу.
@Service
public class PricingService {
// один экземпляр на весь контекст приложения
public int totalOf(int price, int quantity) {
return price * quantity;
}
}
@Test
void calculatesPrice() {
var service = new PricingService(); // в тесте создаём через new — просто
assertThat(service.totalOf(500, 3)).isEqualTo(1500);
}
Prototype
Бывает, что собрать объект дорого: длинная настройка, поход в базу за справочниками, разбор большого шаблона. Prototype предлагает собрать один образец и дальше снимать с него копии, меняя в копии только то, что отличается. В Java это копирующий конструктор, withX-методы у неизменяемых типов, clone() у Cloneable, toBuilder() от Lombok:
Суть: новый объект получают копированием готового образца, а не сборкой с нуля.
record ReportSettings(String period, List<String> columns, boolean withTotals) {
ReportSettings withPeriod(String other) {
return new ReportSettings(other, columns, withTotals); // копия образца с одной правкой
}
}
var template = new ReportSettings("2026-01", List.of("orders", "revenue"), true);
var february = template.withPeriod("2026-02");
Отдельно стоит развести два «прототипа»: в Spring есть scope = prototype, но это не паттерн GoF — контейнер не копирует образец, а каждый раз собирает бин заново. Совпало одно имя. Ловушка самого scope'а: если внедрить prototype-бин в singleton-бин обычным образом, внедрение произойдёт один раз при создании singleton'а — и «новизна» потеряется. Решение — ObjectProvider:
@Service
@RequiredArgsConstructor
public class ReportService {
private final ObjectProvider<ReportBuilder> reportBuilders;
public ReportDto create(ReportRequest request) {
var builder = reportBuilders.getObject(); // каждый раз новый экземпляр
return builder.with(request).build();
}
}
ObjectProvider — не единственный способ. @Lookup над абстрактным методом делает то же самое и без лишнего поля: Spring подменяет метод в наследнике и возвращает новый экземпляр на каждый вызов. Ещё один вариант — заместитель по области видимости (@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)): получение спрятано за обычным полем, удобно при чтении, но по коду уже не видно, что за полем стоит подстановка нового экземпляра.
И отдельная ловушка, из-за которой на prototype-бинах текут ресурсы: контейнер их не уничтожает. У singleton при остановке приложения вызывается @PreDestroy, у prototype — нет: контейнер собрал экземпляр, отдал его и забыл. Если внутри такого бина открытый файл, соединение или поток, закрывать придётся самому — обычно try-with-resources на стороне того, кто его попросил.
Factory Method
Вместо того чтобы везде писать new ConcreteClass(...), вызывающий код работает с интерфейсом, а фабричный метод решает, какую реализацию создать.
Суть: создание объекта делегируется методу, скрывающему конкретный класс.
В 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);
}
}
Оговорка про имена: у GoF Factory Method — это метод, который переопределяют наследники, чтобы решить, экземпляр какого класса создать. Статический фабричный метод — отдельный приём (Блох в «Effective Java» их прямо разводит), но задачу он решает ту же: прячет new за осмысленным именем и не пускает наружу недособранный объект. В прикладном коде чаще нужен именно он:
живой пример
import java.util.List;
public class FactoryMethodDemo {
record Order(String id, List<String> lines) {
static Order create(String id, List<String> lines) {
if (lines.isEmpty()) {
throw new IllegalArgumentException("заказ без позиций не бывает");
}
return new Order(id, lines);
}
}
public static void main(String[] args) {
System.out.println(Order.create("A-1", List.of("ноутбук", "мышь")));
try {
Order.create("A-2", List.of());
} catch (IllegalArgumentException e) {
System.out.println("не создан: " + e.getMessage());
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Abstract Factory
Если Factory Method создаёт один объект, Abstract Factory создаёт целую группу объектов, которые должны «сочетаться» друг с другом.
Суть: фабрика, которая создаёт семейство связанных объектов.
Определяющий признак — именно семейство: продукты одной фабрики обязаны сочетаться между собой. В JDBC такая фабрика — Connection: из неё берут Statement, PreparedStatement, Savepoint, и все они принадлежат одному драйверу и одной сессии; смешать объекты от двух соединений нельзя. Так же устроен ConnectionFactory в JMS: Connection → Session → MessageProducer — одна согласованная связка.
В прикладном коде Abstract Factory пишут редко. Его роль берёт на себя @Configuration-класс под профилем: он выдаёт согласованный набор бинов — источник данных, менеджер транзакций, клиент к базе, — которые обязаны смотреть в одну и ту же базу.
Builder
Конструктор с восемью параметрами — источник ошибок: легко перепутать порядок, непонятно, что за что отвечает. Builder даёт именованные методы для каждого поля.
Суть: пошаговая сборка сложного объекта с читаемым кодом.
В Spring Builder используется в HttpSecurity (конфигурация безопасности) и во всех HTTP-клиентах. Lombok генерирует Builder автоматически через @Builder:
@Builder
public record OrderSearchQuery(
@Nullable CustomerId customerId,
@Nullable OrderStatus status,
int page,
int size
) {}
var query = OrderSearchQuery.builder()
.status(OrderStatus.PAID)
.page(0)
.size(20)
.build();
Структурные паттерны
Эта группа отвечает на вопрос: как правильно строить связи между объектами?
Adapter
Представьте две заглушки разной формы: код ожидает один интерфейс, а внешняя библиотека предлагает другой. Adapter — переходник между ними.
Суть: преобразует один интерфейс в другой, которого ожидает клиент.
В Spring DispatcherServlet не знает, чем написан обработчик — @RequestMapping-методом или чем-то другим. Он работает с единым HandlerAdapter, который приводит любой стиль к общему контракту.
В прикладном коде 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);
}
}
Bridge
Bridge узнаётся по двум иерархиям, которые растут порознь. Классический пример — логирование: SLF4J даёт интерфейс Logger, а под ним стоит подключённая реализация — Logback, Log4j2, java.util.logging. Ваш код пишет в один и тот же Logger, движок меняется заменой зависимости, и при этом API растёт своим темпом, а движки — своим. Так же устроен JDBC: Connection, Statement со стороны API и драйвер конкретной базы со стороны реализации.
Суть: абстракция и реализация развиваются независимо.
Обычный полиморфизм — «интерфейс и три реализации» — ещё не Bridge: Bridge начинается там, где независимо меняются обе стороны. В прикладном коде в чистом виде он почти не встречается — его заменяет связка «интерфейс + внедрение зависимости».
Composite
Нужно отправить уведомление одновременно по email и SMS, но вызывающий код не должен знать о деталях. Composite позволяет «завернуть» несколько объектов в один, реализующий тот же интерфейс.
Суть: группа объектов используется так же, как один объект, и внутри группы может лежать другая такая же группа.
В Spring узнаётся по префиксу Composite: CompositePropertySource, CompositeCacheManager, CompositeHealthContributor в Actuator — несколько источников выглядят как один.
живой пример
import java.util.List;
public class CompositeDemo {
interface Notifications {
void orderCancelled(String orderId);
}
static class CompositeNotifications implements Notifications {
private final List<Notifications> channels;
CompositeNotifications(List<Notifications> channels) {
this.channels = channels;
}
@Override
public void orderCancelled(String orderId) {
channels.forEach(channel -> channel.orderCancelled(orderId));
}
}
public static void main(String[] args) {
Notifications urgent = new CompositeNotifications(List.of(
orderId -> System.out.println(" sms: заказ " + orderId + " отменён"),
orderId -> System.out.println(" push: заказ " + orderId + " отменён")));
Notifications all = new CompositeNotifications(List.of(
orderId -> System.out.println("email: заказ " + orderId + " отменён"),
urgent));
all.orderCancelled("A-1");
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Главное в Composite не «несколько вместо одного», а рекурсия: в группу входит и отдельный объект, и другая такая же группа, поэтому дерево любой глубины обходится одним вызовом. В примере выше срочные каналы собраны в свою группу и вложены в общую — вызывающий код по-прежнему делает один вызов и про вложенность ничего не знает.
Так же устроены все классические примеры части и целого: каталог содержит файлы и другие каталоги, пункт меню — пункты и подменю, узел дерева — листья и поддеревья. CompositeHealthContributor в Actuator ровно поэтому и вложенный: в ответе /actuator/health группы проверок лежат друг в друге, а не плоским списком. Обратное правило тоже полезно: если вложенности нет и не предвидится, это не Composite, а просто список получателей — и списка достаточно.
Decorator
Нужно добавить кеширование к репозиторию, но менять его класс нельзя (или не хочется). Decorator создаёт обёртку с тем же интерфейсом, которая перехватывает вызовы и добавляет нужное поведение.
Суть: объект оборачивается в обёртку с тем же интерфейсом, добавляющую поведение.
В Spring это ContentCachingRequestWrapper, TransactionAwareDataSourceProxy, DelegatingSecurityContextExecutor. Устроен Decorator так же, как Proxy — обёртка с тем же интерфейсом, — а различает их то, кто кем распоряжается. Декоратор добавляет обязанности, его собирают снаружи и накладывают слоями: кеш поверх логирования поверх базы. Прокси управляет доступом к одному конкретному объекту, обычно сам его держит, и вызывающий о подмене не догадывается — так работают AOP-прокси @Transactional.
Форма одна, обёртка с тем же интерфейсом, а разница в цели: декораторы кладут слоями, заместитель стоит один и решает, пускать ли вызов.
живой пример
import java.util.HashMap;
import java.util.Map;
public class DecoratorDemo {
interface Products {
String nameOf(long id);
}
static class DatabaseProducts implements Products {
@Override
public String nameOf(long id) {
System.out.println(" идём в базу за " + id);
return "товар-" + id;
}
}
static class CachingProducts implements Products {
private final Products delegate;
private final Map<Long, String> cache = new HashMap<>();
CachingProducts(Products delegate) {
this.delegate = delegate;
}
@Override
public String nameOf(long id) {
return cache.computeIfAbsent(id, delegate::nameOf);
}
}
public static void main(String[] args) {
Products products = new CachingProducts(new DatabaseProducts());
System.out.println(products.nameOf(7));
System.out.println(products.nameOf(7));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Два вызова — одна строка «идём в базу»: обёртка перехватила второй.
Facade
Работа с JDBC напрямую требует: открыть соединение, создать PreparedStatement, выполнить, обработать ResultSet, закрыть всё в правильном порядке. JdbcTemplate скрывает всю эту сложность за одним вызовом.
Суть: простой интерфейс над сложной подсистемой.
В Spring все *Template и *Client — это фасады: JdbcTemplate, KafkaTemplate, RestClient. От Adapter фасад отличается намерением, а не устройством: адаптер приводит чужой интерфейс к тому, который ждёт ваш код — форма задана снаружи; фасад прячет сложность подсистемы за удобным входом — форму придумывает автор фасада. В примере ниже совмещены обе роли: класс реализует ожидаемый доменом PaymentPort (это Adapter) и попутно сворачивает три вызова SDK в один (это Facade).
@Component
@RequiredArgsConstructor
public class PaymentGatewayAdapter implements PaymentPort {
private final PaymentSdkClient sdkClient;
@Override
public PaymentResult charge(Order order, PaymentMethod method) {
var request = sdkClient.newRequest()
.amount(order.total().amount())
.currency(order.total().currency().code())
.method(method.token())
.build();
var response = sdkClient.submit(request);
return PaymentResult.of(response.transactionId(), response.status());
}
}
Flyweight
Если создавать по объекту на каждое слово в тексте, память закончится быстро. Flyweight разделяет объекты с одинаковым содержимым — один экземпляр на значение.
Суть: разделяемые неизменяемые объекты вместо тысяч одинаковых копий.
В Java это кеш Integer.valueOf(-128..127) и интернирование строк. Во фреймворках — внутренние кеши метаданных аннотаций и типов.
В прикладном коде Flyweight почти никогда не пишут руками. Его идею несут неизменяемые value objects: Currency.getInstance("RUB") при каждом вызове отдаёт один и тот же объект на всё приложение — так можно именно потому, что менять в нём нечего.
Proxy
Прокси перехватывает вызовы и делает что-то до или после: открывает транзакцию, проверяет права, кеширует результат.
Суть: объект-заместитель контролирует доступ к реальному объекту.
Это паттерн номер один во всём Spring. JDK dynamic proxy и CGLIB — механизм, на котором работают @Transactional, @Cacheable, @Async, @PreAuthorize. Аннотация на методе — инструкция контейнеру: «заверни бин в прокси».
Отсюда и главная ловушка. Метод с @Transactional вызвали из соседнего метода того же класса через this: заместитель стоит снаружи объекта, вызов изнутри до него не доходит, транзакции не будет, и никакой ошибки вы не увидите, код просто работает без неё. Лечится выносом метода в отдельный бин, который вызывают как зависимость; подробно в статье про AOP. Механику видно на чистой Java: java.lang.reflect.Proxy подменяет объект заместителем с тем же интерфейсом.
Заместитель стоит снаружи объекта: внешний вызов идёт через него, а this.charge внутри объекта проходит мимо, и транзакции у него нет.
живой пример
import java.lang.reflect.Proxy;
public class ProxyDemo {
interface Payments {
void charge(String orderId);
void chargeTwice(String orderId);
}
static class RealPayments implements Payments {
@Override
public void charge(String orderId) {
System.out.println(" списание по " + orderId);
}
@Override
public void chargeTwice(String orderId) {
charge(orderId);
charge(orderId);
}
}
public static void main(String[] args) {
var real = new RealPayments();
var payments = (Payments) Proxy.newProxyInstance(
Payments.class.getClassLoader(),
new Class<?>[]{Payments.class},
(proxy, method, arguments) -> {
System.out.println("прокси: открыл транзакцию для " + method.getName());
return method.invoke(real, arguments);
});
payments.charge("A-1");
payments.chargeTwice("A-2");
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
В выводе строка про транзакцию появляется дважды, а списаний три: два внутренних вызова charge прошли мимо заместителя.
Поведенческие паттерны
Эта группа отвечает на вопрос: как организовать взаимодействие объектов?
Chain of Responsibility
HTTP-запрос нужно сначала проверить на аутентификацию, потом на CSRF, потом на авторизацию, и каждый шаг может остановить обработку. Вместо одного огромного метода — цепочка независимых обработчиков.
Суть: запрос идёт по цепочке обработчиков, пока кто-то его не обработает.
В Spring это SecurityFilterChain — эталон паттерна. Та же механика у MVC-интерсепторов и цепочек обработчиков исключений.
Запрос идёт по звеньям по порядку, и любое может его оборвать: если авторизация отказала, до обработчика запрос не доедет.
@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");
}
}
}
Command
Обычно вызов метода мгновенный и безымянный. Command превращает операцию в объект с данными — его можно передать в другой поток, отложить, залогировать, отменить.
Суть: операция упакована в объект — её можно передать, отложить, поставить в очередь.
В Java это Runnable/Callable, уходящие в пул потоков. В Spring — @Scheduled-задачи и @Async-методы.
В прикладном коде Command — структурная основа разделения «что делать» и «как делать»: один объект несёт данные операции, другой её исполняет.
public record CancelOrderCommand(OrderId orderId, CancelReason reason) {}
@Component
@RequiredArgsConstructor
public class CancelOrderHandler {
private final OrderRepository orderRepository;
@Transactional
public void handle(CancelOrderCommand command) {
var order = orderRepository.findById(command.orderId()).orElseThrow();
order.cancel(command.reason());
orderRepository.save(order);
}
}
Interpreter
В Spring это SpEL (Spring Expression Language): выражения в @PreAuthorize("hasRole('ADMIN')"), @Cacheable(key = "#id"), @Value("#{systemProperties['user.home']}").
Суть: язык с грамматикой и интерпретатор выражений на нём.
В собственном коде создавать мини-языки не стоит: строковые выражения не проверяются компилятором, ломаются при рефакторинге и усложняют отладку.
Запрет относится к строковым мини-языкам, а не к самой идее. Типобезопасный внутренний язык — это тот же Interpreter, только без строк: в jOOQ условие пишется как ORDERS.TOTAL.gt(1000), в QueryDSL и в Specification у Spring Data — похоже, и каждое такое выражение собирается в дерево, которое потом переводят в SQL. Разница одна, зато решающая: опечатку в строке SpEL найдёт пользователь на проде, опечатку в типизированном выражении — компилятор.
Iterator
Паттерн давно растворился в языке: Iterable/Iterator, for-each, Stream. Spring Data добавляет Page и Slice для постраничных выборок.
Суть: последовательный доступ к элементам без раскрытия внутренней структуры.
Реализовывать Iterator руками не приходится. Единственное близкое решение — отдавать из коллекций агрегата неизменяемые представления.
Одна практическая часть у паттерна всё же есть — поведение при изменении коллекции по ходу обхода. Итераторы обычных коллекций Java работают по принципу «ломаться сразу»: если во время обхода коллекцию изменили мимо итератора, следующий next() бросит ConcurrentModificationException. Это защита, а не поломка: иначе обход тихо пропустил бы элементы или прошёл бы один дважды.
Поэтому удалять по ходу нужно через iterator.remove() или через removeIf, а не через list.remove(...) внутри for-each. Из того же семейства ещё одна особенность: Stream — одноразовый обход, повторный терминальный вызов на нём бросит IllegalStateException, и если данные нужны дважды, поток создают заново.
Mediator
Если один сервис напрямую вызывает другой, они тесно связаны: изменение одного ломает другой. Mediator убирает прямую зависимость — объекты публикуют события, и кто хочет — подписывается.
Суть: объекты общаются через посредника, не зная друг о друге.
Образцовый медиатор в Spring — DispatcherServlet: он знает всех участников разбора запроса (обработчики, резолверы, конвертеры) и сам решает, кого и в каком порядке позвать. Роль посредника играет и ApplicationEventPublisher: бин публикует событие, слушатели реагируют, напрямую друг с другом никто не связан.
@Component
@RequiredArgsConstructor
public class CancelOrderHandler {
private final ApplicationEventPublisher events;
public void handle(CancelOrderCommand command) {
// ... логика отмены ...
events.publishEvent(new OrderCancelled(command.orderId()));
}
}
Memento
Savepoint в транзакциях — чистый Memento: TransactionStatus.createSavepoint() фиксирует точку, rollbackToSavepoint() откатывает к ней.
Суть: снимок состояния объекта для последующего отката.
В прикладном коде почти никогда не нужен: откат состояния — работа транзакции базы данных, история изменений — отдельная таблица аудита или event sourcing.
Суть паттерна не только в снимке, но и в его непрозрачности: тот, кто хранит снимок, не должен уметь в него заглянуть и тем более его поправить. Иначе внутреннее состояние объекта утекло наружу, и менять его больше нельзя — сломаются хранители. В Java это обычно неизменяемая запись, отданная наружу как непрозрачное значение: хранитель держит её в стопке и возвращает обратно, не разбирая.
Прикладной случай, где Memento всё-таки пишут руками, — отмена действия: стопка снимков в редакторе для отмены и повтора, состояние формы, сохранённое перед рискованной операцией, возврат на шаг назад в многошаговой форме.
Observer
Нужно отправить письмо при отмене заказа. Можно вызвать emailService.send(...) прямо в бизнес-логике — но тогда бизнес-логика знает об email-сервисе. Observer разделяет их: бизнес-логика публикует событие, email-сервис подписывается.
Суть: подписчики получают уведомления об изменении состояния издателя.
В Spring это @EventListener и @TransactionalEventListener — та же шина, что и у Mediator, но роль другая: медиатор знает всех участников и координирует их, а издатель в Observer про подписчиков не знает вовсе — объявил факт и пошёл дальше. Важный нюанс: если слушатель с внешними эффектами (письмо, SMS) вешается без привязки к транзакции, уведомление может уйти по откаченной транзакции. Правильно — phase = TransactionPhase.AFTER_COMMIT.
@Component
@RequiredArgsConstructor
public class OrderNotificationListener {
private final NotificationPort notifications;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(OrderCancelled event) {
notifications.orderCancelled(event.orderId());
}
}
И две ловушки самой аннотации, на которых теряют уведомления. Первая: @TransactionalEventListener вне транзакции не вызывается вовсе — не падает и в обычной конфигурации логов ничего не пишет, просто пропускает событие. Так бывает, когда операцию позвали из метода без @Transactional или из планировщика: событие опубликовано, слушатель есть, письма нет. Лечится либо транзакцией вокруг публикации, либо явным fallbackExecution = true, который разрешает вызов и без неё.
Вторая: AFTER_COMMIT — фаза по умолчанию, писать её необязательно, но написанная явно она напоминает читателю, что слушатель ждёт коммита, а не срабатывает сразу.
State
Заказ в статусе CREATED можно отменить. Заказ в статусе DELIVERED — нет. Логику переходов можно разложить в отдельные классы-состояния, но для большинства задач достаточно проверок в методах самого объекта:
Суть: поведение объекта меняется при смене его внутреннего состояния.
живой пример
public class StateDemo {
static class Order {
enum Status { CREATED, PAID, DELIVERED, CANCELLED }
private Status status;
Order(Status status) {
this.status = status;
}
void cancel() {
if (status != Status.CREATED && status != Status.PAID) {
throw new IllegalStateException("нельзя отменить заказ в статусе " + status);
}
status = Status.CANCELLED;
}
}
public static void main(String[] args) {
var paid = new Order(Order.Status.PAID);
paid.cancel();
System.out.println("из PAID: " + paid.status);
try {
new Order(Order.Status.DELIVERED).cancel();
} catch (IllegalStateException e) {
System.out.println("из DELIVERED: " + e.getMessage());
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Классический State с отдельным классом на каждое состояние оправдан при очень большой машине состояний. Для обычного объекта с несколькими статусами хватает enum и проверок переходов.
Когда статусов становится много и переходы начинают ветвиться, задача перестаёт быть про паттерн и становится про машину состояний: таблица переходов вместо цепочки if, отдельный класс на состояние, на верхнем пределе — готовый движок. Этот путь разобран отдельно, в разделе конечные автоматы: от enum и таблицы переходов до реализации в Java и процессов в системах управления бизнес-процессами.
Strategy
Скидки для разных категорий покупателей: можно написать switch с условиями, а можно объявить интерфейс DiscountPolicy и создать по реализации на каждую категорию. Добавление новой категории — новый класс, а не правка switch.
Суть: семейство алгоритмов за общим интерфейсом, выбираемых в зависимости от ситуации.
В Spring Strategy повсюду: PasswordEncoder (выбирает алгоритм хеширования), PlatformTransactionManager, AuthenticationProvider, ContentNegotiationStrategy.
живой пример
import java.util.List;
public class StrategyDemo {
interface DiscountPolicy {
boolean supports(String category);
int apply(int total);
}
record VipDiscount() implements DiscountPolicy {
public boolean supports(String category) { return category.equals("vip"); }
public int apply(int total) { return total / 10; }
}
record FirstOrderDiscount() implements DiscountPolicy {
public boolean supports(String category) { return category.equals("первый заказ"); }
public int apply(int total) { return 300; }
}
public static void main(String[] args) {
List<DiscountPolicy> policies = List.of(new VipDiscount(), new FirstOrderDiscount());
for (String category : List.of("vip", "первый заказ", "обычный")) {
int discount = policies.stream()
.filter(policy -> policy.supports(category))
.mapToInt(policy -> policy.apply(5000))
.sum();
System.out.println(category + ": скидка " + discount);
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Template Method
Фильтр запроса всегда должен выполниться один раз, даже если в цепочке его вызовут дважды. Базовый класс OncePerRequestFilter берёт эту гарантию на себя, оставляя наследнику только содержательную часть. Тот же скелет на чистой Java: порядок шагов задан в базовом классе, наследник пишет один шаг.
Суть: скелет алгоритма в базовом классе, изменяемые шаги — в наследниках.
живой пример
public class TemplateMethodDemo {
abstract static class Importer {
final void run(String file) {
System.out.println("открыли " + file);
parse(file);
System.out.println("закрыли " + file);
}
abstract void parse(String file);
}
public static void main(String[] args) {
Importer csv = new Importer() {
@Override
void parse(String file) {
System.out.println(" разобрали строки CSV");
}
};
csv.run("orders.csv");
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Современный вариант — передавать шаг как функцию, а не создавать наследника. JdbcTemplate так и делает: скелет (открыть соединение → выполнить → закрыть) постоянный, переменный шаг (SQL-запрос) передаётся лямбдой.
Visitor
Есть иерархия типов PaymentMethod: Card, Sbp, Cash. Нужно считать комиссию по-разному для каждого типа, не добавляя метод fee() в каждый класс. Visitor добавляет операцию снаружи.
Суть: новая операция над структурой объектов без изменения их классов.
В современных языках место Visitor занял pattern matching:
живой пример
import java.math.BigDecimal;
public class VisitorDemo {
sealed interface PaymentMethod permits Card, Sbp, Cash {}
record Card(BigDecimal amount) implements PaymentMethod {}
record Sbp(BigDecimal amount) implements PaymentMethod {}
record Cash(BigDecimal amount) implements PaymentMethod {}
static BigDecimal fee(PaymentMethod method) {
return switch (method) {
case Card card -> card.amount().multiply(new BigDecimal("0.02"));
case Sbp ignored -> BigDecimal.ZERO;
case Cash ignored -> new BigDecimal("50");
};
}
public static void main(String[] args) {
var amount = new BigDecimal("5000");
System.out.println("карта: " + fee(new Card(amount)));
System.out.println("СБП: " + fee(new Sbp(amount)));
System.out.println("нал: " + fee(new Cash(amount)));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
sealed держит список типов закрытым, поэтому компилятор сам проверит, что в switch разобраны все три.
Какой паттерн под какую боль
Классическая тройка — порождающие, структурные, поведенческие — отвечает на вопрос «как книга их разложила». В работе вопрос другой: «у меня вот такая боль, что брать?» Поэтому полезна вторая раскладка, по решаемой задаче.
Разрастается условный оператор. Каждый новый вариант дописывают веткой в тот же switch, и таких switch по коду уже три. Берут Strategy, если поведение выбирает вызывающий и передаёт снаружи; State, если поведение выбирает сам объект по своему состоянию; Factory Method, если выбирать надо не поведение, а класс создаваемого объекта. Visitor — тот же случай, но когда операций много, а типов мало и они не меняются; в Java 21 его почти всегда заменяет сопоставление с образцом.
Нужно добавить поведение, не трогая класс. Класс чужой, или свой, но уже проверенный. Decorator оборачивает и добавляет слой (кэш поверх повторов поверх логирования). Proxy оборачивает и контролирует доступ — так работают @Transactional и @Cacheable. Template Method оставляет скелет и даёт переопределить шаги. Observer выносит побочное действие наружу совсем: класс объявляет факт, а письмо отправляет кто-то другой.
Два модуля знают друг о друге слишком много. Adapter подгоняет чужой интерфейс под ваш и держит знание о внешнем SDK в одном классе. Facade закрывает несколько своих классов одной простой дверью. Mediator убирает прямые вызовы между участниками: все общаются через посредника. Bridge разводит две независимо меняющиеся оси, чтобы их варианты не перемножались в классы.
Объект дорого или сложно собрать. Builder — когда у объекта много необязательных частей и конструктор перестал читаться. Prototype — когда готовый образец дешевле скопировать, чем собрать заново. Abstract Factory — когда объекты идут семьёй и смешивать их нельзя. Flyweight — когда одинаковых объектов миллионы и их стоит разделить на общее и различающееся.
Набор объектов надо обойти как один. Composite прячет дерево за интерфейсом одного элемента. Iterator даёт обход, не раскрывая устройство коллекции. Chain of Responsibility пропускает запрос через цепочку обработчиков, пока кто-нибудь не возьмёт его на себя.
Все 23 паттерна: быстрая сводка
| Паттерн | Где встречается в готовых инструментах | Нужен ли в своём коде |
|---|---|---|
| Singleton | Дефолтный scope бина в Spring | Не писать руками — это работа контейнера |
| Prototype | clone() у Cloneable, toBuilder() от Lombok | Редко — и не путать со scope prototype в Spring |
| Factory Method | @Bean-методы, FactoryBean<T> | Да — статические фабричные методы для создания объектов с валидацией |
| Abstract Factory | Connection в JDBC, ConnectionFactory в JMS | Не нужен — согласованный набор бинов собирает @Configuration под профилем |
| Builder | HTTP-клиенты, HttpSecurity в Spring Security | Да — через @Builder от Lombok или вручную |
| Adapter | HandlerAdapter, MessageListenerAdapter | Да — адаптеры к внешним зависимостям |
| Bridge | SLF4J и его реализации, JDBC и драйверы баз | Почти никогда — «интерфейс + DI» покрывает |
| Composite | CompositePropertySource, CompositeHealthContributor | Да — когда нужно несколько получателей за одним интерфейсом |
| Decorator | Request-wrappers, TransactionAwareDataSourceProxy | Да — обёртки над репозиториями; сначала проверьте встроенные механизмы |
| Facade | JdbcTemplate, KafkaTemplate, RestClient | Да — адаптер-фасад над чужим SDK |
| Flyweight | Кеши метаданных во фреймворках, Integer.valueOf | Почти никогда — идею несут неизменяемые value objects |
| Proxy | AOP: @Transactional, @Cacheable, @Async, @PreAuthorize | Не писать — использовать встроенные механизмы AOP |
| Chain of Responsibility | SecurityFilterChain, MVC-интерсепторы | Редко — хватает готовых цепочек фреймворка |
| Command | Runnable + пул потоков, @Scheduled, @Async | Да — разделение «что» и «как» в обработчиках операций |
| Interpreter | SpEL: @PreAuthorize, @Cacheable(key=...), @Value | Не изобретать собственных языков выражений |
| Iterator | Iterable, Stream, Page/Slice в Spring Data | Растворился в языке |
| Mediator | ApplicationEventPublisher, DispatcherServlet | Да — события вместо прямых вызовов между модулями |
| Memento | Savepoint в транзакциях | Почти никогда — откат делает транзакция базы данных |
| Observer | @EventListener, @TransactionalEventListener | Да — доменные события, постоянно |
| State | Spring Statemachine для сложных случаев | Да, в облегчённой форме — enum + проверки переходов |
| Strategy | PasswordEncoder, PlatformTransactionManager | Да — вместо разрастающихся switch |
| Template Method | OncePerRequestFilter, AbstractRoutingDataSource | Колбэк-вариант предпочтительнее наследования |
| Visitor | ASM в component scanning | Вытеснен pattern matching (Java 21+) |
Из 23 паттернов в прикладном коде регулярно пишут семь–восемь: Adapter, Strategy, Observer, Command, Decorator, Composite, Factory Method и State. Ещё шесть используют каждый день в готовом виде, не замечая: Proxy, Singleton, Facade, Template Method, Chain of Responsibility и Iterator. Остальные — словарь для чтения чужого кода.
Глубже: развилки: близкие паттерны и цена абстракциирасширенное
Каталог полезен не списком, а развилками: в живом коде выбор стоит между двумя-тремя похожими паттернами, и между паттерном и его отсутствием.
Strategy, State и Template Method решают одну задачу, «поведение меняется», по-разному. Strategy: поведение выбирает клиент и передаёт снаружи (способ расчёта доставки). State: поведение выбирает сам объект по своему состоянию и меняет его по ходу (заказ в DRAFT ведёт себя не как в PAID). Template Method: скелет алгоритма фиксирован, меняются шаги, и вариантов немного и они известны заранее; как только их становится больше трёх или они нужны на ходу, наследование уступает Strategy.
Decorator и Proxy одинаковы по форме, обёртка с тем же интерфейсом, и разные по цели: декоратор добавляет поведение и складывается слоями (кэш поверх логирования поверх повторов), прокси контролирует доступ к одному объекту и обычно один.
Factory Method и Abstract Factory: первый выбирает класс одного продукта, вторая производит семейство согласованных продуктов; если семейства нет, абстрактная фабрика это лишний слой. Adapter и Facade: адаптер подгоняет один чужой интерфейс под ваш, фасад упрощает несколько своих. Observer и события Spring: тот же паттерн, но события дают ещё и порядок относительно транзакции, поэтому внутри одного приложения свой Observer почти не пишут.
Теперь цена. Каждый паттерн это косвенность: интерфейс, ещё один класс, ещё один переход при чтении кода. Интерфейс с единственной реализацией, фабрика для одного класса, Strategy с одной стратегией не абстракция, а накладные расходы: читатель ищет реализацию по IDE, а не видит её. Правило трёх: абстракцию вводят, когда вариантов стало три, а не когда «может понадобиться». switch из двух веток честнее Strategy, if честнее State, пока состояния два. Паттерн окупается там, где меняется часто и в разных местах, и это единственный критерий, который перевешивает «так красивее».
Глубже: паттерны уровня приложения: каталог Фаулерарасширенное
Repository упомянут в фазе трижды, и каждый раз в чужой роли: как Pure Fabrication, как пример ISP, как пример Adapter. Дело в том, что это паттерн из другого каталога. GoF описывает объекты; Фаулер в «Patterns of Enterprise Application Architecture» описывает, из чего собран сервис. Эти паттерны в Java-сервисе пишут каждый день, часто не зная имён.
Repository: коллекция сущностей с виду, обращение к базе внутри; код домена просит orders.findById(id), а как это SQL или документ, его не касается. Unit of Work: одна единица работы собирает все изменения и записывает их вместе в конце, это EntityManager с его контекстом персистентности и @Transactional вокруг.
Service Layer: слой, который знает сценарии («оформить заказ»: проверить остатки, списать, отправить письмо) и держит транзакции, но не бизнес-правила самих сущностей. DTO: объект без поведения для передачи данных через границу, между слоями или по сети; сущность через границу не отдают, чтобы не тащить с ней ленивые связи и не менять API при каждой правке модели.
Value Object: объект, равный другому по значению, а не по идентичности, Money, Email, Period; неизменяемый, с проверками в конструкторе. Null Object: реализация интерфейса, которая «ничего не делает», вместо null и проверок на него, например NoDiscount вместо discount == null. Specification: условие как объект, activeOrders.and(olderThan(30)), которое можно комбинировать и переводить в запрос.
Различать каталоги важно по одной причине: GoF-паттерн живёт внутри модуля и меняет форму классов, а паттерн Фаулера задаёт границы слоёв, и ошибка в нём стоит дороже. Сущность, ушедшая в JSON, или репозиторий с бизнес-правилами внутри ломают не класс, а архитектуру. В следующих фазах эти имена встретятся снова: в Hibernate, в чистой архитектуре и в Use Case Pattern.
Коротко
- Паттерны GoF — не рецепты для копирования, а словарь: именно им разговаривают фреймворки в названиях классов.
- Proxy — паттерн номер один в Spring:
@Transactional,@Cacheable,@Asyncработают через него. - Strategy — главный инструмент против разрастающихся
switch. - Observer — стандартный способ отделить побочные действия (письмо, метрика) от бизнес-логики.
- Adapter — основа работы с внешними зависимостями: домен знает интерфейс, адаптер знает конкретный SDK.
- Из 23 паттернов регулярно пишут руками ~7; остальные живут в готовых инструментах.
- Выбор между близкими паттернами идёт по тому, кто выбирает поведение и сколько вариантов; абстракцию вводят на третьем варианте, а не «на всякий случай».
- Repository, Unit of Work, Service Layer, DTO, Value Object, Null Object и Specification это каталог Фаулера: они задают границы слоёв, а не форму классов.
- Искать паттерн стоит не по группе из книги, а по боли: разрастающийся
switch, поведение поверх чужого класса, два слишком связанных модуля, дорогая сборка объекта, обход дерева.
Что почитать дальше
- SOLID на примерах — принципы, ради которых эти паттерны существуют.
- GRASP на примерах — какому классу отдать ответственность, прежде чем выбирать паттерн.
- Spring AOP — как устроен Proxy, паттерн номер один Spring.
- DI/IoC, bean scopes — Singleton и Prototype как scope контейнера.
- Конечные автоматы — во что вырастает State, когда состояний и переходов становится много.