JavaRush /Курсы /Java Server /Точка сборки: wiring и граф объектов

Точка сборки: wiring и граф объектов

Java Server
18 уровень , 4 лекция
Открыта

1. Зависимости и точка сборки

Если вчера ваш сервис мог «втихаря» внутри себя написать new HttpClient() и new ObjectMapper(), то сегодня мы стараемся так не делать. И это правильно: класс начинает выглядеть как нормальный взрослый компонент, а не как самосборный шкаф из магазина «Собери себя сам». Но у любого взрослого компонента есть обратная сторона — он требует, чтобы кто-то его создал и подключил к остальным.

Вот тут у новичков часто происходит маленький когнитивный диссонанс. С одной стороны, мы говорим: «Не создавайте зависимости внутри классов, передавайте их через конструктор». С другой стороны, мы всё равно должны где-то написать new. Никуда не денешься: Java пока не умеет материализовать CatalogService из воздуха силой мысли (хотя некоторые IDE ведут себя так, будто умеют).

Поэтому появляется ключевая идея сегодняшней лекции: в приложении должно быть одно понятное место, где “разрешено” собирать объекты и связывать их в рабочую систему. Это место называют composition root (корень композиции) или, более бытовым языком, точка сборки приложения.

Composition root и new

Composition root — это не магический паттерн из книги «Архитектура для тех, кто любит страдать». Это очень простая дисциплина: весь “монтаж проводов” между объектами живёт в одном месте, обычно рядом с точкой входа (в нашем случае — в пакете app). Тогда вы открываете один файл и сразу видите, из каких крупных частей состоит приложение, и кто кого вызывает.

Удобно мыслить так: бизнес-код (сервисы, репозитории, клиенты) должен быть похож на приборы — они делают работу. А composition root — это электрощиток, где вы подключаете приборы к сети и друг к другу. Приборы не должны сами прокладывать провода по квартире. Иначе получится весело, но недолго.

Чтобы было совсем приземлённо, давайте зафиксируем простое правило в табличке. Она не «про красоту», а про то, где вы будете искать баги и как быстро сможете поменять поведение приложения.

Место в проекте Что там допустимо Почему
com.example.readlater.app Создавать объекты через new, выбирать реализации (mock/real), связывать всё в рабочую схему Это «монтажная зона»: здесь видно всю картину
catalog.service, readinglist.service Делать прикладную работу, вызывать зависимости, но не создавать их внутри Иначе сервис становится непереиспользуемым и “липнет” к конкретной реализации
catalog.client, readinglist.repository Реализация конкретной зависимости (HTTP, хранение) Здесь “железо”, но не “электрощиток”
dto Только форма данных DTO не должен знать, кто и где его создаёт

Это правило не запрещает вам делать new ReadingStatus(...) (хотя enum всё равно не создаётся). Речь именно про инфраструктурные и прикладные зависимости: HttpClient, ObjectMapper, CatalogClient, CatalogService, контроллеры/адаптеры входного слоя.

2. Object graph: связи приложения

Когда вы собрали объекты и связали их, у вас получилась не «куча экземпляров», а граф объектов (object graph). Слово звучит как что-то из математики, но по факту это просто ответ на вопрос: «Какие объекты живут в памяти и кто на кого ссылается через поля?». Если вы понимаете этот граф, вы понимаете приложение на уровне механики, а не на уровне надежд.

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

Нарисуем (упрощённо) object graph для клиентской части ReadLater Starter:

flowchart TD
    A["ReadLaterApplication main"] --> B["CommandRouter"]
    B --> C["CatalogCommandController"]
    C --> D["CatalogService"]
    D --> E["CatalogClient"]
    E --> F["HttpClient"]
    E --> G["ObjectMapper"]

Здесь важны две идеи. Во‑первых, направление связей читается сверху вниз: от входа к прикладной логике и дальше к инфраструктуре. Во‑вторых, HttpClient и ObjectMapper — это низкоуровневые “кирпичи”, которые обычно выгодно создавать один раз и переиспользовать, а не плодить по экземпляру на каждый вызов.

3. Анти‑паттерн: сборка везде

Чаще всего код деградирует не потому, что кто-то злой и хочет хаоса. Он деградирует потому, что «так быстрее прямо сейчас». Самый типичный сценарий: вы вынесли кусок кода из main() в CatalogService, но внутри сервиса оставили создание зависимостей. Формально вы “разделили на классы”. По сути вы просто спрятали хаос глубже, чтобы он не смущал вас своим видом.

Вот пример того, что выглядит «удобно», но на практике ломает заменяемость и тестируемость:

import java.net.http.HttpClient;

public final class CatalogService {
    // Плохо: сервис сам выбирает реализацию и способ создания зависимости
    // (снаружи это не видно и это сложно подменять в тестах).
    private final CatalogClient client =
            new CatalogHttpClient(HttpClient.newHttpClient(), ObjectMapperFactory.create()); // зависимости спрятаны
}

Снаружи непонятно, что сервис вообще использует HTTP. Ещё хуже: вы не можете быстро подменить CatalogHttpClient на CatalogMockClient, потому что сервис сам решил за вас, какой клиент ему нравится. Это примерно как если бы чайник сам выбирал себе розетку в стене и при необходимости немного переставлял мебель.

Правильный вариант мы уже обсуждали: сервису “дают” зависимость, а не “вшивают” её внутрь:

public final class CatalogService {
    private final CatalogClient client;

    public CatalogService(CatalogClient client) {
        // Хорошо: зависимость приходит снаружи (из composition root)
        this.client = client;
    }
}

И вот теперь появляется логичный следующий шаг: раз сервис сам не создаёт CatalogClient, значит кто-то должен создать CatalogClient и передать в CatalogService. Это и есть работа composition root.

4. Вариант №1: main() как точка сборки

Иногда у студентов возникает ощущение, что “сборка в main() — это плохая практика”. На самом деле она не плохая и не хорошая. Она просто очень честная: вы видите, что создаётся, в каком порядке, и кто куда передаётся. Для маленького проекта это даже плюс: меньше файлов, меньше прыжков по коду.

Главное условие: main() не должен выполнять бизнес-логику, не должен делать HTTP-вызовы и не должен разбирать JSON. Он должен создать нужные объекты и передать управление дальше.

Пример “терпимого” main() как точки сборки:

import java.net.http.HttpClient;

public final class ReadLaterApplication {
    public static void main(String[] args) {
        // Низкоуровневая инфраструктура — обычно создаётся один раз на запуск
        HttpClient httpClient = HttpClient.newHttpClient();

        // Общий JSON-mapper — тоже часть инфраструктуры, его не нужно плодить по всему коду
        var objectMapper = ObjectMapperFactory.create();

        // Wiring: собираем граф сверху вниз (или снизу вверх — как вам понятнее)
        var catalogClient = new CatalogHttpClient(httpClient, objectMapper);
        var catalogService = new CatalogService(catalogClient);
        var controller = new CatalogCommandController(catalogService);

        // Точка передачи управления: дальше работает входной слой приложения
        new CommandRouter(controller).run(args);
    }
}

Обратите внимание на важную деталь: здесь нет “логики каталога”, здесь только wiring. Если вы поймали себя на мысли «а давайте тут же распарсим args и сделаем search», значит main() снова начал пухнуть и вам пора вынести routing в CommandRouter (что мы и делаем).

Но есть честный минус: как только добавятся новые части (readinglist, общие утилиты, ещё одна команда), main() начнёт разрастаться. И тогда вы захотите второй вариант.

5. Вариант №2: класс ApplicationBootstrap

Когда main() начинает напоминать список покупок на неделю (вроде всё важное, но читать тяжело), мы выносим сборку в отдельный класс, который занимается только этим. Часто его называют Bootstrap, Assembler, ApplicationFactory — название не столь важно. Важно, что он живёт в пакете app и остаётся единственной точкой, где мы связываем крупные компоненты.

Тонкий main() при таком подходе выглядит почти как “включатель”:

public final class ReadLaterApplication {
    public static void main(String[] args) {
        // Bootstrap отвечает только за сборку и связывание объектов
        ApplicationBootstrap bootstrap = new ApplicationBootstrap();

        // Router — это уже “входной слой” приложения, который принимает внешний ввод
        CommandRouter router = bootstrap.createRouter();

        // Передаём управление дальше
        router.run(args);
    }
}

Теперь вся “кухня” спрятана не внутри сервисов (что плохо), а в одном специально выделенном месте (что хорошо). Причём спрятана она не магией, а обычным Java-кодом — вы можете открыть ApplicationBootstrap и увидеть всё.

Сам bootstrap обычно держит низкоуровневые зависимости как поля, чтобы не создавать их каждый раз заново:

import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.http.HttpClient;

public final class ApplicationBootstrap {
    // Общие низкоуровневые зависимости: создаём один раз на запуск
    private final HttpClient httpClient = HttpClient.newHttpClient();

    // Маппер тоже удобно держать общим: одинаковые настройки для всего приложения
    private final ObjectMapper objectMapper = ObjectMapperFactory.create();
}

Тут важно не впасть в крайность “всё сделать static”. Поля bootstrap — это нормально: это просто один объект, который создаётся при старте и содержит «общие детали сборки». Это ещё не глобальные синглтоны, это обычное явное владение зависимостями.

6. Граф: catalog search и catalog details

Теперь соберём кусочки в более цельную схему, близкую к нашему проекту. Мы будем придерживаться логики “снизу вверх”: сначала создаём низкоуровневые штуки (HttpClient, ObjectMapper), потом клиент каталога, потом сервис, потом входной слой (контроллер команд), потом роутер.

Начнём с маленькой фабрики ObjectMapper. Её удобно держать в common, потому что JSON нам нужен и в каталоге, и позже в других местах проекта.

import com.fasterxml.jackson.databind.ObjectMapper;

public final class ObjectMapperFactory {
    private ObjectMapperFactory() { }

    public static ObjectMapper create() {
        // Единое место, где настраивается ObjectMapper.
        // Если позже понадобятся модули/настройки — добавите их здесь.
        return new ObjectMapper();
    }
}

Теперь — wiring для каталога. Верхний пакет app оставляем для ReadLaterApplication, CommandRouter и ApplicationBootstrap, а входной код самой feature держим рядом с ней — в catalog.command. Ниже нам важен именно wiring-срез: он не повторяет всю CLI-логику, а показывает, кто кого получает через конструктор.

public final class CatalogCommandController {
    private final CatalogService catalogService;

    public CatalogCommandController(CatalogService catalogService) {
        // Важно: контроллер получает зависимость извне, а не создаёт её
        this.catalogService = catalogService;
    }

    public void search(String query) {
        // Контроллер тонкий: просто делегирует в прикладной слой
        catalogService.search(query);
    }
}

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

Теперь — bootstrap, который умеет собирать каталоговую часть:

import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.http.HttpClient;

public final class ApplicationBootstrap {
    // Низкоуровневые зависимости переиспользуем
    private final HttpClient httpClient = HttpClient.newHttpClient();
    private final ObjectMapper objectMapper = ObjectMapperFactory.create();

    public CatalogCommandController createCatalogController() {
        // Здесь “переключатель” реализаций: можно подменить HTTP-клиент на mock
        CatalogClient client = new CatalogHttpClient(httpClient, objectMapper);

        // Сервис получает клиента через конструктор (явная зависимость)
        CatalogService service = new CatalogService(client);

        // Контроллер получает сервис через конструктор
        return new CatalogCommandController(service);
    }
}

Это уже похоже на нормальную сборку: видно порядок, видно связи, видно где “переключатель” реализации (в этой версии всегда CatalogHttpClient, но именно bootstrap — то место, где можно выбрать CatalogMockClient, не лезя в сервисы).

И здесь хорошо видно, за что plain Java заставляет платить руками: мы сами выбираем real/mock-реализацию, сами создаём общие инфраструктурные объекты вроде HttpClient и ObjectMapper, сами прокидываем их по конструкторам до нужного места. Позже Spring container будет собирать этот object graph за нас, но сами роли и зависимости не исчезнут. Если сервису нужен клиент, а клиенту нужен mapper, это всё равно надо продумать нам — просто без ручного wiring-boilerplate.

Осталось связать это с роутером команд. Роутер — часть пакета app, потому что это входной уровень всего приложения (он не “про каталог”, он “про запуск приложения”).

import java.util.Arrays;

public final class CommandRouter {
    private final CatalogCommandController catalog;

    public CommandRouter(CatalogCommandController catalog) {
        // Router получает контроллер(ы) — это его зависимости входного слоя
        this.catalog = catalog;
    }

    public void run(String[] args) {
        // Минимальная валидация входа: если аргументов мало — ничего не делаем
        if (args.length < 3) return;

        // Routing: выбираем команду и делегируем в нужный контроллер
        if ("catalog".equals(args[0]) && "search".equals(args[1])) {
            // Собираем query из оставшихся аргументов
            String q = String.join(" ", Arrays.copyOfRange(args, 2, args.length));
            catalog.search(q);
        }
    }
}

Здесь намеренно минимум логики. Роутер не делает HTTP, не маппит JSON — он только принимает внешний вход (args) и направляет его дальше. Да, пока он умеет только catalog search. Вы можете расширить аналогично и на catalog details, но даже в таком виде хорошо видно: routing живёт в одном месте, а не размазан по сервисам.

Теперь финальный кусочек: bootstrap собирает роутер и подсовывает ему уже собранный контроллер.

public final class ApplicationBootstrap {
    public CommandRouter createRouter() {
        // Собираем входной слой приложения из уже собранных кусочков
        return new CommandRouter(createCatalogController());
    }
}

И всё вместе в голове превращается в понятную историю: ReadLaterApplication создаёт bootstrap, bootstrap создаёт роутер, роутер зовёт контроллер, контроллер зовёт сервис, сервис зовёт клиента. Ровно тот object graph, который мы рисовали диаграммой.

7. Типичные ошибки при manual wiring

Ошибка №1: часть графа создаётся в bootstrap, а часть — “тихо” внутри сервисов.
Это самая коварная поломка дисциплины. Снаружи кажется: “ну мы же уже сделали bootstrap”. Но если внутри CatalogService всё равно живёт new CatalogHttpClient(...), вы фактически сделали два composition root: один видимый и один скрытый. В результате у вас появляется непредсказуемое поведение, дублирование HttpClient и невозможность честно заменить реализацию зависимости.

Ошибка №2: сборка дублируется в нескольких местах «потому что так быстрее».
Сегодня вы собрали CatalogService в ReadLaterApplication, завтра — в ApplicationBootstrap, послезавтра — в каком-нибудь CatalogUtils.createService(). Работать будет, но проект начнёт вести себя как гидра: поменяли конструктор — и внезапно надо править три разных места, иначе часть режимов запуска сломается. Один граф — одна точка сборки.

Ошибка №3: превращать bootstrap в место бизнес‑логики.
Bootstrap — это не “сервисный сервис”. Он не должен решать, как искать книги или как обрабатывать ошибки. Его задача — собрать объекты. Если вы поймали себя на том, что в bootstrap появились условия типа “если запрос пустой — возвращаем ошибку”, значит у вас бизнес-логика уехала не туда. Сборка должна быть короткой и скучной. Скучный bootstrap — признак здоровья проекта.

Ошибка №4: глобальные static‑поля как «быстрое DI».
Иногда хочется сделать public static final ObjectMapper MAPPER = ... и успокоиться. Да, это быстро. Но затем вы захотите иметь разные настройки маппера или подменить его в тестовом сценарии, и внезапно окажется, что у вас всё прибито гвоздями к одному месту. В учебном проекте мы тренируем привычку явных зависимостей: если объект нужен — пусть он приходит через конструктор, а создаётся в composition root.

Ошибка №5: создавать “на всякий случай” вообще всё, даже то, что в этом запуске не нужно.
Ручная сборка тем и хороша, что вы можете видеть цену каждого new. Если вы создаёте кучу объектов, которые не используются в текущей команде, вы либо тратите ресурсы зря, либо (хуже) незаметно запускаете побочные эффекты. Хорошая сборка не обязана быть ленивой и хитрой, но она обязана быть осознанной: вы понимаете, зачем создан каждый объект.

1
Задача
Java Server, 18 уровень, 4 лекция
Недоступна
Один composition root для команды поиска
Один composition root для команды поиска
1
Задача
Java Server, 18 уровень, 4 лекция
Недоступна
Выбор реализации в `ApplicationBootstrap`
Выбор реализации в `ApplicationBootstrap`
1
Опрос
Архитектура приложения, 18 уровень, 4 лекция
Недоступен
Архитектура приложения
Слои, зависимости и композиция
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ