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

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

Краткий экскурс

В прошлой части этой статьи мы дали определение тому, что есть такое паттерны. Если кратко:

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

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

Разобрались, на какие три семейства разделяются шаблоны: порождающие, структурные и поведенческие. Также не забыли про деление по уровню связей: классовые и объектные.
Паттерны ООП c примерами на Java: порождающие шаблоны.

Структурные паттерны

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

Adapter

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

Представьте, идеальный мир, где есть всего две роли: разработчик и бизнес. Представили? Классно? Но реальность сурова — нужны «переводчики» между мирами, которые смогут разговаривать на обоих языках. В этом и суть Адаптера, подружить несовместимые интерфейсы, чтобы они работали вместе.

Подружить интерфейсы несложно
Подружить интерфейсы несложно

Рассмотрим на примере доставки грузов. Наша компания занимается грузовыми перевозками по ж/д путям. Пока всё просто: у нас семейство перевозчиков с единым интерфейсом FreightCarrier. Клиентский код умеет работать только через этот интерфейс. В примере «родным» перевозчиком выступает Train, который уже реализует FreightCarrier.

Система до интеграции внешнего грузоперевозчика
// Target — единый интерфейс для «семейства перевозчиков»
@FunctionalInterface
interface FreightCarrier {
    Result deliver(Cargo cargo, String from, String to);
}
enum Result { OK, FAIL }
record Cargo(String id, int kg) {}

// «Родной» перевозчик — поезд
final class Train implements FreightCarrier {
    @Override public Result deliver(Cargo c, String from, String to) {
      return Result.OK;
    }
}

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

Наши контракты взаимодействия несовместимы: другой интерфейс, другие статус-коды, свои исключения. Если подключать интерфейс «в лоб», придётся ломать клиентский код. Из лучших побуждений добавляем обёртку, которая приводит «чужой» интерфейс к нашему FreightCarrier.

Добавляем внешнего поставщика грузоперевозок
// «Чужой» API грузовиков (менять нельзя)
final class TruckApi {
    int create(int kg, String from, String to) throws TruckEx {
        if (kg <= 0) throw new TruckEx("Bad weight");
        return 0;
    }
}
final class TruckEx extends Exception { TruckEx(String m){ super(m); } }

// Adapter/обёртка — делает TruckApi совместимым с FreightCarrier
final class TruckAdapter implements FreightCarrier {
    private final TruckApi api;

    // фиксируем коды чужого API внутри адаптера
    private static final int OK = 0;
    private static final int NO_DRIVER = 1;
    private static final int BAD_ADDRESS = 2;

    TruckAdapter(TruckApi api) {
        this.api = Objects.requireNonNull(api);
    }

    @Override
    public Result deliver(Cargo c, String from, String to) {
        try {
            int code = api.create(c.kg(), from, to);
            return map(code);
        } catch (TruckEx e) {
            return Result.FAIL;
        }
    }

    private static Result map(int code) {
        return switch (code) {
            case OK -> Result.OK;
            case NO_DRIVER, BAD_ADDRESS -> Result.FAIL;
            default -> Result.FAIL;
        };
    }
}

Для клиента ничего не меняется — в этом и ценность адаптера. Мы подружили несовместимые интерфейсы. Клиент продолжает работать через FreightCarrier. Поменяется поставщик — изменим только адаптер.

// Клиентский код — не важно, поезд или грузовик: интерфейс один
public class Main {
    public static void main(String[] args) {
        FreightCarrier train = new Train();
        // оборачиваем в совместимый интерфейс
        FreightCarrier truck = new TruckAdapter(new TruckApi());

        var cargo = new Cargo("C-101", 500);
        train.deliver(cargo, "Склад A", "Склад B");
        truck.deliver(cargo, "Склад A", "Склад B");
    }
}

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

Резюмируем: у нас были несовместимые интерфейсы. Мы, не изменяя клиентский контракт и внешний интерфейс, добавили тонкую обёртку, которая адаптировала вызовы в формат, доступный клиенту.

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

Когда мы хотим использовать сторонний класс, стороннего поставщика данных и так далее, но его интерфейс не соответствует остальному коду нашего приложения.

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

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

Bridge

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

Представьте, систему коммуникации с клиентом. У нас есть N каналов: sms, push, email и т. д. Кроме того, есть M коммуникаций по типам: уведомление, предложение. Вариаций и комбинаций большое множество: EmailNotifier, EmailOffer, SMSNotifier и так далее.

Собак не хватит, чтобы описать N частей тела и M цветов этих частей
Собак не хватит, чтобы описать N частей тела и M цветов этих частей

Для того чтобы избавиться от m * n комбинаций в коде, рассмотрим пример реализации моста для этого примера:

Мост между группой каналов и группой коммуникаций
// Структуры: получатель, каналы, события
record To(String email, String phone) {}
record Event(String type, String text) {}
record Message(String title, String text, To to, Instant ts) {}

// Implementor: канал доставки
@FunctionalInterface
interface Channel { String send(Message m); }

// Abstraction: тип уведомления; держит ссылку на Channel (МОСТ)
abstract class Notifier {
    private final Channel ch; // композиция  = "мост" между слоями

    Notifier(Channel ch) { this.ch = ch; }

    // для клиента единый контракт
    public String notify(Event e, To to) {
        return ch.send(compose(e, to)); // делегирование реализации
    }

    abstract Message compose(Event e, To to);
}

// Конкретные абстракции: меняется "что отправляем"
final class Alert extends Notifier {
    public Alert(Channel ch) { super(ch); }
    @Override protected Message compose(Event e, To to) {
        return new Message("[ALERT] " + e.type(), e.text(),
                                       to, Instant.now());
    }
}
final class Offer extends Notifier {
    public Offer(Channel ch) { super(ch); }
    @Override protected Message compose(Event e, To to) {
        return new Message("Deal", e.text(), to, Instant.now());
    }
}

// Демонстрация Bridge: свободные комбинации "что" и "как"
public class Main {
    public static void main(String[] args) {
        Channel email = m -> { 
            System.out.println("[EMAIL] " + m.title());
            return "OK";
        };
      
        Channel sms   = m -> {
            System.out.println("[SMS] "   + m.text());
            return "OK";
        };

        var to = new To("user@example.com", "+48111222333");

        new Alert(sms).notify(new Event("DB outage", "Latency > 2s"), to);
        new Offer(email).notify(new Event("Sale", "-20%"), to);
    }
}

Здесь мы разделяем иерархии на две независимые оси, которые можно расширять отдельно. Первая ось — что отправляем (абстракции Notifier: Alert, Offer, формируют Message) и как отправляем (реализация Channel, лямбды — email и SMS). Абстракция Notifier держит ссылку на реализацию Channel. При вызове notify(...) собирает композит Message, после чего делегируем доставку по каналу. Благодаря такому подходу, свободно комбинируются типы сообщений и каналы без множества пар классов и без правок клиентского кода при расширении любой иерархии.

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

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

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

Из минусов подхода, усложнение кода введением дополнительных классов — осознанная плата за независимость. Когда меняется только одна ось/иерархия — паттерн избыточен. Хотим добавить поведение поверх того же интерфейса — больше подходит Декоратор.

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

Composite или Компоновщик

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

Представим себе, меню ресторана в мобильном приложении. В нём есть разделы: «Итальянская кухня», «Русская кухня» и так далее. Внутри ещё разделы: «Горячее», «Напитки», «Закуски». Эта вложенность может многоуровневой, как дерево. Нам нужно одинаково легко считывать меню, вне зависимости от того, узел (объект с вложенностью) перед нами или лист (без вложенностей).

Хорошее место, меню большое, конкурсы интересные. Надеемся, не закроется.
Хорошее место, меню большое, конкурсы интересные. Надеемся, не закроется.

Рассмотрим паттерн как раз на этом примере:

Демонстрация паттерна Компоновщик на примере Меню
// Component — общий контракт, наследуется блюдом и разделом
sealed interface MenuNode permits Dish, Section {
    String render(String indent); // печать дерева
    int countDishes();            // агрегирующая операция
}

// Leaf — лист — блюдо
record Dish(String name, int priceRub) implements MenuNode {
    @Override
    public String render(String indent) {
        return indent + "- " + name + " … " + priceRub + "₽";
    }
    @Override
    public int countDishes() {
        return 1;
    }
}

// Composite — раздел меню, содержит другие узлы
final class Section implements MenuNode {
    private final String name;
    private final List<MenuNode> children = new ArrayList<>();
    Section(String name) {
        this.name = name;
    }

    Section add(MenuNode... nodes) { children.addAll(List.of(nodes)); return this; }

    @Override
    public String render(String indent) {
        var sb = new StringBuilder(indent).append("+ ").append(name).append("\n");
        for (MenuNode n : children) sb.append(n.render(indent + "  ")).append("\n");
        return sb.toString().stripTrailing();
    }

    @Override
    public int countDishes() {
        return children.stream().mapToInt(MenuNode::countDishes).sum();
    }
}

// Демонстрация: работаем с деревом как с одним объектом
public class Main {
    public static void main(String[] args) {
        var pizza   = new Dish("Пицца Маргарита", 550);
        var pasta   = new Dish("Паста Карбонара", 490);
        var kvass   = new Dish("Квас", 120);
        var tea     = new Dish("Чай", 90);

        var italian = new Section("Итальянская кухня").add(pizza, pasta);
        var drinks  = new Section("Напитки").add(kvass, tea);
        var menu    = new Section("Меню").add(italian, drinks);

        System.out.println(menu.render(""));
        System.out.println("Всего блюд: " + menu.countDishes());
    }
}

Контракт MenuNode задаёт две операции — render() для печати дерева и countDishes() для подсчёта блюд. Dish — лист: сам себя отрисовывает и возвращает 1, как единичная позиция без вложенностей. Section — композит: хранит список вложенных объектов chldren из любых MenuNode, при вызове обращается к render() на потомках и агрегирует итог, а в countDishes() суммирует вложенные позиции. В итоге мы построили дерево, с которым можно работать как с одним объектом: печатать всю структуру и получать общее число блюд.

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

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

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

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

Decorator

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

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

Более простой пример: одежда на человеке тоже «обёртка», надевая её слоями, получаем комбинированный эффект полезных функциональностей.

В его случае интерфейс — сухой нос и мокрый язык
В его случае интерфейс — сухой нос и мокрый язык

Декоратор реализует тот же контракт, держит ссылку на «внутренний» объект и добавляет логику до/после/вокруг вызова. Рассмотри на примере обработки сообщений в приложении:

Пример с обёртками над отправителем сообщений
@FunctionalInterface
interface Sender { boolean send(String text); }  // единый контракт

// База (например, HTTP/SMTP/...): просто пример
final class BaseSender implements Sender {
    @Override public boolean send(String text) {
        System.out.println("SEND -> " + text);
        return true;
    }
}

// Декоратор: логирование
final class LoggingSender implements Sender {
    private final Sender inner;
    public LoggingSender(Sender inner) { this.inner = inner; }

    @Override public boolean send(String text) {
        System.out.println("[log] start");
        boolean ok = inner.send(text);
        System.out.println("[log] done ok=" + ok);
        return ok;
    }
}

// Декоратор: ретраи (очень простой)
final class RetrySender implements Sender {
    private final Sender inner; private final int attempts;
    public RetrySender(Sender inner, int attempts) { this.inner = inner; this.attempts = attempts; }

    @Override public boolean send(String text) {
        for (int i = 1; i <= attempts; i++) {
            if (inner.send(text)) return true;
            System.out.println("[retry] attempt " + i + " failed");
        }
        return false;
    }
}

public class Main{
    public static void main(String[] args) {
        Sender base = new BaseSender();
        Sender decorated = new RetrySender(new LoggingSender(base), 3); // наслаиваем
        decorated.send("Hello, world!");
    }
}

LoggingSender и RetrySender — обёртки над тем же Sender. Хотим добавить новые метрики, лимиты и подсчёты — пишем ещё один декоратор и вставляем в цепочку без изменений клиентского контракта.

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

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

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

Не используем шаблон, когда требуется сменить сам алгоритм (паттерн Стратегия), или подогнать интерфейс (Адаптер). Если поведение должно добавляться к целому слою, а не отдельным объектам. Цепочки обёрток могут запросто усложнить отладку и читаемость кода.

Facade

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

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

В качестве примера рассмотрим процесс создания документов:

Пример использования Фасада для операции создания документов
// Доменные объекты
record Draft(String templateId, String payload, String authorId, String approverId) {}
record CreateResult(String docId) {}

// Контракты подсистем
@FunctionalInterface interface TemplateEngine { String render(String templateId, String payload); }
@FunctionalInterface interface Signer         { String sign(String content); }
@FunctionalInterface interface Repository     { String save(String content, String signature); } // -> new docId

// Facade — один простой метод поверх нескольких сервисов
final class DocumentFacade {
    private final TemplateEngine tpl;
    private final Signer signer;
    private final Repository repo;

    DocumentFacade(TemplateEngine tpl, Signer signer, Repository repo) {
        this.tpl = tpl; this.signer = signer; this.repo = repo;
    }

    public CreateResult create(Draft d) {
        String content   = tpl.render(d.templateId(), d.payload());
        String signature = signer.sign(content);
        String docId     = repo.save(content, signature);
        return new CreateResult(docId);
    }
}

// Демонстрация: клиенту доступен один метод create(...)
public class Main {
    public static void main(String[] args) {
        // Построение фасада за ширмой
        TemplateEngine tpl = (id, body) -> "DOC[" + id + "]\n" + body;
        Signer signer      = content -> "sig-" + Integer.toHexString(content.hashCode());
        Repository repo    = (content, sig) -> "doc-" + Math.abs((content + sig).hashCode());
        var facade = new DocumentFacade(tpl, signer, repo);

        // использование на клиенте
        var result = facade.create(new Draft("TPL-001", "Счёт на оплату #42", "user-1", "boss-9"));

        System.out.println("New docId=" + result.docId());
    }
}

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

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

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

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

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

Flyweight

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

В этот раз обратимся к тому, с чем мы имеем дело как Java-разработчики каждый день. String Pool — частный случай Flyweight: одинаковые строковые значения представляются одним общим иммутабельным объектом. Литералы интернируются автоматически, а динамические строки можно явно поместить в пул через intern(). Другими словами, при повторной встрече того же значения JVM вернёт ссылку на уже существующую строку, вместо выделения памяти под экземпляр.

В отличие от Flyweight: да, становятся легче в кг все, кроме одного, но общий то вес не уменьшается
В отличие от Flyweight: да, становятся легче в кг все, кроме одного, но общий то вес не уменьшается
Пример с валютой в роли Flyweight
// Легковес: неизменяемая валюта (общее состояние)
final class Currency {
    private final String code;
    Currency(String code) { this.code = code; }
    public String code() { return code; }
}

// Фабрика/кэш легковесов: один объект на код валюты
final class Currencies {
    private static final Map<String, Currency> cache = new HashMap<>();

    static Currency get(String code) {
        return cache.computeIfAbsent(code, Currency::new);
    }

    static int unique() { return cache.size(); }
}

// Внешнее состояние: сумма в единицах (цент/копейка) + ссылка на валюту
record Money(long minor, Currency cur) {
    String show() { return (minor / 100) + "." + (minor % 100) + " " + cur.code(); }
}

public class Main {
    public static void main(String[] args) {
        var usd = Currencies.get("USD");
        // вернёт один и тот же объект при повторных вызовах
        var eur = Currencies.get("EUR");

        // Тысячи денег ссылаются на пару общих валют
        List<Money> list = List.of(
                new Money(1999, usd), new Money(2500, usd),
                new Money(9900, eur), new Money(1234, usd)
        );

        list.forEach(m -> System.out.println(m.show()));
        System.out.println("Уникальных Currency в кэше: "
                + Currencies.unique());
    }
}

Важно, чтобы состояние легковесов было неизменяемым, поскольку используется во множестве контекстов. Изменение состояния легковеса поддерживается только на этапе создания через конструктор.

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

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

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

Плюс очевиден — экономия оперативной памяти. Из минусов: расход процессорного времени на поиск контекста.

Proxy

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

Способов применения Proxy много, например, вы приходите в магазин бытовой техники и покупаете холодильник фирмы X. Консультант(прокси) перехватывает запрос к работнику склада (реальный объект) и проверяет наличие на витрине (кэш). Если не нашёл, то передаёт запрос клиента на склад.

«А твоё какое дело, дружок? Выбирай, орёл или решка?»
«А твоё какое дело, дружок? Выбирай, орёл или решка?»

Рассмотрим один из примеров Прокси — кэширующий:

Пример кэширующего Proxy
// Subject (контракт)
interface UserRepo { User find(String id); }

// Реальная реализация (дорогая/медленная, имитируем БД)
final class DbUserRepo implements UserRepo {
    private final Map<String, User> db = Map.of(
            "u1", new User("u1", "Alice"),
            "u2", new User("u2", "Bob")
    );

    @Override public User find(String id) {
        simulateLatency();
        System.out.println("[db] query " + id);
        return db.get(id);
    }

    private void simulateLatency() {
        try { Thread.sleep(200); } catch (InterruptedException ignored) {}
    }
}

// Proxy: тот же контракт, но с кэшем перед делегированием
final class CachedUserRepo implements UserRepo {
    private final UserRepo inner;
    private final Map<String, User> cache = new ConcurrentHashMap<>();

    CachedUserRepo(UserRepo inner) { this.inner = inner; }

    @Override public User find(String id) {
        return cache.computeIfAbsent(id, key -> {
            System.out.println("[cache] miss " + key);
            return inner.find(key);
            // дорого - ходим в “реал” только один раз
        });
    }
}

// Простая модель (Java 17 record)
record User(String id, String name) {}

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

        System.out.println(repo.find("u1")); // промах - [db]
        System.out.println(repo.find("u1")); // попадание - из кэша, без [db]
        System.out.println(repo.find("u2")); // промах - [db]
        System.out.println(repo.find("u2")); // попадание - из кэша
    }
}

Клиент работает с UserRepo и ничего не знает о кэшировании. CachedUserRepo — это Proxy: тот же интерфейс, но «делает полезное» (читает кэш) до делегирования в DbUserRepo.

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

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

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

Если цель — просто «добавить поведение вокруг вызова» (логирование, метрики, кэш на уровне бизнес-логики), чаще это Decorator, а не Proxy. Если нужно подогнать интерфейс — это Адаптер. Из явных минусов: увеличение времени отклика от сервиса, поскольку подсовывается дополнительная не бесплатная функциональность.

К слову: в Spring Security проверки вроде @PreAuthorize работают через AOP-прокси — вокруг вашего бина создаётся прокси, который сначала проверяет права, и только потом делегирует вызов «настоящему» объекту.

Заключение второй части

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

Он возвращается сверхсобакой, постигшей структурные паттерны, как и задумывал Кубрик
Он возвращается сверхсобакой, постигшей структурные паттерны, как и задумывал Кубрик

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

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

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

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


  1. wertingo
    22.10.2025 09:17

    Как всегда отлично, доходчиво. Отсылки топ. Можно было бы потокобезопасность прикрутить на мапу, но мб для примера избыточно


  1. NightBlade74
    22.10.2025 09:17

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

    То же самое можно и про Adapter сказать. Самый лучший пример здесь - драйвер устройства. Прикладному разработчику, к примеру, не надо уметь работать со всеми моделями принтеров (да и невозможно), достаточно знать API печати целевой операционки и через него обращаться к абстрактному принтеру, а драйвер принтера как раз таки и является тем самым адаптером.

    Паттерн Bridge как всегда пострадал и получил невнятное описание.

    Если уж взялись давать примеры паттернов на конкретном языке, то можно сразу приводить примеры реализации в этом языке и базовом фреймворке. Отличные примеры: InputStream/OutputStream и xxxReader/xxxWriter к ним как декораторы. Iterator и foreach - пример встраивания шаблона даже не в библиотеку, а прямо в язык.

    Ну и никто не мешает UML диаграммы добавить, лишними не будут, тем более, что все уже украдено до нас.


    1. br0mberg Автор
      22.10.2025 09:17

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

      Пример с драйвером устройства реально академически хороший, но только для разработчика на плюсах. Потому что процент людей, которые занимаются на Java подобным ничтожно мал. Гораздо ближе был бы JDBC-драйвер (адаптирует конкретную БД под единый java.sql апи) раз уж на то пошло, как пример из стандартных библиотек. При внимательном прочтении можно заметить близкий к нему пример прямо в этой статье — удалённый вызов процедур. Хоть его можно обозвать формально, ещё и Proxy.

      У всех свои любимые примеры, ваши ничем не хуже. Спасибо за комментарий.


      1. NightBlade74
        22.10.2025 09:17

        Composite предполагает неограниченную вложенность. Количество узлов и листьев (на одном уровне) никак не связаны с глубиной дерева. И в примере все же было меню ресторана, а не служба доставки, верно? А заставлять продираться пользователя через десять вложенных уровней меню - еще то издевательство. Да и нормальные рестораны стараются меню не раздувать, это дико дорого для бизнеса и головняк для поваров, которым надо сотни рецептов и технологических карт помнить и уметь готовить. А академический пример потому и академический, что понятен и нагляден.

        Когда я писал про драйвер, я писал про любой драйвер, хоть принтера, хоть видеокарты, хоть БД. А пример вывода картинки на экран и принтер - отличный и наглядный адаптера графического устройства.


  1. APKAH9
    22.10.2025 09:17

    А сейчас все под java стандартизировано уже?