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"] --> 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. Граф: пошук у каталозі і деталі каталогу
Тепер зберемо шматочки в більш цілісну схему, близьку до нашого проєкту. Ми дотримуватимемося логіки «знизу вгору»: спочатку створюємо низькорівневі штуки (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. Якщо ви створюєте купу об’єктів, які не використовуються в поточній команді, ви або марнуєте ресурси, або, що гірше, непомітно запускаєте побічні ефекти. Хороше збирання не зобов’язане бути лінивим і хитрим, але воно зобов’язане бути усвідомленим: ви розумієте, навіщо створено кожен об’єкт.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ