Ночь. Курсор мигает, как маяк в тумане. Логи шепчут о том, что в коде — своя улица, свои правила и кодекс общения. Объекты — не безмолвные элементы системы. У каждого свой характер, привычки и слабости. Один щёлчок и поведение меняется: кто-то отдаёт приказы, кто-то внимательно прислушивается, а кто-то терпеливо ждёт сигнала. Эта статья — карта такого города.

Всем привет! Меня зовут Бромбин Андрей и сегодня разберёмся в поведенческих паттернах ООП. Короткие определения, идеи через ясные метафоры и рабочие примеры на Java. Всё это для того, чтобы система не трещала по швам, а решения были ясными, предсказуемыми и поддерживаемыми.

Шаблоны — как правила дорожного движения: все знают, но аварии всё равно случаются. Где же лучше притормозить, а когда горит зелёный свет и можно вдавить педаль — узнаем сегодня.

Кратко о главном

В предыдущих частях мы успели рассмотреть две группы шаблонов «Банды четырёх». Конечно, не забыли и про ключевое определение:

Паттерн проектирования — это проверенный способ организовать код, когда проблема — типовая. Это не рецепт решения задачи, а шаблон, который задаёт структуру и роли, улучшает понимание и ускоряет разработку.

Часть 1: Паттерны ООП c примерами на Java: порождающие шаблоны — про гибкое создание объектов, момент и способ появления экземпляров, сокрытие деталей и подготовленное корректное состояние.

Часть 2: Паттерны ООП с при��ерами на Java: структурные шаблоны — про наглядную и удобную в поддержке иерархию классов: упорядоченные связи, уменьшение зависимости от деталей реализации, объединение классов в композиции, сокрытие сложности за фасадом и обёртками, изолирование изменений внутри.

Теперь смело приступаем к описанию поведения объектов.

Поведенческие паттерны

Суть поведенческих шаблонов — распределить ответственность между объектами, минимизировать связность и сделать реакции на события предсказуемыми.

Chain of Responsibility (Цепочка обязанностей)

Поведенческий паттерн проектирования, который подразумевает передачу запросов по цепочке из обработчиков. Каждый из обработчиков решает поставленную задачу или передаёт её дальше.

Вспоминаем типичный звонок в службу поддержки: первый звонок принимает бот. Если он справился, диалог завершается, если нет — переключает на оператора. Каждый следующий оператор держит ответственность за обработку задачи пользователя или передачи её дальше по цепочке. Клиент проходит свои «семь кругов ада» поддержки, пока не найдётся тот, кто удовлетворит его запрос.

Рассмотрим именно этот пример. Класс Base хранит ссылку на следующий обработчик next и делегирует задачу через forward(). Каждый handle() принимает решение: обработать или передать дальше.

Цепочка обязанностей при решении запроса в поддержку
// Тикет в поддержку
record Ticket(String subject, Severity severity, boolean inFAQ) {}

// Уровень "сложности" тикета.
enum Severity { LOW, MID, HIGH, CRITICAL }

// Контракт обработчика цепочки.
interface Handler { Result handle(Ticket t); }

/*
  Базовый класс для звеньев цепи.
  Делегирование инкапсулировано в forward(t)
*/
abstract class AbstractHandler implements Handler {
    private final Handler next;

    protected AbstractHandler(Handler next) { this.next = next; }

    // Передаём обработку следующему звену
    protected Result forward(Ticket t) {
        return next == null ? new Result.Unhandled() : next.handle(t);
    }
}

// 1) Бот отвечает на известные вопросы из FAQ.
final class Bot extends AbstractHandler {
    Bot(Handler next) { super(next); }

    @Override
    public Result handle(Ticket t) {
        // "Знаю ответ" → завершаем цепочку (ранний выход)
        return t.inFAQ() ? new Result.Handled("Бот: ответил по FAQ")
                         : forward(t); // "Не знаю" → отдаём дальше.
    }
}

// 2) Первая линия поддержки: берёт простые кейсы.
final class L1 extends AbstractHandler {
    L1(Handler next) { super(next); }

    @Override
    public Result handle(Ticket t) {
        return (t.severity().ordinal() <= Severity.MID.ordinal())
                ? new Result.Handled("L1: решено")
                : forward(t);
    }
}

/**
 * 3) Финальная перехватчик: R&D "забирает в работу".
 * Это "поглощающий" обработчик: всегда Handled.
 */
final class RnD extends AbstractHandler {
    RnD() { super(null); }

    @Override
    public Result handle(Ticket t) {
        return new Result.Handled("Эскалация в R&D: взяли в работу");
    }
}

public class CoRExample {
  public static void main(String[] args) {
      // Сборка неизменяемой цепочки: Bot → L1 → RnD.
      Handler chain = new Bot(new L1(new RnD()));

      // Демонстрационные тикеты: FAQ/простой/критичный.
      for (var t : List.of(
          new Ticket("Как сменить пароль?", Severity.LOW, true),
          new Ticket("Смена тарифа", Severity.MID, false),
          new Ticket("Падает прод", Severity.CRITICAL, false))) {

         var r = chain.handle(t);
         switch (r) {
            case Result.Handled h   
                        -> System.out.println(h.message());
            case Result.Unhandled u 
                        -> System.out.println("Некому обработать");
       }
     }
  }
}

Бот закрывает вопрос, если он из FAQ. Первая линия L1 берёт всё, что «низкой» сложности. Остальное отправляется в Fallback — последнее звено цепи обработчиков. Как только кто-то возвращает непустой Optional<>, цикл останавливается — это и есть ранний выход.

Сценарий применимости

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

Сценарий неприменимости

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

Команда (Command)

Поведенческий паттерн проектирования, который упаковывает действие в самостоятельный объект: что сделать, с какими параметрами и над каким получателем. Отправитель (invoker) лишь передаёт команду «на выполнение», не зная деталей. Такой перенос позволяет ставить операции в очередь, откладывать их, логировать историю, объединить в макрокоманды и поддерживать отмену или ретраи.

Работник кофейни каждый день интерпретирует запросы кл��ента в команды.
Бариста, получая чек, работает с чётким описанием задачи, которую необходимую выполнить, вместо неформального и не всегда прозрачного взаимодействия с клиентом. Чек можно снять (отменить), сдвинуть в очереди (запланировать) или хранить в коробке для аудита (лог).

Давайте посмотрим на корзину интернет-магазина с нового ракурса. Здесь совершенно немагическим образом действие превращается в объект: AddItemToCart и RemoveItemFromCart знают, как изменить ShoppingCart, а CommandInvoker всего лишь запускает команды и складывает их в историю для аккуратного undo.

Реализация паттерна Command для продуктовой корзины
// Получатель (Receiver): корзина
final class ShoppingCart {
    // productCode -> count
    private final Map<String, Integer> items = new LinkedHashMap<>();

    void addItem(String productCode, int count) {
        items.merge(productCode, count, Integer::sum);
    }

    void removeItem(String productCode, int count) {
        int currentCount = items.getOrDefault(productCode, 0);
        int nextCount = Math.max(0, currentCount - count);
        
        if (nextCount == 0) items.remove(productCode); 
        else items.put(productCode, nextCount);
    }

    int countOf(String productCode) {
      return items.getOrDefault(productCode, 0);
    }
}

// Контракт команды
interface CartCommand {
    void execute();
    void undo();
}

// Конкретные команды
record AddItemToCart(ShoppingCart cart, String productCode, int count) implements CartCommand {
    @Override public void execute() {
      cart.addItem(productCode, count);
    }

    @Override public void undo() {
      cart.removeItem(productCode, count); // по учебному просто
    }
}

final class RemoveItemFromCart implements CartCommand {
    private final ShoppingCart cart;
    private final String productCode;
    private final int requestedCount;

    // Сколько реально сняли
    private int actuallyRemoved;

    RemoveItemFromCart(ShoppingCart cart, String productCode, int requestedCount) {
        this.cart = cart; this.productCode = productCode; this.requestedCount = requestedCount;
    }

    @Override public void execute() {
        int currentCount = cart.countOf(productCode);
        actuallyRemoved = Math.min(requestedCount, currentCount);
        cart.removeItem(productCode, actuallyRemoved);
    }

    @Override public void undo() {
        if (actuallyRemoved > 0) {
          cart.addItem(productCode, actuallyRemoved);
        }
    }
}

// Инициатор (Invoker): выполняет команды и хранит историю для undo
final class CommandInvoker {
    private final Deque<CartCommand> historyStack = new ArrayDeque<>();

    void run(CartCommand command) {
        command.execute();
        historyStack.push(command);
    }

    void undoLast() {
        CartCommand command = historyStack.poll();
        if (command != null) command.undo();
    }
}

// Клиент
public class ShoppingCartCommandDemo {
  public static void main(String[] args) {
    var cart = new ShoppingCart();
    var invoker = new CommandInvoker();

    // +2 яблока
    invoker.run(new AddItemToCart(cart, "PRODUCT-APPLE", 2));
    // +1 молоко
    invoker.run(new AddItemToCart(cart, "PRODUCT-MILK", 1));
    // -1 яблоко
    invoker.run(new RemoveItemFromCart(cart, "PRODUCT-APPLE", 1));

    // вернёт 1 яблоко
    invoker.undoLast();                                            

    // снимет всё, что было (1)
    invoker.run(new RemoveItemFromCart(cart, "PRODUCT-MILK", 5));
    // вернёт ровно 1
    invoker.undoLast();                                           
  }
}

Сценарий формируется клиентом: добавить яйца, положить молоко, убрать лишнее — и при необходимости откатывает последние действия ровно в том объёме, в каком оно было выполнено. Отправитель вовсе не знает деталей реализации корзины. Команды легко ставить в очередь, логировать, объединять в макрооперации и переносить как данные — ровно для этого и нужен нам Command.

Сценарий применимости

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

Сценарий неприменимости

Избыточен, если вы просто вызываете метод и результат нужен «здесь и сейчас», без очередей, истории и повторов — обычный вызов или Runnable/Consumer будет проще. Не лучший выбор, когда каждая операция строго синхронна и тесно связана с контекстом вызова, а также в высоконагруженных участках, где накладные расходы на создание объектов-команд не приносят выгоды.

Iterator (Итератор)

Паттерн предлагает вынести логику обхода коллекции в отдельный объект и дать единый протокол, не раскрывая внутреннее устройство контейнера. Позволяет менять порядок обхода или держать несколько независимых проходов одновременно.

Аудиогид в музее — это наш итератор по коллекции картин. Жмёшь «вперёд» и слышишь рассказ о следующем экспонате. Никаких забот о маршруте и залах. Гид сам ведёт по намеченной траектории — тематической или укороченной.

Говорят, Леонардо да Винчи так полюбил эту картину, что хранил её у себя до конца жизни
Говорят, Леонардо да Винчи так полюбил эту картину, что хранил её у себя до конца жизни

Самый простой пример, который существует в Java и без нашего вмешательства:

java.util.Iterator
var list = java.util.List.of("a", "b", "c");
var it = list.iterator();
while (it.hasNext()) {
    System.out.println(it.next());
}

У нас есть единый протокол hasNext() и next(), а внутренняя структура списка остаётся скрытой.

Можем написать реализацию и под свой объект Playlist:

Пример применения Итератора для кастомного объекта
final class Playlist implements Iterable<String> {
    private static final class Node {
        final String title; Node next;
        Node(String t) { this.title = t; }
    }

    private Node head, tail;
    private int modCount; // отслеживаем изменения

    public void add(String title) {
        Objects.requireNonNull(title);
        Node n = new Node(title);
        if (head == null) head = tail = n;
        else { tail.next = n; tail = n; }
        modCount++;
    }

    @Override public Iterator<String> iterator() {
        final int expected = modCount; // снимок состояния
      
        return new Iterator<>() {
            private Node cur = head;
          
            @Override public boolean hasNext() { return cur != null; }
            @Override public String next() {
                if (expected != modCount) throw new ConcurrentModificationException();
                if (cur == null) throw new NoSuchElementException();
                String v = cur.title;
                cur = cur.next;
                return v;
            }
            @Override public void remove() { 
              throw new UnsupportedOperationException(); 
            }
        };
    }
}

public class IteratorDemo {
    public static void main(String[] args) {
        var pl = new Playlist();
        pl.add("Intro");
        pl.add("Main Theme");
        pl.add("Finale");

        // 1) for-each использует наш Iterator
        for (var track : pl) System.out.println(track);

        // 2) Два независимых курсора
        Iterator<String> it1 = pl.iterator();
        Iterator<String> it2 = pl.iterator();
        System.out.println(it1.next()); // Intro
        System.out.println(it1.next()); // Main Theme
        System.out.println(it2.next());// Intro (другой курсор)
    }
}

Для простоты наш итератор не потокобезопасен. Как и в стандартных коллекциях при модификации во время обхода приводит к ConcurrentModification исключению

Playlist здесь — «агрегат», внутренности (узлы) никому не видны. Внешний мир гуляет по коллекции через единый интерфейс итератора. Хотим другой порядок? Легко, добавляем новый метод, который вернёт альтернативный Iterator, например, в обратном порядке.

Сценарий применимости

Когда нужен последовательный проход по элементам и не хочется светить внутренностями структуры, когда мы хотим иметь возможность менять стратегию обхода, например, в глубину и ширину для дерева — применяем с удовольствием. Кроме того, если есть необходимость держать несколько независимых курсоров одновременно, подгружать элементы лениво или отдавать их потоком — это знак свыше, Iterator подходит.

Сценарий неприменимости

Когда нужен произвольный индексный доступ — проще дать прямой API коллекции. Когда задача разовая, и контейнер тривиален — обычный for-each или stream без отдельного итератора короче и очевиднее.

Mediator (Посредник)

Поведенческий паттерн, который выносит правила взаимодействия из множества «коллег» в один объект-координатор. Объекты больше не ссылаются друг на друга и не знают деталей протокола. Они «говорят» только с посредником, который направляет события и применяет общие правила.

Аэропорт. Диспетчерская вышка. Самолёты не договариваются между друг другом, засоряя эфир и путаясь. Кто взлетает, а кто садится — решает диспетчер, выдавая инструкции. Ему видна вся картина. Он разводит крылатые машины и на земле, и в воздухе. Меняет приоритеты и предотвращает столкновения.

«Хомяки-пилоты, слушаем. Туда-сюда, нормально делаем и нормально будет», — диспетчер вышки.
«Хомяки-пилоты, слушаем. Туда-сюда, нормально делаем и нормально будет», — диспетчер вышки.

Давайте рассмотрим паттерн на маленьком, но честном примере:

Посредник в сервисе пользователей
// события, о которых сообщают коллеги посреднику
enum Event { SIGNUP_SUBMITTED, USER_SAVED, SAVE_FAILED }

record User(String email) {}

// контракт посредника
interface Mediator { void notify(Event event, User user); }

// базовый класс коллег: знают только посредника
abstract class Colleague {
    protected Mediator mediator;
    void setMediator(Mediator m) { this.mediator = m; }
}

// коллеги (никаких прямых вызовов друг друга)
final class UserController extends Colleague {
    void signup(User u) {
        System.out.println("Controller: signup " + u.email());
        mediator.notify(Event.SIGNUP_SUBMITTED, u);
    }
}

final class UserRepository extends Colleague {
    void save(User u) {
        boolean ok = !u.email().endsWith("@bad");
        System.out.println(ok ? "Repo: saved " : "Repo: save FAILED");
        mediator.notify(ok ? Event.USER_SAVED : Event.SAVE_FAILED, u);
    }
}

final class Mailer extends Colleague {
    void sendWelcome(User u) {
        System.out.println("Mailer: welcome sent to " + u.email());
    }
    void sendSorry(User u) {
        System.out.println("Mailer: sorry email to " + u.email());
    }
}

// посредник: вся схема взаимодействия централизована здесь
final class SignupMediator implements Mediator {
    private final UserRepository repo;
    private final Mailer mailer;
    SignupMediator(UserController c, UserRepository r, Mailer m) {
        this.repo = r; this.mailer = m;
        c.setMediator(this); r.setMediator(this); m.setMediator(this);
    }

    @Override
    public void notify(Event e, User u) {
        switch (e) {
            case SIGNUP_SUBMITTED -> repo.save(u);
            case USER_SAVED       -> mailer.sendWelcome(u);
            case SAVE_FAILED      -> mailer.sendSorry(u);
        }
    }
}

// демо
public class Main {
    public static void main(String[] args) {
        var controller = new UserController();
        var repo = new UserRepository();
        var mailer = new Mailer();
        new SignupMediator(controller, repo, mailer);

        // saved -> welcome
        controller.signup(new User("good@example.com"));
        // failed -> sorry
        controller.signup(new User("oops@bad"));
    }
}

Компоненты свободны от связанности, общаются только через посредника, который решает, что делать дальше — сохранить или отправить письмо с «извините». Хочется поменять сценарий (например, добавить ретраи сохранения)? Легко, затронем один класс SignupMediator, не трогая кучу связей. Меньше каши и больше управляемости.

Сценарий применимости

Давайте договоримся: мы используем паттерн Посредник, когда модули и сервисы сильно связаны в отношении «многие-ко-многим», координация сложная и меняется чаще, чем сами участники. Мы хотим централизовано дирижировать шагами, применять единые правила (валидации, приоритеты), хотим, наконец, облегчить, себе завтрашнему, тестирование и легко переключать участников в одном месте.

Сценарий неприменимости

Если всё просто, то и усложнять не к чему — это главное правило. Важно и обратное, если посредник превращается в «божество», знаёт всё и отвечает за это всё — получаем бутылочное горлышко. Кроме того, паттерн Посредник — ещё и про издержки, которые становятся видны, когда узлам нужно частое общение с низкой задержкой. В последнем случае, лучше использовать локальные протоколы.

Memento (Снимок)

Поведенческий паттерн, который позволяет сохранять и восстанавливать прошлые состояния объектов, не раскрывая подробностей их реализации.

Чек-поинт в игре является пример реализации снимка. Сделал «сохранение», пошёл вперёд, понял, что получается не очень? — Загружаешь и возвращаешься ровно туда, где был. Никто, кроме игры, не знает, какие именно числа и флаги лежат в сохранении, — это непрозрачный файл, который можно положить в папку «снимков» и достать при необходимости.

Рассмотрим реализацию паттерна на каноничном примере сохранения документа:

Класс TextEditor
final class TextEditor {
    private StringBuilder buf = new StringBuilder();

    public void append(String s) { buf.append(s); }

    public void deleteLast(int n) {
        int newLen = Math.max(0, buf.length() - Math.max(0, n));
        buf.setLength(newLen);
    }

    public String text() { return buf.toString(); }

    // Сохранить/восстановить снимок 
    // внешний мир видит только интерфейс
    public Memento save() { return new Snap(buf.toString()); }

    public void restore(Memento m) {
        this.buf = new StringBuilder(((Snap) m).state());
    }

    // Узкий тип снимка доступен снаружи (для хранения), 
    // реализация скрыта
    public sealed interface Memento permits Snap {}

    // Реализация снимка — приватная «чёрная коробка»
    private static final record Snap(String state) implements Memento {}
}

// Caretaker
public class Main {
    public static void main(String[] args) {
        TextEditor editor = new TextEditor();
        Deque<TextEditor.Memento> history = new ArrayDeque<>();

        editor.append("Hello");
        history.push(editor.save());              // чекпоинт #1

        editor.append(", world");
        history.push(editor.save());              // чекпоинт #2

        editor.append("!!!");
        System.out.println(editor.text());        // Hello, world!!!

        editor.restore(history.pop());
        System.out.println(editor.text());        // Hello, world

        editor.restore(history.pop());
        System.out.println(editor.text());        // Hello
    }
}

TextEditor умеет создавать и применять снимки, при этом структура снимка спрятана во внутреннем классе Snap. «Опекун» хранит непрозрачные ссылки Memento и не может заглянуть внутрь — инкапсуляция не нарушается.

Сценарий применимости

Когда нужно сохранять мгновенные снимки состояния объектов (или их частей), чтобы впоследствии при необходимости восстановить в том же зафиксированном состоянии. Для временных «что-если» изменений. При оптимистических транзакциях и сложной агрегации состояния, где проще сохранить состояние (бэкап), чем писать симметричные операции отката.

Сценарий неприменимости

Когда состояние велико и снимки «съедают» память, когда требуется глобальная согласованность множества агрегатов или аудит истории, а не локальные снимки, — не применяем. Когда достаточно одного шага назад — проще ограничиться копией объекта. Если снимок может утечь за пределы доверенной зоны и раскрыть чувствительные данные — лучше отказаться от приёма или шифровать чувствительные данные.

Observer (Наблюдатель)

Поведенческий паттерн, который описывает механизм подписки — одна группа объектов следит и реагирует на события или изменения в других объектах.

Довольно часто раньше я слышал, что комментаторов спортивных событий называют ласково «обсерверами». Позднее я понял, что речь идёт о наблюдателях, которые реагируют на события, происходящие на поле или арене, и дают свои комментарии на этот счёт.

Рассмотрим на наглядном примере источника событий и подписчиков:

Класс публикации новостей и его подписчики
// Наблюдатель: получает ссылку на источник и тянет состояние сам (PULL)
@FunctionalInterface
interface Observer<T> { void onUpdate(Subject<T> source); }

// Источник событий
interface Subject<T> {
    void subscribe(Observer<T> o);
    void unsubscribe(Observer<T> o);
    T state(); // текущее состояние, которое наблюдатели читают в onUpdate(...)
}

// Публикатор новостей (Subject)
final class NewsPublisher implements Subject<String> {
    private final List<Observer<String>> observers = new CopyOnWriteArrayList<>();
    private volatile String lastNews; // актуальная новость

    public void publish(String news) {
        lastNews = news;
        // передаём ИСТОЧНИК, не сами данные
        observers.forEach(o -> o.onUpdate(this)); 
    }

    @Override public String state() { return lastNews; }
    @Override public void subscribe(Observer<String> o)   {
      observers.add(o);
    }
    @Override public void unsubscribe(Observer<String> o) {
      observers.remove(o);
    }
}

// Подписчики (Observers): тянут news через source.state()
final class EmailService implements Observer<String> {
    @Override public void onUpdate(Subject<String> source) {
        System.out.println("[EMAIL] " + source.state());
    }
}
final class SmsService implements Observer<String> {
    @Override public void onUpdate(Subject<String> source) {
        System.out.println("[SMS] " + source.state());
    }
}

// Демонстрация
public class Main {
    public static void main(String[] args) {
        var publisher = new NewsPublisher();
        var email = new EmailService();
        var sms = new SmsService();

        publisher.subscribe(email);
        publisher.subscribe(sms);

        publisher.publish("Распродажа до −50%!");
        publisher.unsubscribe(sms);
        publisher.publish("Вышла новая статья про поведенческие паттерны.");
    }
}

Источник (Subject) хранит список подписчиков и при событии вызывает у каждого onUpdate(this), передавая ссылку на себя. Подписчики независимы: они сами запрашивают детали через subject.state() и могут свободно подключаться/отключаться в любом месте кода. Такой подход снижает связность — меняется модель источника, а контракт обновления остаётся стабильным (пока доступен state()).

Сценарий применимости

Подходит, когда одно событие должно автоматически разойтись по нескольким независимым заинтересованным лицам. Применяем, когда мы заранее не знаем, кому эта информация может быть полезна, а источнику не к чему знать о них. Если одни объекты должны наблюдать за другими, но в определённых случаях (подписка на конкретные события) — смело используем.

Можно дополнительно почитать про Event-driven архитектуру систем, где, например, в очередь помещается событие, а все заинтересованные потребители получают информацию и предпринимают действия.

Сценарий неприменимости

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

State (Состояние)

Паттерн, который выносит логику «как вести себя в текущем режиме» в отдельные объекты и даёт контексту переключать состояние на лету. Внешний интерфейс остаётся тем же, а поведение меняется только за счёт подстановки.

Спускаемся под землю к т��рникетам в метро. По умолчанию он в закрытом состоянии. Оплата, если денег хватает, переводит его в режим «открыт», недостаток средств ничего не меняет. В этом «открытом» режиме первое нажатие — это проход и блокировка после, либо автоматическое переключение по таймауту. Снаружи один и тот же турникет, тот же набор действий, но реакция зависит от текущего состояния.

На этом же примере и рассмотрим реализацию паттерна:

Реализация паттерна Состояние в логике работы турникета
// Контекст + состояния (инкапсулированы внутри)
final class Turnstile {
    // синглтоны состояний
    static final TurnstileState LOCKED   = new Locked();
    static final TurnstileState UNLOCKED = new Unlocked();

    // текущее состояние
    private TurnstileState state = LOCKED;

    // события
    void coin(boolean paid) { state.coin(this, paid); }
    void push()             { state.push(this); }
    void timeout()          { state.timeout(this); }

    // смена состояния — доступна только внутри пакета
    void setState(TurnstileState s) { this.state = s; }

    // «эффекты»
    void lock()   { System.out.println("Turnstile: LOCKED"); }
    void unlock() { System.out.println("Turnstile: UNLOCKED"); }
    void alarm()  { System.out.println("Turnstile: ALARM!"); }
    void thank()  { System.out.println("Turnstile: THANK YOU"); }
}

sealed interface TurnstileState permits Locked, Unlocked {
    void coin(Turnstile t, boolean paid);
    void push(Turnstile t);
    default void timeout(Turnstile t) {} // по умолчанию — нет реакции
}

final class Locked implements TurnstileState {
    @Override public void coin(Turnstile t, boolean paid) {
        if (paid) { t.unlock(); t.setState(Turnstile.UNLOCKED); }
        else t.alarm();
    }
    @Override public void push(Turnstile t) { t.alarm(); }
}

final class Unlocked implements TurnstileState {
    @Override public void coin(Turnstile t, boolean paid) {
      t.thank(); // заплатил ещё раз? поблагодарим :)
    } 
    @Override public void push(Turnstile t) {
      t.lock();
      t.setState(Turnstile.LOCKED);
    }
    @Override public void timeout(Turnstile t) {
      t.lock();
      t.setState(Turnstile.LOCKED);
    }
}

// Демонстрация
public class Main {
    public static void main(String[] args) {
        var gate = new Turnstile();

        gate.push(); // ALARM! (закрыт)
        gate.coin(false); // ALARM! (денег не хватило)
        gate.coin(true); // UNLOCKED
        gate.coin(true); // THANK YOU (лишняя оплата)
        gate.timeout(); // LOCKED (автозакрытие)
        gate.coin(true);  // UNLOCKED
        gate.push(); // LOCKED (прошёл)
    }
}

Контекст Turnstile передаёт управление текущему состоянию. Сами классы логики режимов работы инкапсулирует некоторые правила и при необходимости переключают контекст. Надобность в разрастающихся if и else отпадает.

Сценарий применимости

Когда поведение объекта существенно зависит от режима, например, этапа жизненного цикла, статуса документа, фазы процесса, а ветвления по флагам чудовищно расползаются по коду — применяем на здоровье. Когда нужно централизовать переходы между режимами, легко добавлять новые, когда требуется несколько независимых проходов по одним и тем же операциям, но с разным поведением, — используем с удовольствием.

Сценарий неприменимости

Когда режимов мало, они стабильны и почти не меняются — достаточно enum + switch или стратегии. Когда «состояние» на самом деле про оркестрацию между сервисами — лучше явный конечный автомат или паттерн Saga. Когда число состояний и переходов взрывается от количества, получается целый «зоопарк классов» — используем таблицы переходов вместо десятков мелких классов.

Strategy

Поведенческий паттерн, который выносит взаимозаменяемые алгоритмы за общий интерфейс и подставляет нужный вариант динамически через композицию. Клиент работает с абстракцией, а конкретную реализацию можно переключать без if и else, и правок вызывающего кода.

Автомобильные пробки и пробки, и пробки. Они повсюду, иногда даже снятся. Навигатор с режимами прокладки маршрута: «быстрее», «короче», «без платных дорог». Точка отправления А и точка назначения B те же, интерфейс один — меняется только стратегия расчёта. Хочешь — переключайся на лету.

Любители пробок. Их жизнь — сплошной Ла-ла-лэнд
Любители пробок. Их жизнь — сплошной Ла-ла-лэнд

Расчёт стоимости — сложная операция, которая вполне может быть реализована паттерном Стратегия:

Расчёт стоимости товара
@FunctionalInterface
interface PricingStrategy {
    BigDecimal price(BigDecimal base);
}

// Контекст
final class PriceCalculator {
    private PricingStrategy strategy;
    PriceCalculator(PricingStrategy s) { this.strategy = Objects.requireNonNull(s); }
    void setStrategy(PricingStrategy s) { this.strategy = Objects.requireNonNull(s); }

    BigDecimal checkout(BigDecimal base) {
        BigDecimal r = Objects.requireNonNull(strategy).price(Objects.requireNonNull(base));
        if (r.signum() < 0) r = BigDecimal.ZERO;                 // инвариант: не уходим в минус
        return r.setScale(2, RoundingMode.HALF_UP);              // единое правило округления
    }
}

// Стратегии
record NoDiscount() implements PricingStrategy {
    public BigDecimal price(BigDecimal base) { return base; }
}

record PercentOff(int percent) implements PricingStrategy {
    public PercentOff {                                        // compact ctor (Java 16+)
        if (percent < 0 || percent > 100) throw new IllegalArgumentException("0..100%");
    }
    public BigDecimal price(BigDecimal base) {
        BigDecimal factor = BigDecimal.ONE.subtract(
                BigDecimal.valueOf(percent).movePointLeft(2)); // percent/100
        return base.multiply(factor);
    }
}

record FlatOff(BigDecimal amount) implements PricingStrategy {
    public FlatOff {
        Objects.requireNonNull(amount);
        if (amount.signum() < 0) throw new IllegalArgumentException("amount >= 0");
    }
    public BigDecimal price(BigDecimal base) { return base.subtract(amount); }
}

// Демонстрация
public class StrategyDemo {
    public static void main(String[] args) {
        var base = new BigDecimal("499.90");
        var calc = new PriceCalculator(new NoDiscount());

        System.out.println(calc.checkout(base)); // 499.90

        calc.setStrategy(new PercentOff(20)); // -20%
        System.out.println(calc.checkout(base)); // 399.92

        calc.setStrategy(new FlatOff(new BigDecimal("50")));
        System.out.println(calc.checkout(base)); // 449.90

        // Можно подставить стратегию лямбдой
        calc.setStrategy(p -> p.multiply(new BigDecimal("0.85"))); // -15%
        System.out.println(calc.checkout(base)); // 424.92
    }
}

Калькулятор знает только про интерфейс PricingStrategy, а конкретные алгоритмы (без учёта скидки, процентная, фиксированная) взаимозаменяемы и выбираются в рантайме.

Сценарий применимости

Когда нужно варьировать поведение в зависимости от условий или конфигурации, отделить «что» и «как» делаем, упростить тестирование, мокируя стратегию — используем на здоровье. Кроме того, подходит, если количество вариантов растёт и их хочется добавлять, пожалуйста, без каскада условий.

Сценарий неприменимости

Избыточна, когда алгоритмы отличаются лишь параметром — проще использовать один алгоритм с настройками. Если вариантов мало и не меняются, то дополнительный слой абстракции скорее усложнит чтение, чем принесёт реальную пользу. В горячих местах помним о накладных расходах на динамическую диспетчеризацию и возможном «зоопарке» мелких классов. В Java нередко достаточно передать лямбду или function вместо полноразмерного класса-стратегии.

Template Method (Шаблонный метод)

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

Рецепт на приготовление блюда в нашей голове в общем случае имеет чёткую структуру: что-то помыть, что-то разогреть, потом обжарить и положить на тарелку. Как творцы-поварята мы можем варьировать начинку, специи, длительность, но последовательность действий остаётся чёткой.

Рассмотрим на примере полезного синхронизированного алгоритма полезной работы:

Шаблонный метод run()
// Шаблон (Template)
abstract class SyncJob<T> {
    // фиксируем скелет алгоритма
    public final void run() {
        lock();
        try {
            var data = fetch();  // 1) достаём данные
            var prepared = transform(data);// 2) преобразуем
            persist(prepared);   // 3) сохраняем
        } finally {
            unlock();
        }
    }

    // шаги по умолчанию/крючки
    protected void lock()   { System.out.println("lock"); }
    protected void unlock() { System.out.println("unlock"); }
    protected T transform(T data) { return data; } // hook — можно не переопределять

    // обязательные шаги для подклассов
    protected abstract T fetch();
    protected abstract void persist(T prepared);
}

// Конкретные варианты (подклассы меняют шаги, порядок неизменен)
final class UserSyncJob extends SyncJob<List<String>> {
    @Override 
    protected List<String> fetch() {
      return List.of("ivan", "petr");
    }
  
    @Override 
    protected List<String> transform(List<String> d) {
      return d.stream().map(String::toUpperCase).toList();
    }
  
    @Override 
    protected void persist(List<String> users) {
      System.out.println("save users " + users);
    }
}

final class ProductSyncJob extends SyncJob<List<String>> {
    @Override protected List<String> fetch() {
      return List.of("tv", "phone");
    }
  
    @Override protected void persist(List<String> items) {
      System.out.println("save products " + items);
    }
}

// Демонстрация
public class Main {
    public static void main(String[] args) {
        new UserSyncJob().run();    
        // lock -> fetch -> transform -> persist -> unlock
        new ProductSyncJob().run();
        // тот же порядок, другие реализации шагов
    }
}

«Скелет» алгоритма зашит в SyncJob.run(), а вариативность вынесена в переопределяемые шаги (fetch/transform/persist). Порядок этапов не меняется, что и делает это Шаблонным методом.

Сценарий применимости

Когда у нескольких вариантов алгоритма есть общий жёсткий порядок этапов, а различается только наполнение шагов — применяем с радостью. Нужны инварианты и единые точки до и/или после шагов (логирование, метрики, транзакции), а расширяемость достигается переопределением отдельных этапов, не ломая схему, — используем не стесняясь.

Сценарий неприменимости

Если порядок шагов должен меняться динамически или часть шагов опциональна или переставляется — лучше Strategy или политики с конвейером функций. Когда вариантов маловато и различия — лишь параметры, наследование даст лишнюю иерархию. С ростом числа шагов поддерживать шаблонный метод становится критически сложно. Кроме того, легко нарушить принцип подстановки Барбары Лисков, изменяя кардинально базовое поведение одного из шагов алгоритма через подкласс.

Visitor (Посетитель)

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

Оценщик недвижимости проходит по комнатам квартиры. Они всегда одинаковы и не требуют изменений под каждого специалиста, лишь принимая их внутрь. Сегодня пришел оценщик, завтра придёт — электрик, на следующей неделе — специалист пожарной безопасности и газовой службы. Объект тот же, добавляются новые проверки.

Рассмотрим на примере ресурса — обычного документа, по которому нужно собирать информацию или представления:

Посетители документа для сбора статистики и чтения в нужном формате
// Элементы (Element)
sealed interface DocPart permits PlainText, BoldText, Picture {
    void accept(DocVisitor v);
}
record PlainText(String text) implements DocPart {
    public void accept(DocVisitor v){ v.visit(this);}
}
// аналогично
record BoldText  (String text) implements DocPart { (...) }
record Picture   (String alt ) implements DocPart { (...) }

// Посетитель (Visitor)
interface DocVisitor {
    void visit(PlainText t);
    void visit(BoldText t);
    void visit(Picture p);
}

// Операция 1: HTML-рендер (элементы не меняем)
final class HtmlVisitor implements DocVisitor {
    private final StringBuilder out = new StringBuilder();
    
    public void visit(PlainText t){ out.append(t.text()); }
    
    public void visit(BoldText t) {
      out.append("<b>").append(t.text()).append("</b>");
    }
  
    public void visit(Picture p)  {
      out.append("<img alt='").append(p.alt()).append("'/>");
    }
  
    String result(){ return out.toString(); }
}

// Операция 2: статистика
final class StatsVisitor implements DocVisitor {
    int images, chars;
    
    private void add(String s)    { chars += s.length(); }
    public void visit(PlainText t){ add(t.text()); }
    public void visit(BoldText t) { add(t.text()); }
    public void visit(Picture p)  { images++;      }
}

// Демонстрация
public class Main {
    public static void main(String[] args) {
        List<DocPart> doc = List.of(
                new PlainText("Hello "),
                new BoldText("world"),
                new Picture("logo")
        );

        var html  = new HtmlVisitor();
        var stats = new StatsVisitor();

        doc.forEach(p -> p.accept(html));
        doc.forEach(p -> p.accept(stats));

        System.out.println(html.result());
        // Hello <b>world</b><img alt='logo'/>
        System.out.println("chars=" + stats.chars + 
                           ", images=" + stats.images);
        // chars=10, images=1
    }
}

Иерархия элементов стабильна, а операции (рендер, статистика и другие) добавляются как новые посетители без правок в классах элементов.

Сценарий применимости

Когда нужно выполнить какую-то операцию над всеми элементами сложной структуры объектов, например, деревом. Когда над объектами такой сложной структуры надо выполнять некоторые не связанные между собой операции, но мы не хотим «засорять» классы такими операциями. Также паттерн имеет смысл, когда новое поведение распространяется только на некоторые классы сложной структуры.

Сценарий неприменимости

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

Заключение

Можем гордиться тем, что мы героически разобрались с поведенческими, структурными и порождающими паттернами за цикл из трёх статей. Мы отважно переоткрыли для себя шаблоны «банды четырёх». В бурных обсуждениях каждый сформировал свой современный взгляд на паттерны объектно-ориентированного проектирования.

Вот и всё… Неплохая получилась история. Интересная, весёлая, порой немного грустная, а главное — поучительная…Она научила нас быть смелыми и не бояться вызовов, которые готовит нам жизнь. Помогала нам добиваться поставленных целей, несмотря ни на что.

Присоединяйтесь к моему Telegram-каналу. Я публикую в нём полезные материалы, ресурсы для подготовки к собеседованию. Если у вас появились вопросы, идеи или замечания, делитесь ими в комментариях. Вместе мы сделаем наш путь ещё интереснее и полезнее.

До встречи в будущих статьях!

© 2025 ООО «МТ ФИНАНС»

Комментарии (2)


  1. br0mberg Автор
    03.11.2025 13:29

    Чуть не забыл.
    Ссылка на репозиторий: https://github.com/br0mberg/GOF-patterns-java


  1. federini123
    03.11.2025 13:29

    Как всегда классно. После прочтения вашей статьи о RAG на Java, страшно что-то пропустить :)
    Так держать