Проектування баз даних

Google Guice: Впровадження залежностей у JavaFX-проєкті

Від ручного зв'язування об'єктів до повноцінного IoC-контейнера: архітектура Google Guice, анотаційне впровадження, модулі, скопи та інтеграція з JavaFX через ControllerFactory.

Google Guice: Впровадження залежностей у JavaFX-проєкті

Вступ: Коли граф залежностей стає лабіринтом

Уявіть типову сцену розробки. Ви відкриваєте контролер JavaFX, що відповідає за список треків аудіоплатформи. Там, всередині конструктора або методу initialize(), ховається ось такий код:

public class TrackListController {

    private final TrackService trackService;

    public TrackListController() {
        ConnectionManager connectionManager = new ConnectionManager(
            "jdbc:h2:./data/audiobook;MODE=PostgreSQL",
            "sa", ""
        );
        TrackRepository trackRepository = new JdbcTrackRepository(connectionManager);
        TrackValidator validator = new TrackValidator();
        AudioFileProcessor processor = new AudioFileProcessor("/tmp/uploads");
        this.trackService = new TrackService(trackRepository, validator, processor);
    }
}

З першого погляду — нічого катастрофічного. Але подивіться уважніше: контролер сам вирішує, як саме побудувати весь граф своїх залежностей. Він знає про ConnectionManager, про шлях до файлів завантаження, про TrackValidator. Він прив'язаний до конкретних реалізацій усіх своїх залежностей так міцно, наче виготовлений разом з ними на одному заводі.

Проблема не в тому, що цей код не працює. Він працює. Проблема в тому, що відбувається далі.

Перший симптом — неможливість тестування. Щоб написати unit-тест для TrackListController, вам доведеться або підіймати реальну базу даних H2 (це вже інтеграційний тест), або вдаватись до складних маніпуляцій з рефлексією. Підмінити TrackRepository на mock-об'єкт неможливо: він жорстко закодований усередині конструктора.

Другий симптом — жорсткість при зміні реалізацій. Вирішили замінити H2 на PostgreSQL? Або AudioFileProcessor на хмарне сховище? Вам потрібно знайти кожне місце, де ці класи інстанціюються через new, і оновити їх вручну. У великому проєкті таких місць можуть бути десятки.

Третій симптом — дублювання ініціалізації. ConnectionManager зі своїм URL та обліковими даними створюється у кожному DAO-класі, у кожному тесті, у кожному місці, де потрібен доступ до бази. Жодної гарантії узгодженості.

Це не погані розробники — це природний наслідок відсутності систематичного підходу до управління залежностями. І саме цю проблему вирішує патерн Dependency Injection (Впровадження залежностей) та бібліотека Google Guice, яку ми вивчатимемо у цій статті.

Google Guice (вимовляється «джус») — легковагий фреймворк для впровадження залежностей у Java, розроблений командою Google у 2006 році. Він є основою для внутрішньої інфраструктури Google та використовується у тисячах відкритих і закритих проєктів. Офіційна документація

Ручне зв'язування

Підхід: new ServiceA(new RepoA(new ConnMgr(...)))

Кожен клас сам створює залежності. Швидко для маленьких проєктів, але стає некерованим при зростанні. Тестування — лише інтеграційне.

Фабрики та Сервіс-локатор

Підхід: ServiceFactory.create("trackService")

Централізоване створення об'єктів, але клієнт сам шукає залежності. Залежність від фабрики — прихована зв'язаність.

IoC-контейнер (Guice)

Підхід: @Inject TrackService trackService

Контейнер будує весь граф залежностей автоматично. Клас лише декларує потреби — хто і як їх задовольнить, вирішує зовні.


Фундаментальні концепції: IoC, DI та принцип інверсії залежностей

Інверсія управління (Inversion of Control)

Інверсія управління (IoC) — це архітектурний принцип, згідно з яким управління потоком виконання програми передається від самого коду до зовнішнього фреймворку або контейнера. Термін систематизований Мартіном Фаулером у 2004 році, хоча сам принцип існував значно раніше.

Щоб зрозуміти «інверсію», необхідно спочатку усвідомити те, що саме інвертується. У традиційній програмі ваш код контролює все: він викликає бібліотечні функції, вирішує, коли і що створювати, сам організовує порядок дій. У IoC-підході фреймворк контролює загальний потік виконання, а ваш код лише реагує на події та надає конкретні реалізації через інтерфейси або анотації.

Голлівудський принцип (Hollywood Principle) — влучна метафора для IoC: «Не телефонуйте нам, ми самі зателефонуємо вам». Ваш код не «шукає» залежності — контейнер сам «передзвонює» й надає їх.

Dependency Injection (Впровадження залежностей) — це конкретний механізм реалізації IoC. Замість того щоб клас самостійно створював свої залежності через new, він декларує їх через конструктор, сеттери або поля, а зовнішній код (контейнер) впроваджує їх ззовні.

Принцип інверсії залежностей (DIP)

Принцип інверсії залежностей (Dependency Inversion Principle, DIP) — п'ятий принцип SOLID — формулюється так:

  1. Модулі верхнього рівня не повинні залежати від модулів нижнього рівня. Обидва повинні залежати від абстракцій.
  2. Абстракції не повинні залежати від деталей. Деталі повинні залежати від абстракцій.

На практиці це означає: TrackService (бізнес-логіка, верхній рівень) не повинен залежати від JdbcTrackRepository (деталь реалізації, нижній рівень). Обидва мають залежати від інтерфейсу TrackRepository. Це та «абстракція», що дозволяє замінити реалізацію без зміни споживача.

Рис. 1 ілюструє трансформацію архітектури: ✅ Після DI: IoC-контейнерTrackListControllernew TrackService(...)new JdbcTrackRepository(...)TrackServiceJdbcTrackRepositoryConnectionManagerAudioFileProcessorКонтролер знає ВСЕ про реалізаціїGuice InjectorModule + Bindingsбудує граф автоматичноTrackListControllerTrackServiceJdbcTrackRepositoryConnectionManagerКонтролер знає лише інтерфейси ::

Три типи впровадження залежностей

Специфікація Jakarta Inject (колишня javax.inject) визначає три місця, де можуть впроваджуватись залежності. Guice підтримує всі три, але надає різні рекомендації щодо їх застосування.

public class TrackService {

    private final TrackRepository repository;
    private final TrackValidator validator;

    @Inject
    public TrackService(TrackRepository repository, TrackValidator validator) {
        this.repository = repository;
        this.validator  = validator;
    }
}

Guice бачить анотацію @Inject на конструкторі та автоматично передає всі параметри. Поля оголошені final — залежності незмінні після ініціалізації. Клас неможливо створити без усіх залежностей: це унеможливлює часткову ініціалізацію. Для тестування достатньо передати mock у конструктор напряму, без жодних фреймворків.

Рекомендація Guice: завжди надавайте перевагу Constructor Injection. Він є найексплікитнішим, найбезпечнішим і найзручнішим для тестування. Field Injection виправданий лише для ситуацій, коли конструктор недоступний (наприклад, при успадкуванні від класу без @Inject конструктора).

Архітектура Google Guice: ключові компоненти

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

Module (Модуль) — це декларативний опис зв'язувань (bindings). Модуль — це місце, де ви як розробник кажете Guice: «якщо хтось попросить TrackRepository, надай йому JdbcTrackRepository». Модуль реалізується через успадкування від AbstractModule та перевизначення методу configure().

Injector (Інжектор) — центральний реєстр усіх прив'язок і фабрика об'єктів. Він створюється один раз, на старті застосунку, через Guice.createInjector(module). Після цього Injector є єдиним джерелом правди щодо того, які об'єкти і як створювати. У JavaFX-застосунку він живе протягом усього часу роботи програми.

Binding (Прив'язка) — зв'язок між ключем (зазвичай інтерфейсом або класом) та способом його задоволення (реалізацією, провайдером або екземпляром). Bindings описуються у Module і реєструються в Injector.

@Inject — анотація з пакету jakarta.inject (або com.google.inject), що позначає точки впровадження: конструктори, поля або методи. Це єдиний маркер, якого потребує клас; він не прив'язує клас до Guice як до фреймворку.

Provider<T> — функціональний інтерфейс для ліниво або параметризовано створюваних об'єктів. Guice може впроваджувати Provider<TrackService> замість TrackService — це дає отримувачу контроль над часом та кількістю створення екземплярів.

AppModule extends AbstractModule TrackRepository → JdbcTrackRepository AuthorRepository → JdbcAuthorRepository "dbUrl" → "jdbc:h2:./data/..." ConnectionManager → @Singleton Injector Guice.createInjector (new AppModule()) реєстр + фабрика configure() TrackListController TrackService JdbcTrackRepository ConnectionManager JdbcAuthorRepository getInstance() / @Inject

Послідовність роботи Guice

Розуміння порядку, в якому Guice виконує свою роботу, є критично важливим для правильного проєктування застосунку. Цей порядок незмінний і не залежить від того, скільки модулів та прив'язок ви визначаєте.

Loading diagram...
sequenceDiagram
    participant Main as main()
    participant Guice as Guice
    participant Module as AppModule
    participant Injector as Injector
    participant App as JavaFX App

    Main->>Guice: createInjector(new AppModule())
    Guice->>Module: configure()
    Module-->>Guice: bindings зареєстровано
    Guice-->>Main: injector (готовий реєстр)
    Main->>Injector: getInstance(TrackListController.class)
    Injector->>Injector: визначити залежності @Inject
    Injector->>Injector: рекурсивно вирішити TrackService
    Injector->>Injector: рекурсивно вирішити JdbcTrackRepository
    Injector->>Injector: вирішити ConnectionManager (@Singleton)
    Injector-->>App: готовий контролер з усіма залежностями

Зверніть увагу на рекурсивне вирішення: щоб створити TrackListController, Guice спочатку створює TrackService; щоб створити TrackServiceJdbcTrackRepository; щоб створити JdbcTrackRepositoryConnectionManager. Весь цей граф вирішується автоматично, на основі анотацій @Inject у конструкторах та прив'язок у AppModule. Розробнику не потрібно описувати порядок ініціалізації — Guice визначає його сам.


Налаштування проєкту: від нуля до першого Injector

Перейдемо від теорії до практики. У цьому розділі ми налаштуємо Google Guice у JavaFX-проєкті аудіоплатформи та побудуємо фундамент: підключимо залежності, опишемо перший модуль та отримаємо перший об'єкт через Injector.

Крок 1: Додати залежності до системи збірки

Guice доступний через Maven Central. Нам потрібні два артефакти: сам guice та jakarta.inject-api — специфікація анотацій @Inject, @Singleton.

<dependencies>
    <!-- Google Guice — IoC-контейнер -->
    <dependency>
        <groupId>com.google.inject</groupId>
        <artifactId>guice</artifactId>
        <version>7.0.0</version>
    </dependency>
    <!-- JavaFX FXML-підтримка -->
    <dependency>
        <groupId>org.openjfx</groupId>
        <artifactId>javafx-fxml</artifactId>
        <version>21.0.3</version>
    </dependency>
</dependencies>
Версія Guice 7.x: починаючи з версії 7.0, Guice перейшов з пакету javax.inject на jakarta.inject. Якщо ваш проєкт залежить від старих бібліотек зі javax.inject, використовуйте Guice 6.x, що підтримує обидва простори імен.

Крок 2: Налаштувати module-info.java

Якщо проєкт використовує Java Platform Module System (JPMS), необхідно відкрити пакети для Guice-рефлексії:

module-info.java
module com.example.audiobook {
    requires javafx.controls;
    requires javafx.fxml;
    requires com.google.guice;
    requires jakarta.inject;

    // opens — глибокий рефлексивний доступ у runtime (потрібен Guice)
    opens com.example.audiobook.controller to javafx.fxml, com.google.guice;
    opens com.example.audiobook.service    to com.google.guice;
    opens com.example.audiobook.repository to com.google.guice;
    exports com.example.audiobook;
}

Ключове слово opens (на відміну від exports) дозволяє саме глибокий рефлексивний доступ у runtime — це необхідно Guice для впровадження залежностей. Якщо пакет не відкрито, Guice викине InaccessibleObjectException при спробі створити об'єкт.

Крок 3: Описати перший Module

AppModule.java
package com.example.audiobook;

import com.example.audiobook.repository.*;
import com.google.inject.AbstractModule;
import com.google.inject.name.Names;

public class AppModule extends AbstractModule {

    @Override
    protected void configure() {
        bind(TrackRepository.class).to(JdbcTrackRepository.class);
        bind(AuthorRepository.class).to(JdbcAuthorRepository.class);

        bindConstant().annotatedWith(Names.named("dbUrl"))
            .to("jdbc:h2:./data/audiobook;MODE=PostgreSQL;DB_CLOSE_DELAY=-1");
        bindConstant().annotatedWith(Names.named("dbUser")).to("sa");
        bindConstant().annotatedWith(Names.named("dbPassword")).to("");
    }
}

Кожен виклик bind(...).to(...) — одна прив'язка. bindConstant().annotatedWith(Names.named(...)).to(...) дозволяє впроваджувати прості значення (рядки, числа) через анотацію @Named на параметрах конструктора.

Крок 4: Впровадити залежності у клас

ConnectionManager.java
import com.google.inject.Inject;
import com.google.inject.Singleton;
import com.google.inject.name.Named;

@Singleton
public class ConnectionManager {

    private final String url;
    private final String user;
    private final String password;

    @Inject
    public ConnectionManager(
        @Named("dbUrl")      String url,
        @Named("dbUser")     String user,
        @Named("dbPassword") String password
    ) {
        this.url      = url;
        this.user     = user;
        this.password = password;
    }

    public Connection getConnection() throws SQLException {
        return DriverManager.getConnection(url, user, password);
    }
}

@Singleton на класі декларує єдиний екземпляр на весь Injector. Анотації @Named(...) на параметрах вказують Guice знайти саме ту константу, що зв'язана у модулі через Names.named(...) з відповідним ім'ям.

Крок 5: Створити Injector і отримати перший об'єкт

Main.java
import com.google.inject.Guice;
import com.google.inject.Injector;

public class Main {
    public static void main(String[] args) {
        // Єдина точка створення Injector у всьому застосунку
        Injector injector = Guice.createInjector(new AppModule());

        // Guice рекурсивно вирішує весь граф залежностей
        TrackService trackService = injector.getInstance(TrackService.class);
        trackService.listAll().forEach(System.out::println);
    }
}

Структура проєкту після налаштування:

Ніколи не зберігайте Injector у статичному полі та не передавайте його класам як залежність — це антипатерн Service Locator. Клас, що отримує Injector, сам витягує залежності й унеможливлює тестування. Правильна практика: Injector живе лише у Main.java та App.java.

Типи прив'язок (Bindings) та анотації Guice

Метод configure() у AbstractModule — це не просто перелік рядків коду. Це повноцінна декларативна мова опису залежностей. Guice підтримує кілька типів прив'язок, кожен з яких вирішує конкретний сценарій.

Linked Binding — зв'язування інтерфейсу з реалізацією

Найпоширеніший тип. Дозволяє замінити реалізацію без зміни жодного класу-споживача.

// Коли хтось запросить TrackRepository — отримає JdbcTrackRepository
bind(TrackRepository.class).to(JdbcTrackRepository.class);

// З явним Scope: лише один JdbcTrackRepository на весь Injector
bind(TrackRepository.class).to(JdbcTrackRepository.class).in(Singleton.class);

Зверніть увагу: JdbcTrackRepository сам також може мати @Inject-конструктор і власні залежності — Guice рекурсивно вирішить їх автоматично.

Instance Binding — впровадження готового екземпляра

Коли об'єкт уже створений (або не може бути створений Guice) — передайте його напряму:

// Передаємо готовий об'єкт — Guice не створює його сам
ConnectionManager manager = ConnectionManager.forH2("./data/audiobook");
bind(ConnectionManager.class).toInstance(manager);

// Або через статичну фабрику
bind(DataSource.class).toInstance(createHikariDataSource());

toInstance() автоматично реєструє переданий об'єкт як Singleton — він завжди повертає той самий екземпляр.

Provider Binding — відкладене або параметризоване створення

Provider<T> — це фабрика, що викликається Guice щоразу, коли потрібен новий об'єкт. Використовується, коли логіка створення складніша за простий new.

// Клас-провайдер для DataSource (наприклад, HikariCP)
public class HikariDataSourceProvider implements Provider<DataSource> {

    private final String url;
    private final String user;

    @Inject
    public HikariDataSourceProvider(
        @Named("dbUrl")  String url,
        @Named("dbUser") String user
    ) {
        this.url  = url;
        this.user = user;
    }

    @Override
    public DataSource get() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(url);
        config.setUsername(user);
        config.setMaximumPoolSize(10);
        return new HikariDataSource(config);
    }
}

// У модулі:
bind(DataSource.class).toProvider(HikariDataSourceProvider.class).in(Singleton.class);

HikariDataSourceProvider сам також отримує @Inject-залежності. Поєднання з in(Singleton.class) гарантує, що пул з'єднань створюється рівно один раз.

@Named та кастомні кваліфікатори

Коли один інтерфейс має кілька реалізацій — Guice потребує підказки, яку саме надавати. Для цього існують кваліфікатори (qualifiers).

Варіант 1 — @Named (найпростіший):

// У модулі — дві реалізації одного інтерфейсу
bind(TrackRepository.class)
    .annotatedWith(Names.named("jdbc"))
    .to(JdbcTrackRepository.class);

bind(TrackRepository.class)
    .annotatedWith(Names.named("cache"))
    .to(InMemoryCacheTrackRepository.class);

// У споживачі — явно вказуємо, яка потрібна
@Inject
public TrackService(
    @Named("jdbc")  TrackRepository primary,
    @Named("cache") TrackRepository cache
) { ... }

Варіант 2 — кастомний кваліфікатор (@BindingAnnotation):

// Оголошення кваліфікатора — окрема анотація
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@BindingAnnotation
public @interface ReadOnly {}

// У модулі
bind(TrackRepository.class)
    .annotatedWith(ReadOnly.class)
    .to(ReadOnlyJdbcTrackRepository.class);

// У споживачі
@Inject
public AnalyticsService(@ReadOnly TrackRepository repo) { ... }

Кастомні кваліфікатори кращі за @Named у великих проєктах: вони рефакторяться разом з кодом (компілятор перевірить назву анотації, але не перевірить рядок у @Named).

Типи Bindings у Guice Linked Binding bind(Interface.class) .to(Implementation.class) Заміна реалізації без зміни споживачів. Найпоширеніший. Instance Binding bind(Cls.class) .toInstance(obj) Готовий об'єкт. Автоматично є Singleton. Provider Binding bind(Cls.class) .toProvider(Provider.class) Складна логіка створення. HikariCP, кешування. Constant Binding bindConstant() .annotatedWith(@Named) Рядки, числа, enum-значення. URL, порти, ліміти. @Named (рядковий кваліфікатор) bind(Repo.class).annotatedWith(Names.named("jdbc")).to(...) bind(Repo.class).annotatedWith(Names.named("cache")).to(...) Швидко, але refactoring-небезпечно: рядок не перевіряє компілятор @BindingAnnotation (кастомний кваліфікатор) @Retention(RUNTIME) @BindingAnnotation public @interface ReadOnly {} Compile-time безпека. Рекомендовано для великих проєктів.
@Inject
annotation
Позначає конструктор, поле або метод як точку впровадження. Guice автоматично надає значення при створенні об'єкта. Є у пакеті jakarta.inject (стандарт) та com.google.inject (Guice-специфічний).
@Singleton
annotation
Декларує, що Guice повинен створити рівно один екземпляр класу на весь Injector. Можна застосувати до класу або у виклику bind(...).in(Singleton.class). Потоко-безпечний: Guice гарантує атомарність створення.
@Named
annotation
Кваліфікатор для розрізнення кількох прив'язок одного типу. Використовується разом з Names.named(...) у configure(). Доступний з com.google.inject.name та jakarta.inject.
@BindingAnnotation
annotation
Мета-анотація для створення кастомних кваліфікаторів. Оголошена на вашій анотації дає Guice знати, що вона є кваліфікатором. Перевіряється компілятором — безпечніша за @Named.
@Provides
annotation
Альтернатива класу-провайдеру: метод у Module з анотацією @Provides стає фабрикою. Зручніше для простих випадків.

Метод @Provides як зручна альтернатива Provider-класу

Замість окремого класу, що реалізує Provider<T>, можна написати метод прямо у Module:

public class AppModule extends AbstractModule {

    @Override
    protected void configure() {
        bind(TrackRepository.class).to(JdbcTrackRepository.class);
    }

    // Guice автоматично визнає цей метод як провайдер для DataSource
    @Provides
    @Singleton
    DataSource provideDataSource(
        @Named("dbUrl")  String url,
        @Named("dbUser") String user
    ) {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(url);
        config.setUsername(user);
        config.setMaximumPoolSize(10);
        return new HikariDataSource(config);
    }
}

Параметри методу @Provides автоматично впроваджуються Guice — він вирішує їх так само, як і параметри @Inject-конструкторів. Поєднання @Provides + @Singleton гарантує, що HikariCP-пул буде створено рівно один раз.

Коли обирати @Provides vs клас-провайдер: якщо логіка провайдера проста та умістилась в один метод — використовуйте @Provides. Якщо провайдер складний, потребує стану або хочеться тестувати його окремо — винесіть у власний клас, що реалізує Provider<T>.

Інтеграція з JavaFX: вирішення ключової суперечності

Переходимо до центрального практичного питання цієї статті: як поєднати Guice з JavaFX? На перший погляд завдання виглядає тривіально — просто додайте @Inject до контролерів. Але реальність складніша.

Проблема: FXMLLoader — прихований конкурент

JavaFX використовує FXMLLoader для завантаження .fxml-файлів та автоматичного створення контролерів, описаних у fx:controller. Цей механізм сам інстанціює контролери через рефлексію, викликаючи конструктор за замовчуванням (без параметрів). Guice при цьому не задіяний — він нічого не знає про ці об'єкти, і всі @Inject-поля залишаються null.

Якщо у контролері написати:

public class TrackListController {
    @Inject
    private TrackService trackService; // завжди null при стандартному FXMLLoader!
}

Метод initialize() викличеться, trackService буде null, і застосунок впаде з NullPointerException. Це класична пастка, у яку потрапляє кожен, хто вперше намагається поєднати JavaFX та DI.

Рішення: setControllerFactory()

FXMLLoader передбачає цю ситуацію. Метод setControllerFactory(Callback<Class<?>, Object> factory) дозволяє замінити стандартний механізм створення контролерів на довільну фабрику. І саме тут з'являється Guice:

FXMLLoader loader = new FXMLLoader(getClass().getResource("/fxml/track-list.fxml"));

// Передаємо Injector як фабрику контролерів
// injector::getInstance — це method reference на Injector.getInstance(Class)
loader.setControllerFactory(injector::getInstance);

Parent root = loader.load();

Тепер, коли FXMLLoader зустрічає fx:controller="...TrackListController", він не викликає new TrackListController(), а передає клас у injector.getInstance(TrackListController.class). Guice будує контролер зі всіма залежностями через @Inject-конструктор.

main() Application.launch() App.start() extends Application Guice.createInjector (new AppModule()) FXMLLoader .setControllerFactory(injector::getInstance) fx:controller= "TrackListCtrl" Injector.getInstance (TrackListController.class) @Inject → вирішення залежностей TrackListController з TrackService, NavigationService… Scene / Stage primaryStage.setScene(...) ① createInjector ② setControllerFactory ③ loader.load() ④ getInstance(ctrl) ⑤ @Inject вирішено ⑥ show()

Повна реалізація: App + GuiceModule + контролер

Розглянемо повний, робочий приклад для нашої аудіоплатформи.

package com.example.audiobook;

import com.google.inject.Guice;
import com.google.inject.Injector;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;

public class App extends Application {

    // Injector доступний у межах App — і нікуди далі
    private Injector injector;

    @Override
    public void init() {
        // init() — правильне місце для Guice. Викликається до start(),
        // у окремому потоці JavaFX Launcher Thread, до JavaFX Application Thread.
        injector = Guice.createInjector(new AppModule());
    }

    @Override
    public void start(Stage primaryStage) throws Exception {
        FXMLLoader loader = new FXMLLoader(
            getClass().getResource("/fxml/track-list.fxml")
        );
        // Підключаємо Guice як фабрику контролерів
        loader.setControllerFactory(injector::getInstance);

        Parent root = loader.load();
        primaryStage.setTitle("Аудіоплатформа");
        primaryStage.setScene(new Scene(root, 900, 600));
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Розберемо ключові архітектурні рішення у прикладі вище.

init() замість start(): метод init() класу Application викликається до відображення будь-якого UI і є правильним місцем для ініціалізації важких ресурсів (бази даних, пулів, Guice-контейнера). Якщо ініціалізація Injector займає час (наприклад, перевіряє з'єднання з БД), вона не блокує JavaFX Application Thread.

NavigationService з Injector: це єдиний виправданий випадок, коли клас зберігає посилання на Injector. NavigationService виступає централізованим сервісом навігації — він відкриває нові вікна/діалоги, завантажуючи FXML і передаючи injector::getInstance як фабрику. Всі інші класи отримують залежності виключно через конструктор.

Constructor Injection у контролері: незважаючи на те, що FXMLLoader традиційно очікує конструктор без параметрів, після підключення setControllerFactory Guice передає повністю сформований об'єкт. Поля @FXML заповнює сам FXMLLoader після отримання контролера від Guice, тому initialize() отримує і залежності (@Inject), і UI-компоненти (@FXML).

Не впроваджуйте Stage, Scene або Parent через Guice @Inject. Ці об'єкти мають бути створені у JavaFX Application Thread, але Guice може будувати граф залежностей у будь-якому потоці. Передавайте UI-об'єкти явно через параметри методів або через NavigationService.
App startup — Guice initialization log
$ mvn javafx:run
[INFO] Building audiobook-platform 1.0-SNAPSHOT
[Guice] Stage 1: Module configuration started
[Guice] Binding: TrackRepository → JdbcTrackRepository
[Guice] Binding: AuthorRepository → JdbcAuthorRepository
[Guice] Binding: @Named("dbUrl") → "jdbc:h2:./data/audiobook..."
[Guice] Stage 2: Injector created (12 bindings registered)
[H2] Connection established: jdbc:h2:./data/audiobook
[JavaFX] Loading FXML: /fxml/track-list.fxml
[Guice] getInstance(TrackListController) — resolved in 3ms
[JavaFX] Stage shown — application ready ✓

Скопи (Scopes) та час життя об'єктів

Одне з найважливіших рішень при проєктуванні DI-архітектури — скільки екземплярів певного класу повинно існувати у системі та як довго. Guice вирішує цю задачу через механізм скопів (Scopes).

Prototype — скоп за замовчуванням

Якщо жодного скопу не вказано, Guice поводиться як фабрика без пам'яті: кожен виклик getInstance() або кожне впровадження @Inject отримує новий екземпляр. Цей режим називається Prototype або No Scope.

// Guice створює новий TrackValidator при кожному впровадженні
public class TrackService {
    @Inject
    public TrackService(TrackValidator validator) { ... }
}

public class AudioProcessor {
    @Inject
    public AudioProcessor(TrackValidator validator) { ... }
}
// TrackService та AudioProcessor отримають РІЗНІ екземпляри TrackValidator

Prototype ідеально підходить для stateful об'єктів, де кожен споживач повинен мати власний, незалежний стан: form-objects, команди (Command pattern), тимчасові обчислювальні об'єкти.

@Singleton — один на весь Injector

@Singleton гарантує, що Guice створить клас рівно один раз протягом усього часу життя Injector. Усі, хто залежать від цього класу, отримають той самий екземпляр.

@Singleton
public class ConnectionManager {
    // Конструктор викликається один раз — при першому запиті
    @Inject
    public ConnectionManager(@Named("dbUrl") String url, ...) { ... }
}

Альтернативний спосіб — вказати скоп у модулі, не чіпаючи клас:

// У AppModule.configure() — корисно, коли клас з бібліотеки
bind(ConnectionManager.class).in(Singleton.class);
// або через статичний імпорт:
bind(ConnectionManager.class).in(Scopes.SINGLETON);

@Singleton ідеальний для: репозиторіїв, сервісів, пулів з'єднань, кешів, NavigationService.

Eager vs Lazy Singleton

За замовчуванням @Singleton є lazy (ліниво створюваним): Guice створює екземпляр лише при першому зверненні. Якщо потрібно створити його при старті застосунку (наприклад, щоб перевірити з'єднання з БД негайно), використовуйте asEagerSingleton():

// У AppModule.configure()
bind(ConnectionManager.class).asEagerSingleton();
// Guice створить ConnectionManager відразу при Guice.createInjector()
// Якщо ініціалізація провалиться — застосунок не запуститься

asEagerSingleton() підходить для критичних ресурсів, відсутність яких є фатальною для застосунку: бази даних, черги повідомлень, ліцензійні перевірки.

Lifecycle об'єктів у Guice час виконання застосунку → Injector.createInjector() Запит 1 Запит 2 Запит 3 Prototype new Instance #1 new Instance #2 new Instance #3 Кожен запит отримує новий об'єкт. Немає спільного стану. Singleton Lazy: перший запит той самий екземпляр — завжди Один екземпляр на весь Injector. Потоко-безпечний. Eager ↑ створено при createInjector() той самий екземпляр — завжди asEagerSingleton(): ініціалізація при старті. Збій = застосунок не запуститься. 💡 Repository, Service, Pool → @Singleton 💡 FormObject, Command, DTO → Prototype

Thread Safety у Singleton

@Singleton у Guice є потоко-безпечним з точки зору створення: Guice гарантує, що конструктор викличеться рівно один раз, навіть при одночасних запитах з кількох потоків. Але стан синглтона — ваша відповідальність. Якщо @Singleton-клас зберігає змінні поля, вони повинні бути синхронізовані або використовуватись лише з JavaFX Application Thread.

JavaFX-контролери не повинні бути @Singleton. Кожне відкриття FXML-сцени створює новий контролер — і Guice повинен мати можливість це зробити. Якщо контролер є Singleton, другий виклик loader.load() поверне той самий об'єкт зі старим UI-станом, що спричинить непередбачувану поведінку.

Тестування з Google Guice

Одна з головних переваг DI — тестованість. Guice надає елегантний механізм підміни залежностей у тестах без зміни продуктивного коду.

Проблема без DI

Уявімо, що TrackService без Guice сам створює свій репозиторій:

public class TrackService {
    // Жорстка залежність — підмінити неможливо без рефлексії
    private final TrackRepository repo = new JdbcTrackRepository(
        new ConnectionManager("jdbc:h2:./data/test", "sa", "")
    );
}

Щоб протестувати TrackService.getTopRatedTracks(), ми змушені підняти H2-базу, наповнити її тестовими даними, і виконати реальний SQL-запит. Це інтеграційний тест, а не unit-тест — він повільніший, нестабільніший і залежить від середовища.

Modules.override() — хірургічна підміна

Guice надає метод Modules.override(base).with(override), що дозволяє замінити конкретні прив'язки базового модуля тестовими, не змінюючи жодного рядка продуктивного коду:

// Тестовий модуль — лише ті прив'язки, що потрібно замінити
public class TestAppModule extends AbstractModule {
    @Override
    protected void configure() {
        // Замінюємо JDBC-репозиторій на in-memory заглушку
        bind(TrackRepository.class).to(InMemoryTrackRepository.class);
        // Замінюємо реальний URL на тестовий (H2 in-memory)
        bindConstant()
            .annotatedWith(Names.named("dbUrl"))
            .to("jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1");
    }
}
// Тест сервісу — без реальної бази даних
class TrackServiceTest {

    private TrackService trackService;
    private InMemoryTrackRepository fakeRepository;

    @BeforeEach
    void setUp() {
        // Базовий модуль + тестові override
        Injector injector = Guice.createInjector(
            Modules.override(new AppModule()).with(new TestAppModule())
        );
        trackService   = injector.getInstance(TrackService.class);
        fakeRepository = (InMemoryTrackRepository)
            injector.getInstance(TrackRepository.class);
    }

    @Test
    void shouldReturnTopRatedTracks() {
        // Arrange: наповнюємо in-memory сховище тестовими даними
        fakeRepository.save(new Track("Beethoven - Symphony No.9", 5.0));
        fakeRepository.save(new Track("Mozart - Requiem",          4.8));
        fakeRepository.save(new Track("Bach - Goldberg Variations", 3.2));

        // Act
        List<Track> top = trackService.getTopRatedTracks(2);

        // Assert
        assertThat(top).hasSize(2);
        assertThat(top.get(0).getTitle()).contains("Beethoven");
    }
}

Метод Modules.override(base).with(override) будує новий об'єднаний модуль. Усі прив'язки з override мають вищий пріоритет і перекривають відповідні прив'язки з base. Решта прив'язок з AppModule залишаються незмінними — наприклад, TrackService, TrackValidator та всі інші класи, що не потребують підміни у тесті.

Тестування з Mockito без TestModule

Якщо ви використовуєте Mockito, можна не писати TestModule взагалі — використайте toInstance() з mock-об'єктом:

@ExtendWith(MockitoExtension.class)
class TrackServiceTest {

    @Mock
    private TrackRepository mockRepository;

    private TrackService trackService;

    @BeforeEach
    void setUp() {
        // Передаємо Mockito-mock напряму через toInstance()
        Injector injector = Guice.createInjector(binder ->
            binder.bind(TrackRepository.class).toInstance(mockRepository)
        );
        trackService = injector.getInstance(TrackService.class);
    }

    @Test
    void shouldDelegateToRepository() {
        // Arrange
        when(mockRepository.findAll()).thenReturn(List.of(
            new Track("Test Track", 4.0)
        ));

        // Act
        List<Track> result = trackService.listAll();

        // Assert
        assertThat(result).hasSize(1);
        verify(mockRepository, times(1)).findAll();
    }
}

Зверніть увагу: binder -> binder.bind(...).toInstance(...) — це лямбда, що реалізує інтерфейс Module. Guice підтримує цей скорочений синтаксис, що робить тестові конфігурації надзвичайно компактними.

Золоте правило тестування з Guice: тест повинен перевіряти один клас у ізоляції. Підміняйте через Modules.override() або toInstance() лише ті залежності, що є зовнішніми по відношенню до тестованого класу (БД, файлова система, HTTP-клієнти). Внутрішні залежності (валідатори, маппери, утиліти) залишайте реальними — вони є частиною контракту тестованого класу.

Практичні завдання

Завдання розбиті на три рівні складності. Рівні 1 і 2 є самостійними, рівень 3 утворює міні-проєкт — повноцінну JavaFX-сцену з Guice від нуля до готового застосунку.

Рівень 1 — Базовий: Синтаксис та анотації

Завдання 1.1 — Перший Module. Напишіть клас AudioModule, що розширює AbstractModule. У методі configure() зв'яжіть:

  • AuthorRepositoryJdbcAuthorRepository
  • Константу @Named("maxPoolSize") → значення 10 (тип int)
  • Константу @Named("uploadPath") → рядок "/var/audiobook/uploads"

Перевірте, що Guice може створити Injector без помилок.

Завдання 1.2 — Впровадження через конструктор. Візьміть клас AuthorService з попередньої статті, що має поля authorRepository та validator. Перепишіть його так, щоб обидві залежності впроваджувались через @Inject-конструктор із final полями. Переконайтесь, що клас неможливо інстанціювати без Guice без явної передачі залежностей у конструктор.

Завдання 1.3 — Виправлення антипатерну. Знайдіть помилку у такому коді та поясніть, чому вона є проблемою:

@Singleton
public class PlaybackService {

    @Inject
    private Injector injector; // впроваджуємо сам контейнер

    public TrackPlayer createPlayer() {
        return injector.getInstance(TrackPlayer.class); // Service Locator!
    }
}

Перепишіть клас правильно — через Provider<TrackPlayer>.


Рівень 2 — Логіка: Провайдери та тестування

Завдання 2.1 — Provider для пулу з'єднань. Реалізуйте клас HikariConnectionPoolProvider implements Provider<DataSource>. Він повинен отримувати через @Inject такі параметри: @Named("dbUrl") String url, @Named("dbUser") String user, @Named("dbPassword") String password, @Named("maxPoolSize") int maxPoolSize. У методі get() створюйте і налаштовуйте HikariDataSource. Зареєструйте провайдер у AudioModule через bind(DataSource.class).toProvider(HikariConnectionPoolProvider.class).in(Singleton.class).

Завдання 2.2 — Тест з Modules.override(). Напишіть клас AudioServiceTest з JUnit 5. У методі setUp() створіть Injector через Modules.override(new AudioModule()).with(testModule), де testModule підміняє AuthorRepository на InMemoryAuthorRepository (проста реалізація у пам'яті на основі HashMap). Напишіть тест shouldFindAuthorByName(), що перевіряє логіку пошуку без жодного підключення до бази даних.

Завдання 2.3 — Кастомний кваліфікатор. Створіть анотацію @Primary (@BindingAnnotation, @Retention(RUNTIME), @Target(FIELD, PARAMETER)). Зареєструйте у модулі дві реалізації TrackRepository:

  • @Primary TrackRepositoryJdbcTrackRepository (основна)
  • TrackRepository без кваліфікатора → InMemoryCacheTrackRepository (кеш)

Впровадьте обидві у TrackService і реалізуйте метод findById(UUID id), що спочатку перевіряє кеш, і лише якщо запис не знайдено — звертається до @Primary-репозиторію.


Рівень 3 — Архітектура: JavaFX-застосунок з Guice

Ці завдання є послідовними кроками до реалізації повноцінного JavaFX-застосунку аудіоплатформи з Guice.

Завдання 3.1 — Базова інтеграція. Налаштуйте JavaFX-застосунок з Guice за схемою, описаною у цій статті:

  • Створіть клас App extends Application з методом init(), де ініціалізується Injector.
  • Реалізуйте головну сцену main-view.fxml з контролером MainController, що має @Inject-конструктор з TrackService та AuthorService.
  • Підключіть setControllerFactory(injector::getInstance) і переконайтесь, що сцена відображається без NullPointerException.
  • Додайте лог-повідомлення в конструктор MainController та всіх сервісів — переконайтесь у правильному порядку ініціалізації.

Завдання 3.2 — NavigationService. Реалізуйте NavigationService @Singleton, що підтримує такі операції:

  • showTrackList() — відкрити сцену зі списком треків
  • showAuthorList() — відкрити сцену зі списком авторів
  • openAddTrackDialog() — відкрити модальний діалог додавання треку
  • goBack() — повернутись до попередньої сцени (зберігайте стек Deque<Stage>)

Впровадьте NavigationService у три різних контролери. Переконайтесь, що всі три отримують той самий екземпляр (@Singleton), і навігація між сценами працює коректно.

Завдання 3.3 — Кастомний Scope @PerWindow. Реалізуйте власний Guice Scope, що гарантує: кожне нове вікно JavaFX отримує свій екземпляр певного сервісу (наприклад, TrackEditState, що зберігає стан форми редагування), але в межах одного вікна — завжди той самий. Для цього:

  • Оголосіть анотацію @PerWindow (@ScopeAnnotation).
  • Реалізуйте клас WindowScope implements Scope з методами enter() та exit().
  • Зареєструйте scope у модулі: bindScope(PerWindow.class, windowScope).
  • Протестуйте: відкрийте два вікна редагування і переконайтесь, що кожне має власний TrackEditState.

Підсумок

У цій статті ми пройшли шлях від ручного зв'язування залежностей через new до повноцінного IoC-контейнера, інтегрованого у JavaFX-застосунок. Ключові ідеї, які варто зафіксувати:

Що вивчили

  • DI/IoC/DIP — три концепції, що доповнюють одна одну
  • Guice Module — декларативний опис залежностей
  • Injector — центральна фабрика об'єктів
  • setControllerFactory — ключ до JavaFX-інтеграції
  • Scopes — управління lifecycle об'єктів

Ключові анотації

  • @Inject — точка впровадження (конструктор!)
  • @Singleton — один екземпляр на Injector
  • @Named — рядковий кваліфікатор
  • @BindingAnnotation — кастомний кваліфікатор
  • @Provides — метод-фабрика у Module

Типові помилки

  • Зберігати Injector поза App / NavigationService
  • @Singleton на JavaFX-контролерах
  • Field Injection замість Constructor Injection
  • Впроваджувати Stage/Scene через Guice
  • Не відкривати пакети у module-info.java

Що далі

Офіційні ресурси:
Copyright © 2026