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

Інтеграція MVVM з Guice: Автоматична ін'єкція залежностей

Від ручного створення об'єктів до Dependency Injection: Guice Module, ControllerFactory, Constructor Injection, Scopes (Singleton vs Prototype), AssistedInject для параметризованих ViewModel.

Інтеграція MVVM з Guice: Автоматична ін'єкція залежностей

Вступ: Проблема ручного створення ViewModel

У попередніх статтях ми побудували повноцінну MVVM-архітектуру: ViewModel з Properties та Commands, Controller з Bindings, Repository для доступу до даних. Але є одна проблема, яку ми обходили стороною: хто створює всі ці об'єкти?

Розглянемо типовий граф залежностей нашого додатку:

AudiobookListController
    ↓ залежить від
AudiobookListViewModel
    ↓ залежить від
AudiobookService
    ↓ залежить від
AudiobookRepository (JdbcAudiobookRepository)
    ↓ залежить від
DataSource (HikariCP Connection Pool)

Якщо створювати ці об'єкти вручну у Application.start(), код виглядатиме так:

@Override
public void start(Stage primaryStage) throws Exception {
    // Створення DataSource
    HikariConfig config = new HikariConfig();
    config.setJdbcUrl("jdbc:h2:mem:audiobook");
    config.setUsername("sa");
    config.setPassword("");
    DataSource dataSource = new HikariDataSource(config);
    
    // Створення Repository
    AudiobookRepository audiobookRepository = new JdbcAudiobookRepository(dataSource);
    AuthorRepository authorRepository = new JdbcAuthorRepository(dataSource);
    GenreRepository genreRepository = new JdbcGenreRepository(dataSource);
    
    // Створення Service
    AudiobookService audiobookService = new AudiobookService(
        audiobookRepository, 
        authorRepository, 
        genreRepository
    );
    
    // Створення ViewModel
    AudiobookListViewModel viewModel = new AudiobookListViewModel(audiobookService);
    
    // Завантаження FXML
    FXMLLoader loader = new FXMLLoader(getClass().getResource("audiobook-list-view.fxml"));
    Parent root = loader.load();
    
    // Передача ViewModel у Controller
    AudiobookListController controller = loader.getController();
    controller.setViewModel(viewModel);
    
    // Налаштування Scene
    Scene scene = new Scene(root, 900, 600);
    primaryStage.setScene(scene);
    primaryStage.show();
}

Цей код працює, але має кілька критичних проблем:

Проблема 1: Жорстке кодування залежностей. Якщо ми захочемо замінити JdbcAudiobookRepository на InMemoryAudiobookRepository для тестування, доведеться змінювати код у start().

Проблема 2: Дублювання коду. Якщо у нас 10 екранів, кожен з власним Controller та ViewModel, доведеться повторювати цей код 10 разів.

Проблема 3: Порушення Single Responsibility Principle. Application.start() не повинен знати, як створювати Repository, як конфігурувати Connection Pool, як передавати залежності. Його відповідальність — запустити додаток.

Проблема 4: Складність тестування. Щоб протестувати AudiobookListViewModel, потрібно створити весь ланцюжок залежностей вручну. Це робить тести громіздкими та крихкими.

Рішення всіх цих проблем — Dependency Injection (DI) через Google Guice. Guice автоматично створює об'єкти, резолвить залежності, керує життєвим циклом (Singleton vs Prototype), дозволяє легко замінювати реалізації для тестування.

У статті 24 ми вже розглядали основи Guice. Тепер застосуємо його для повної інтеграції з MVVM-архітектурою JavaFX-додатку.

Guice vs Spring: Guice — це легковаговий DI-контейнер від Google, що фокусується на простоті та швидкості. Spring — це повноцінний фреймворк з DI, AOP, транзакціями, веб-сервером. Для desktop JavaFX-додатків Guice — оптимальний вибір: мінімальні залежності, швидкий старт, відсутність "магії" Spring.

Архітектура інтеграції: Guice + JavaFX

Перш ніж писати код, розглянемо архітектуру інтеграції Guice з JavaFX. Ключові компоненти:

Компоненти інтеграції

1. Guice Injector — центральний контейнер, що створює об'єкти та впроваджує залежності. Створюється один раз при старті додатку:

Injector injector = Guice.createInjector(new AudiobookModule());

2. Guice Module — конфігурація bindings: які інтерфейси прив'язані до яких реалізацій, які об'єкти є Singleton, як створювати складні залежності (Provider).

3. ControllerFactory — міст між FXMLLoader та Guice. За замовчуванням FXMLLoader створює Controllers через рефлексію (Class.newInstance()), що не підтримує Constructor Injection. ControllerFactory делегує створення Controllers до Guice Injector.

4. @Inject анотації — маркери для Guice: які конструктори, поля або методи потребують ін'єкції залежностей.

Діаграма архітектури

Loading diagram...

graph TB AppAudiobookApp
Application.start
InjectorGuice Injector ModuleAudiobookModule
configure bindings
FactoryControllerFactory
injector::getInstance
LoaderFXMLLoader CtrlAudiobookListController VMAudiobookListViewModel SvcAudiobookService RepoJdbcAudiobookRepository DSHikariCP DataSource

App -->|1. createInjector| Injector
Injector -->|reads| Module
App -->|2. setControllerFactory| Factory
Factory -->|uses| Injector
App -->|3. load FXML| Loader
Loader -->|4. create Controller| Factory
Factory -->|5. getInstance| Ctrl
Injector -->|6. inject| VM
Injector -->|7. inject| Svc
Injector -->|8. inject| Repo
Injector -->|9. inject| DS

Ctrl -.depends on.-> VM
VM -.depends on.-> Svc
Svc -.depends on.-> Repo
Repo -.depends on.-> DS

style App fill:#e1f5ff
style Injector fill:#fff4e1
style Module fill:#f0f0f0
style Factory fill:#ffe1e1

Пояснення потоку:

  1. AudiobookApp.start() створює Guice Injector з AudiobookModule.
  2. AudiobookModule конфігурує bindings: AudiobookRepository → JdbcAudiobookRepository, DataSource → HikariDataSource.
  3. Application встановлює ControllerFactory для FXMLLoader: loader.setControllerFactory(injector::getInstance).
  4. FXMLLoader парсить FXML, знаходить fx:controller="AudiobookListController".
  5. FXMLLoader викликає ControllerFactory.call(AudiobookListController.class).
  6. ControllerFactory делегує до Injector.getInstance(AudiobookListController.class).
  7. Guice аналізує конструктор AudiobookListController(@Inject AudiobookListViewModel viewModel).
  8. Guice рекурсивно резолвить залежності: створює AudiobookListViewModelAudiobookServiceJdbcAudiobookRepositoryHikariDataSource.
  9. Guice повертає повністю ініціалізований AudiobookListController з усіма залежностями.
  10. FXMLLoader ін'єктує @FXML поля (UI-елементи) та викликає initialize().
Переваги цієї архітектури: Додавання нового екрану зводиться до створення FXML, Controller та ViewModel. Guice автоматично резолвить всі залежності — не потрібно писати код створення об'єктів.

Guice Module: Конфігурація залежностей

Guice Module — це клас, що конфігурує bindings: які інтерфейси прив'язані до яких реалізацій, які об'єкти є Singleton, як створювати складні залежності.

Створення AudiobookModule

Створимо AudiobookModule — центральну конфігурацію нашого додатку:

package dev.kostyl.audiobook.infrastructure;

import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.Singleton;
import dev.kostyl.audiobook.repository.AudiobookRepository;
import dev.kostyl.audiobook.repository.AuthorRepository;
import dev.kostyl.audiobook.repository.GenreRepository;
import dev.kostyl.audiobook.repository.jdbc.JdbcAudiobookRepository;
import dev.kostyl.audiobook.repository.jdbc.JdbcAuthorRepository;
import dev.kostyl.audiobook.repository.jdbc.JdbcGenreRepository;
import dev.kostyl.audiobook.service.AudiobookService;
import dev.kostyl.audiobook.viewmodel.AudiobookListViewModel;

import javax.sql.DataSource;

public class AudiobookModule extends AbstractModule {
    
    @Override
    protected void configure() {
        // ===== Data Access Layer =====
        
        // Repositories: інтерфейс → реалізація
        bind(AudiobookRepository.class).to(JdbcAudiobookRepository.class);
        bind(AuthorRepository.class).to(JdbcAuthorRepository.class);
        bind(GenreRepository.class).to(JdbcGenreRepository.class);
        
        // ===== Service Layer =====
        
        // Services як Singleton (stateless, можна перевикористовувати)
        bind(AudiobookService.class).in(Singleton.class);
        
        // ===== Presentation Layer =====
        
        // ViewModels як Singleton (зберігають стан між переходами)
        bind(AudiobookListViewModel.class).in(Singleton.class);
    }
    
    // ===== Provider Methods =====
    
    @Provides
    @Singleton
    DataSource provideDataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:h2:mem:audiobook;DB_CLOSE_DELAY=-1");
        config.setUsername("sa");
        config.setPassword("");
        config.setMaximumPoolSize(10);
        config.setMinimumIdle(2);
        config.setConnectionTimeout(30000);
        
        return new HikariDataSource(config);
    }
}

Розбір конфігурації

Рядки 20-25: Bindings для Repositories. bind(AudiobookRepository.class).to(JdbcAudiobookRepository.class) означає: "коли хтось запитує AudiobookRepository, створи екземпляр JdbcAudiobookRepository". Це дозволяє легко замінити реалізацію (наприклад, на InMemoryAudiobookRepository для тестів) без зміни коду, що використовує Repository.

Рядок 30: Service як Singleton. bind(AudiobookService.class).in(Singleton.class) — Guice створить один екземпляр AudiobookService при першому запиті та повертатиме його для всіх наступних запитів. Це оптимізація: Service не має стану (stateless), тому немає сенсу створювати новий екземпляр кожного разу.

Рядок 35: ViewModel як Singleton. AudiobookListViewModel — Singleton, бо він зберігає стан (список аудіокниг, фільтри, вибраний елемент), що має зберігатися між переходами між екранами. Якщо користувач переходить з "Audiobook List" до "Audiobook Details" і повертається назад, список має залишитися у тому самому стані (прокрутка, фільтри, вибір).

Рядки 41-53: Provider Method для DataSource. @Provides метод — це спосіб створити складний об'єкт, що потребує конфігурації. Guice викличе цей метод один раз (через @Singleton) та збереже результат. Provider Method зручніший за bind().toProvider(), коли логіка створення проста.

Чому DataSource — Singleton? Connection Pool (HikariCP) — це дорогий ресурс: він створює з'єднання з базою даних, керує їхнім життєвим циклом. Створювати новий Pool при кожному запиті — це витік ресурсів. Один Pool на весь додаток — стандартна практика.

Альтернатива: Provider Class

Якщо логіка створення DataSource складна (читання конфігурації з файлу, міграції через Flyway), краще винести її у окремий Provider:

public class HikariDataSourceProvider implements Provider<DataSource> {
    
    @Override
    public DataSource get() {
        // Читання конфігурації з application.properties
        Properties props = loadProperties("application.properties");
        
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(props.getProperty("db.url"));
        config.setUsername(props.getProperty("db.username"));
        config.setPassword(props.getProperty("db.password"));
        config.setMaximumPoolSize(Integer.parseInt(props.getProperty("db.pool.size")));
        
        DataSource dataSource = new HikariDataSource(config);
        
        // Виконання міграцій через Flyway
        Flyway flyway = Flyway.configure()
            .dataSource(dataSource)
            .locations("classpath:db/migration")
            .load();
        flyway.migrate();
        
        return dataSource;
    }
    
    private Properties loadProperties(String filename) {
        // Реалізація завантаження properties
        // ...
    }
}

Використання у Module:

@Override
protected void configure() {
    bind(DataSource.class)
        .toProvider(HikariDataSourceProvider.class)
        .in(Singleton.class);
}
Provider Method vs Provider Class: Використовуйте @Provides метод для простої логіки (2-10 рядків). Використовуйте Provider Class для складної логіки (читання конфігурації, міграції, валідація).

ControllerFactory: Міст між FXMLLoader та Guice

FXMLLoader за замовчуванням створює Controllers через рефлексію: controllerClass.getDeclaredConstructor().newInstance(). Це працює лише для конструкторів без параметрів. Але наші Controllers мають залежності (ViewModel), тому потрібен конструктор з параметрами.

Проблема: FXMLLoader не знає про Guice

Розглянемо Controller з Constructor Injection:

public class AudiobookListController {
    private final AudiobookListViewModel viewModel;
    
    @Inject
    public AudiobookListController(AudiobookListViewModel viewModel) {
        this.viewModel = viewModel;
    }
}

Якщо завантажити FXML без ControllerFactory:

FXMLLoader loader = new FXMLLoader(getClass().getResource("AudiobookListView.fxml"));
Parent root = loader.load(); // Викине виняток!

FXMLLoader спробує викликати new AudiobookListController(), але такого конструктора немає → InstantiationException.

Рішення: setControllerFactory()

FXMLLoader підтримує кастомну фабрику для створення Controllers:

public interface Callback<P, R> {
    R call(P param);
}

FXMLLoader.setControllerFactory(Callback<Class<?>, Object>) приймає функцію, що отримує клас Controller та повертає його екземпляр. Ми можемо передати туди Guice Injector:

FXMLLoader loader = new FXMLLoader(getClass().getResource("AudiobookListView.fxml"));
loader.setControllerFactory(injector::getInstance);
Parent root = loader.load(); // Працює!

injector::getInstance — це method reference, еквівалент лямбди clazz -> injector.getInstance(clazz).

Повний приклад: Завантаження FXML з Guice

public class FxmlLoaderHelper {
    private final Injector injector;
    
    @Inject
    public FxmlLoaderHelper(Injector injector) {
        this.injector = injector;
    }
    
    public Parent load(String fxmlPath) throws IOException {
        FXMLLoader loader = new FXMLLoader(getClass().getResource(fxmlPath));
        loader.setControllerFactory(injector::getInstance);
        return loader.load();
    }
    
    public <T> T loadWithController(String fxmlPath) throws IOException {
        FXMLLoader loader = new FXMLLoader(getClass().getResource(fxmlPath));
        loader.setControllerFactory(injector::getInstance);
        loader.load();
        return loader.getController();
    }
}

Використання:

@Inject
private FxmlLoaderHelper loaderHelper;

public void showAudiobookList() throws IOException {
    Parent root = loaderHelper.load("/fxml/audiobook-list-view.fxml");
    scene.setRoot(root);
}
Не створюйте FXMLLoader вручну у кожному місці. Винесіть логіку завантаження у окремий клас (FxmlLoaderHelper, ViewLoader, Navigator) та ін'єктуйте Injector туди. Це дозволить централізовано керувати завантаженням Views.

Constructor Injection: Ін'єкція ViewModel у Controller

Тепер, коли Guice інтегрований з FXMLLoader, можемо використовувати Constructor Injection у Controllers.

Controller з Constructor Injection

package dev.kostyl.audiobook.controller;

import com.google.inject.Inject;
import dev.kostyl.audiobook.viewmodel.AudiobookListViewModel;
import dev.kostyl.audiobook.viewmodel.AudiobookViewModel;
import javafx.fxml.FXML;
import javafx.scene.control.*;

public class AudiobookListController {
    
    // ===== FXML-ін'єкція UI-елементів =====
    
    @FXML private TableView<AudiobookViewModel> audiobookTable;
    @FXML private TableColumn<AudiobookViewModel, String> titleColumn;
    @FXML private TableColumn<AudiobookViewModel, String> authorColumn;
    @FXML private TableColumn<AudiobookViewModel, String> genreColumn;
    @FXML private TableColumn<AudiobookViewModel, String> durationColumn;
    
    @FXML private TextField searchField;
    @FXML private ComboBox<String> genreComboBox;
    
    @FXML private Button addButton;
    @FXML private Button editButton;
    @FXML private Button deleteButton;
    
    @FXML private ProgressIndicator loadingIndicator;
    @FXML private Label statusLabel;
    @FXML private Label countLabel;
    
    // ===== Guice-ін'єкція залежностей =====
    
    private final AudiobookListViewModel viewModel;
    
    @Inject
    public AudiobookListController(AudiobookListViewModel viewModel) {
        this.viewModel = viewModel;
    }
    
    // ===== Lifecycle =====
    
    @FXML
    public void initialize() {
        setupTableColumns();
        setupBindings();
        setupEventHandlers();
        
        // Завантаження даних
        viewModel.initialize();
    }
    
    // ===== Налаштування =====
    
    private void setupTableColumns() {
        titleColumn.setCellValueFactory(cellData -> 
            cellData.getValue().titleProperty());
        authorColumn.setCellValueFactory(cellData -> 
            cellData.getValue().authorNameProperty());
        genreColumn.setCellValueFactory(cellData -> 
            cellData.getValue().genreNameProperty());
        durationColumn.setCellValueFactory(cellData -> 
            cellData.getValue().formattedDurationProperty());
    }
    
    private void setupBindings() {
        // Таблиця
        audiobookTable.setItems(viewModel.getSortedAudiobooks());
        audiobookTable.getSelectionModel().selectedItemProperty()
            .bindBidirectional(viewModel.selectedAudiobookProperty());
        
        // Фільтри
        searchField.textProperty().bindBidirectional(viewModel.searchQueryProperty());
        genreComboBox.valueProperty().bindBidirectional(viewModel.selectedGenreProperty());
        
        // Індикатори
        loadingIndicator.visibleProperty().bind(viewModel.isLoadingProperty());
        statusLabel.textProperty().bind(viewModel.statusMessageProperty());
        
        // Кнопки
        editButton.disableProperty().bind(viewModel.selectedAudiobookProperty().isNull());
        deleteButton.disableProperty().bind(viewModel.selectedAudiobookProperty().isNull());
    }
    
    private void setupEventHandlers() {
        addButton.setOnAction(e -> onAddClicked());
        editButton.setOnAction(e -> onEditClicked());
        deleteButton.setOnAction(e -> onDeleteClicked());
    }
    
    // ===== Event Handlers =====
    
    @FXML
    private void onAddClicked() {
        viewModel.addNew();
    }
    
    @FXML
    private void onEditClicked() {
        viewModel.editSelected();
    }
    
    @FXML
    private void onDeleteClicked() {
        viewModel.deleteSelected();
    }
}

Розбір коду

Рядки 13-28: @FXML поля. Ці поля ін'єктуються FXMLLoader після парсингу FXML. Це відбувається після виклику конструктора, але до виклику initialize().

Рядки 32-37: Constructor Injection. Конструктор з @Inject — це сигнал для Guice: "створи AudiobookListViewModel та передай його сюди". Guice рекурсивно резолвить залежності: якщо AudiobookListViewModel залежить від AudiobookService, Guice створить AudiobookService (і його залежності), потім створить AudiobookListViewModel, потім створить AudiobookListController.

Рядок 32: final для залежностей. Поле viewModel оголошене як final — це гарантує, що воно буде ініціалізоване у конструкторі та не зміниться протягом життя Controller. Це immutability — важлива практика для безпеки та передбачуваності коду.

Рядки 41-48: initialize(). Цей метод викликається FXMLLoader після ін'єкції @FXML полів. На цей момент viewModel вже ініціалізований (через конструктор), і всі UI-елементи вже ін'єктовані (через @FXML). Тут ми налаштовуємо Bindings та викликаємо viewModel.initialize() для завантаження даних.

Рядки 52-84: Налаштування. Методи setupTableColumns(), setupBindings(), setupEventHandlers() розбивають логіку ініціалізації на логічні блоки. Це покращує читабельність: замість одного методу на 100 рядків — три методи по 20-30 рядків.

Порядок ініціалізації Controller:
  1. Guice викликає конструктор → viewModel ініціалізований.
  2. FXMLLoader ін'єктує @FXML поля → UI-елементи ініціалізовані.
  3. FXMLLoader викликає initialize() → Bindings налаштовані, дані завантажені.
Якщо спробувати звернутися до @FXML полів у конструкторі, отримаєте NullPointerException — вони ще не ін'єктовані.

Порівняння: Setter Injection vs Constructor Injection

== Setter Injection (без Guice)


Scopes: Singleton vs Prototype

Guice підтримує різні scopes (області видимості) для об'єктів. Два найважливіші для JavaFX-додатків:

Singleton Scope

Визначення: Guice створює один екземпляр об'єкта при першому запиті та повертає його для всіх наступних запитів.

Коли використовувати:

  • Stateless об'єкти: Repository, Service, Validator — вони не зберігають стан між викликами.
  • Дорогі ресурси: DataSource (Connection Pool), ExecutorService — створення нового екземпляра кожного разу — це витік ресурсів.
  • Глобальний стан: ViewModel для головних екранів, що зберігає стан між переходами (список аудіокниг, фільтри, вибір).

Приклад:

@Override
protected void configure() {
    bind(AudiobookRepository.class).to(JdbcAudiobookRepository.class).in(Singleton.class);
    bind(AudiobookService.class).in(Singleton.class);
    bind(AudiobookListViewModel.class).in(Singleton.class);
}

Альтернативний синтаксис через анотацію:

@Singleton
public class AudiobookService {
    private final AudiobookRepository repository;
    
    @Inject
    public AudiobookService(AudiobookRepository repository) {
        this.repository = repository;
    }
}

Prototype Scope (No Scope)

Визначення: Guice створює новий екземпляр об'єкта при кожному запиті.

Коли використовувати:

  • Stateful об'єкти: ViewModel для діалогів, форм — кожне відкриття діалогу має мати свій стан.
  • Короткоживучі об'єкти: Task, Command — створюються для одноразового виконання.

Приклад:

@Override
protected void configure() {
    // Без .in(Singleton.class) — це Prototype scope
    bind(AudiobookFormViewModel.class);
}

Кожного разу, коли хтось запитує AudiobookFormViewModel, Guice створить новий екземпляр:

AudiobookFormViewModel vm1 = injector.getInstance(AudiobookFormViewModel.class);
AudiobookFormViewModel vm2 = injector.getInstance(AudiobookFormViewModel.class);

System.out.println(vm1 == vm2); // false — різні екземпляри

Проблема: Singleton ViewModel для діалогів

Розглянемо проблемний сценарій:

// AudiobookFormViewModel — Singleton
bind(AudiobookFormViewModel.class).in(Singleton.class);

Користувач відкриває діалог "Add Audiobook" → вводить дані → закриває діалог. Потім знову відкриває діалог → бачить попередні дані! Це тому, що ViewModel — Singleton, і він зберігає стан між відкриттями.

Рішення: ViewModel для діалогів має бути Prototype:

bind(AudiobookFormViewModel.class); // Без Singleton

Тепер кожне відкриття діалогу створює новий ViewModel з чистим станом.

Будьте обережні з Singleton ViewModel. Переконайтеся, що стан не конфліктує між різними використаннями. Якщо ViewModel містить стан форми (введені дані, валідаційні помилки), він має бути Prototype.

Application Entry Point: Ініціалізація Guice

Тепер з'єднаємо всі частини разом: створимо Application клас, що ініціалізує Guice Injector, завантажує головний екран та керує життєвим циклом додатку.

AudiobookApp: Точка входу

package dev.kostyl.audiobook;

import com.google.inject.Guice;
import com.google.inject.Injector;
import dev.kostyl.audiobook.infrastructure.AudiobookModule;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;

import javax.sql.DataSource;
import java.io.IOException;

public class AudiobookApp extends Application {
    
    private Injector injector;
    
    @Override
    public void init() throws Exception {
        // Створення Guice Injector з модулем конфігурації
        injector = Guice.createInjector(new AudiobookModule());
        
        // Ініціалізація бази даних (міграції через Flyway)
        initializeDatabase();
    }
    
    @Override
    public void start(Stage primaryStage) throws IOException {
        // Завантаження головного екрану через FXMLLoader з ControllerFactory
        FXMLLoader loader = new FXMLLoader(
            getClass().getResource("/fxml/audiobook-list-view.fxml")
        );
        loader.setControllerFactory(injector::getInstance);
        
        Parent root = loader.load();
        
        // Налаштування Scene
        Scene scene = new Scene(root, 1000, 700);
        scene.getStylesheets().add(
            getClass().getResource("/css/styles.css").toExternalForm()
        );
        
        // Налаштування Stage
        primaryStage.setTitle("Audiobook Platform");
        primaryStage.setScene(scene);
        primaryStage.setMinWidth(800);
        primaryStage.setMinHeight(600);
        primaryStage.show();
    }
    
    @Override
    public void stop() throws Exception {
        // Закриття ресурсів при завершенні додатку
        closeResources();
    }
    
    private void initializeDatabase() {
        DataSource dataSource = injector.getInstance(DataSource.class);
        
        // Виконання міграцій через Flyway
        Flyway flyway = Flyway.configure()
            .dataSource(dataSource)
            .locations("classpath:db/migration")
            .load();
        
        flyway.migrate();
        
        System.out.println("Database initialized successfully");
    }
    
    private void closeResources() {
        try {
            // Закриття Connection Pool
            DataSource dataSource = injector.getInstance(DataSource.class);
            if (dataSource instanceof HikariDataSource) {
                ((HikariDataSource) dataSource).close();
                System.out.println("DataSource closed");
            }
        } catch (Exception e) {
            System.err.println("Error closing resources: " + e.getMessage());
        }
    }
    
    public static void main(String[] args) {
        launch(args);
    }
}

Розбір життєвого циклу Application

Рядки 20-25: init(). Метод init() викликається перед start(), у фоновому потоці. Це ідеальне місце для ініціалізації Guice Injector та виконання міграцій бази даних. Якщо init() викине виняток, додаток не запуститься.

Рядок 22: Створення Injector. Guice.createInjector(new AudiobookModule()) — це точка, де Guice читає конфігурацію з Module та будує граф залежностей. Це відбувається один раз при старті додатку.

Рядки 28-49: start(). Метод start() викликається у JavaFX Application Thread. Тут завантажуємо головний екран через FXMLLoader з ControllerFactory, налаштовуємо Scene та Stage, показуємо вікно.

Рядок 34: ControllerFactory. loader.setControllerFactory(injector::getInstance) — це міст між FXMLLoader та Guice. Тепер FXMLLoader створюватиме Controllers через Guice, що автоматично ін'єктує всі залежності.

Рядки 51-54: stop(). Метод stop() викликається при закритті додатку (користувач натиснув "X" або викликав Platform.exit()). Тут закриваємо ресурси: Connection Pool, ExecutorService, файли.

Рядки 56-67: Ініціалізація бази даних. Отримуємо DataSource з Injector та виконуємо міграції через Flyway. Це гарантує, що база даних має актуальну схему перед запуском додатку.

Рядки 69-81: Закриття ресурсів. Отримуємо DataSource та закриваємо його. HikariCP автоматично закриє всі активні з'єднання та звільнить ресурси.

Чому init() у фоновому потоці? JavaFX викликає init() у фоновому потоці, щоб не блокувати UI Thread. Це дозволяє виконувати довгі операції (міграції, завантаження конфігурації) без заморожування інтерфейсу. Але будьте обережні: не створюйте JavaFX-об'єкти (Node, Scene) у init() — вони мають створюватися у start().

Структура проєкту


AssistedInject: Параметризовані ViewModel

Іноді ViewModel потребує параметр при створенні. Наприклад, AudiobookDetailViewModel має показувати деталі конкретної аудіокниги — йому потрібен Audiobook у конструкторі:

public class AudiobookDetailViewModel {
    private final Audiobook audiobook;
    private final AudiobookRepository repository;
    
    public AudiobookDetailViewModel(Audiobook audiobook, AudiobookRepository repository) {
        this.audiobook = audiobook;
        this.repository = repository;
    }
}

Але як передати audiobook через Guice? injector.getInstance(AudiobookDetailViewModel.class) не приймає параметрів.

Рішення: AssistedInject

Guice підтримує AssistedInject — механізм для створення об'єктів з параметрами через фабрику.

Крок 1: Анотація @Assisted для параметрів

import com.google.inject.assistedinject.Assisted;
import com.google.inject.Inject;

public class AudiobookDetailViewModel {
    private final Audiobook audiobook;
    private final AudiobookRepository repository;
    
    @Inject
    public AudiobookDetailViewModel(
        @Assisted Audiobook audiobook,
        AudiobookRepository repository
    ) {
        this.audiobook = audiobook;
        this.repository = repository;
    }
}

@Assisted означає: "цей параметр буде переданий вручну, не резолви його через Guice".

Крок 2: Створення Factory інтерфейсу

public interface AudiobookDetailViewModelFactory {
    AudiobookDetailViewModel create(Audiobook audiobook);
}

Guice автоматично створить реалізацію цього інтерфейсу.

Крок 3: Реєстрація Factory у Module

import com.google.inject.assistedinject.FactoryModuleBuilder;

@Override
protected void configure() {
    // ... інші bindings
    
    install(new FactoryModuleBuilder()
        .build(AudiobookDetailViewModelFactory.class));
}

Крок 4: Використання Factory

public class AudiobookListController {
    private final AudiobookDetailViewModelFactory detailViewModelFactory;
    
    @Inject
    public AudiobookListController(AudiobookDetailViewModelFactory factory) {
        this.detailViewModelFactory = factory;
    }
    
    private void onShowDetails(Audiobook audiobook) {
        AudiobookDetailViewModel viewModel = detailViewModelFactory.create(audiobook);
        // Відкрити діалог з цим ViewModel
    }
}

Guice автоматично ін'єктує AudiobookRepository у AudiobookDetailViewModel, а audiobook передається через factory.create(audiobook).

AssistedInject для діалогів: Це ідеальний підхід для ViewModel діалогів, що потребують параметрів (редагування існуючого об'єкта, показ деталей). Factory створює новий ViewModel з параметром, Guice ін'єктує решту залежностей.

Повний приклад: Додаток з кількома екранами

Розглянемо повний приклад додатку з трьома екранами: список аудіокниг, форма додавання, деталі аудіокниги.

AudiobookModule: Повна конфігурація

package dev.kostyl.audiobook.infrastructure;

import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.Singleton;
import com.google.inject.assistedinject.FactoryModuleBuilder;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import dev.kostyl.audiobook.repository.*;
import dev.kostyl.audiobook.repository.jdbc.*;
import dev.kostyl.audiobook.service.AudiobookService;
import dev.kostyl.audiobook.viewmodel.*;

import javax.sql.DataSource;

public class AudiobookModule extends AbstractModule {
    
    @Override
    protected void configure() {
        // ===== Data Access Layer =====
        
        bind(AudiobookRepository.class).to(JdbcAudiobookRepository.class);
        bind(AuthorRepository.class).to(JdbcAuthorRepository.class);
        bind(GenreRepository.class).to(JdbcGenreRepository.class);
        
        // ===== Service Layer =====
        
        bind(AudiobookService.class).in(Singleton.class);
        
        // ===== Presentation Layer =====
        
        // Singleton ViewModels (зберігають стан між переходами)
        bind(AudiobookListViewModel.class).in(Singleton.class);
        
        // Prototype ViewModels (новий екземпляр для кожного відкриття)
        bind(AudiobookFormViewModel.class); // Без Singleton
        
        // AssistedInject Factory для параметризованих ViewModel
        install(new FactoryModuleBuilder()
            .build(AudiobookDetailViewModelFactory.class));
    }
    
    @Provides
    @Singleton
    DataSource provideDataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:h2:mem:audiobook;DB_CLOSE_DELAY=-1");
        config.setUsername("sa");
        config.setPassword("");
        config.setMaximumPoolSize(10);
        config.setMinimumIdle(2);
        config.setConnectionTimeout(30000);
        config.setIdleTimeout(600000);
        config.setMaxLifetime(1800000);
        
        return new HikariDataSource(config);
    }
}

Порівняння: До та після Guice

== Без Guice (ручне створення)


Тестування з Guice: Mock залежностей

Одна з найбільших переваг Dependency Injection — легкість тестування. Ми можемо замінити реальні залежності на mock-об'єкти для unit-тестів.

Unit-тестування ViewModel з mock Repository

package dev.kostyl.audiobook.viewmodel;

import dev.kostyl.audiobook.domain.Audiobook;
import dev.kostyl.audiobook.repository.AudiobookRepository;
import dev.kostyl.audiobook.service.AudiobookService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.List;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class AudiobookListViewModelTest {
    
    @Mock
    private AudiobookRepository repository;
    
    @Mock
    private AudiobookService service;
    
    private AudiobookListViewModel viewModel;
    
    @BeforeEach
    void setUp() {
        // Створення ViewModel з mock залежностями
        viewModel = new AudiobookListViewModel(service);
    }
    
    @Test
    void shouldLoadAudiobooks() {
        // Given
        List<Audiobook> audiobooks = List.of(
            new Audiobook("Title 1", author1, genre1, 3600),
            new Audiobook("Title 2", author2, genre2, 7200)
        );
        when(service.getAllAudiobooks()).thenReturn(audiobooks);
        
        // When
        viewModel.loadAudiobooks();
        
        // Then
        assertEquals(2, viewModel.getAudiobooks().size());
        verify(service).getAllAudiobooks();
    }
    
    @Test
    void shouldFilterAudiobooksBySearchQuery() {
        // Given
        viewModel.getAudiobooks().addAll(
            new AudiobookViewModel(audiobook1), // title: "1984"
            new AudiobookViewModel(audiobook2)  // title: "Brave New World"
        );
        
        // When
        viewModel.searchQueryProperty().set("1984");
        
        // Then
        assertEquals(1, viewModel.getFilteredAudiobooks().size());
        assertEquals("1984", viewModel.getFilteredAudiobooks().get(0).getTitle());
    }
}

Переваги тестування з DI:

  • Не потрібно створювати реальну базу даних для unit-тестів.
  • Mock залежності передаються через конструктор — просто та зрозуміло.
  • Тести швидкі (без IO-операцій) та ізольовані.

Інтеграційне тестування з Test Module

Для інтеграційних тестів створимо окремий Guice Module з тестовими залежностями:

package dev.kostyl.audiobook.infrastructure;

import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.Singleton;
import dev.kostyl.audiobook.repository.AudiobookRepository;
import dev.kostyl.audiobook.repository.inmemory.InMemoryAudiobookRepository;

import javax.sql.DataSource;

public class TestAudiobookModule extends AbstractModule {
    
    @Override
    protected void configure() {
        // Використовуємо InMemory реалізації для тестів
        bind(AudiobookRepository.class).to(InMemoryAudiobookRepository.class);
        
        // Решта конфігурації як у production Module
        bind(AudiobookService.class).in(Singleton.class);
        bind(AudiobookListViewModel.class).in(Singleton.class);
    }
    
    @Provides
    @Singleton
    DataSource provideDataSource() {
        // H2 in-memory database для тестів
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1");
        config.setUsername("sa");
        config.setPassword("");
        
        return new HikariDataSource(config);
    }
}

Використання у тестах:

@ExtendWith(GuiceExtension.class)
@GuiceModules(TestAudiobookModule.class)
class AudiobookListViewModelIntegrationTest {
    
    @Inject
    private AudiobookListViewModel viewModel;
    
    @Inject
    private AudiobookRepository repository;
    
    @Test
    void shouldLoadAudiobooksFromRepository() {
        // Given
        repository.save(audiobook1);
        repository.save(audiobook2);
        
        // When
        viewModel.loadAudiobooks();
        
        // Then
        assertEquals(2, viewModel.getAudiobooks().size());
    }
}
Test Module для різних середовищ: Створіть окремі Modules для production (AudiobookModule), тестів (TestAudiobookModule), розробки (DevAudiobookModule з логуванням SQL). Це дозволить легко перемикатися між конфігураціями.

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

Рівень 1: Базова інтеграція Guice

Завдання 1.1: Створіть AuthorModule (Guice Module) з bindings для AuthorRepository, AuthorService.

Завдання 1.2: Створіть AuthorListController з Constructor Injection для AuthorListViewModel.

Завдання 1.3: Модифікуйте AudiobookApp, щоб він використовував AuthorModule та завантажував author-list-view.fxml через FXMLLoader з ControllerFactory.

Рівень 2: Scopes та Lifecycle

Завдання 2.1: Створіть AudiobookFormViewModel як Prototype scope (новий екземпляр для кожного відкриття форми). Переконайтеся, що при повторному відкритті форми стан скидається.

Завдання 2.2: Додайте у AudiobookApp.stop() логіку закриття ExecutorService (якщо він використовується у ViewModel для асинхронних операцій).

Завдання 2.3: Створіть Provider для ExecutorService, що конфігурує ThreadPool з параметрами з файлу application.properties.

Рівень 3: AssistedInject та складні сценарії

Завдання 3.1: Створіть AudiobookDetailViewModel, що приймає Audiobook у конструкторі через @Assisted. Створіть Factory для нього та зареєструйте у Module.

Завдання 3.2: Реалізуйте діалог редагування аудіокниги: при натисканні "Edit" у AudiobookListController відкривається діалог з AudiobookFormViewModel, створеним через Factory з параметром Audiobook.

Завдання 3.3: Створіть Navigator клас з методом navigateTo(String screenId), що завантажує FXML через FXMLLoader з ControllerFactory та змінює Root Node у Scene. Ін'єктуйте Navigator у Controllers для навігації між екранами.


Підсумок

У цій статті ми інтегрували MVVM-архітектуру з Google Guice для автоматичної ін'єкції залежностей. Ключові висновки:

Guice вирішує проблему ручного створення об'єктів. Замість 20+ рядків коду для створення графу залежностей у Application.start(), Guice автоматично резолвить всі залежності через конфігурацію у Module.

Guice Module — це центральна конфігурація додатку. Тут визначаємо bindings (інтерфейс → реалізація), scopes (Singleton vs Prototype), Provider Methods для складних об'єктів (DataSource, ExecutorService).

ControllerFactory — це міст між FXMLLoader та Guice. loader.setControllerFactory(injector::getInstance) дозволяє FXMLLoader створювати Controllers через Guice, що автоматично ін'єктує всі залежності (ViewModel, Navigator).

Constructor Injection — рекомендований підхід. Явні залежності у конструкторі, immutability через final, легкість тестування (передача mock через конструктор).

Scopes керують життєвим циклом об'єктів. Singleton для stateless об'єктів (Repository, Service) та глобального стану (ViewModel головних екранів). Prototype для stateful об'єктів (ViewModel діалогів, форм).

AssistedInject для параметризованих ViewModel. Коли ViewModel потребує параметр (наприклад, Audiobook для редагування), використовуємо Factory з @Assisted анотацією. Guice автоматично ін'єктує решту залежностей.

Application lifecycle: init() → start() → stop(). init() — створення Injector та міграції БД (фоновий потік). start() — завантаження UI (JavaFX Thread). stop() — закриття ресурсів (Connection Pool, ExecutorService).

Тестування стає простішим. Unit-тести з mock залежностями (Mockito). Інтеграційні тести з Test Module (InMemory Repository, H2 database). Легко замінити реалізації без зміни коду.

У наступній статті ми розглянемо валідацію та обробку помилок у MVVM: як валідувати введення користувача на рівні ViewModel, як відображати помилки у View, як обробляти винятки з Repository та Service, та як створити централізовану систему валідації через Validator Pattern.

Copyright © 2026