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

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

Ниже — все 23 паттерна по классическим группам. Для каждого: суть в одном предложении, где он уже живёт в готовых инструментах и нужно ли его писать самому.

вызывающий код не меняется — меняется то, что стоит за интерфейсом вызывающий код интерфейс один вызов Strategy: одна реализация из трёх vip — минус 10% первый заказ — минус 300 обычный — без скидки vip — минус 10% первый заказ — минус 300 обычный — без скидки Decorator: обёртка над обёрткой логирует кеширует база у каждой обёртки тот же интерфейс логирует кеширует база Composite: один вызов — всем сразу рассылка email sms push

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

Обязательно

Порождающие паттерны

Эта группа отвечает на один вопрос: как правильно создавать объекты?

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.

Decorator вызывающий кеш логирование база Proxy вызывающий заместитель реальный объект

Форма одна, обёртка с тем же интерфейсом, а разница в цели: декораторы кладут слоями, заместитель стоит один и решает, пускать ли вызов.

живой пример

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 подменяет объект заместителем с тем же интерфейсом.

извне вызывающий код прокси RealPayments изнутри RealPayments this.charge() RealPayments

Заместитель стоит снаружи объекта: внешний вызов идёт через него, а 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-интерсепторов и цепочек обработчиков исключений.

аутентификация кто это CSRF подделка формы авторизация есть ли право обработчик бизнес-логика

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

@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Не писать руками — это работа контейнера
Prototypeclone() у Cloneable, toBuilder() от LombokРедко — и не путать со scope prototype в Spring
Factory Method@Bean-методы, FactoryBean<T>Да — статические фабричные методы для создания объектов с валидацией
Abstract FactoryConnection в JDBC, ConnectionFactory в JMSНе нужен — согласованный набор бинов собирает @Configuration под профилем
BuilderHTTP-клиенты, HttpSecurity в Spring SecurityДа — через @Builder от Lombok или вручную
AdapterHandlerAdapter, MessageListenerAdapterДа — адаптеры к внешним зависимостям
BridgeSLF4J и его реализации, JDBC и драйверы базПочти никогда — «интерфейс + DI» покрывает
CompositeCompositePropertySource, CompositeHealthContributorДа — когда нужно несколько получателей за одним интерфейсом
DecoratorRequest-wrappers, TransactionAwareDataSourceProxyДа — обёртки над репозиториями; сначала проверьте встроенные механизмы
FacadeJdbcTemplate, KafkaTemplate, RestClientДа — адаптер-фасад над чужим SDK
FlyweightКеши метаданных во фреймворках, Integer.valueOfПочти никогда — идею несут неизменяемые value objects
ProxyAOP: @Transactional, @Cacheable, @Async, @PreAuthorizeНе писать — использовать встроенные механизмы AOP
Chain of ResponsibilitySecurityFilterChain, MVC-интерсепторыРедко — хватает готовых цепочек фреймворка
CommandRunnable + пул потоков, @Scheduled, @AsyncДа — разделение «что» и «как» в обработчиках операций
InterpreterSpEL: @PreAuthorize, @Cacheable(key=...), @ValueНе изобретать собственных языков выражений
IteratorIterable, Stream, Page/Slice в Spring DataРастворился в языке
MediatorApplicationEventPublisher, DispatcherServletДа — события вместо прямых вызовов между модулями
MementoSavepoint в транзакцияхПочти никогда — откат делает транзакция базы данных
Observer@EventListener, @TransactionalEventListenerДа — доменные события, постоянно
StateSpring Statemachine для сложных случаевДа, в облегчённой форме — enum + проверки переходов
StrategyPasswordEncoder, PlatformTransactionManagerДа — вместо разрастающихся switch
Template MethodOncePerRequestFilter, AbstractRoutingDataSourceКолбэк-вариант предпочтительнее наследования
VisitorASM в 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, когда состояний и переходов становится много.