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

Валідація та обробка помилок у MVVM

Від реактивної валідації Properties до централізованої системи Validators: валідація на рівні ViewModel, Validator Pattern, CompositeValidator, асинхронна валідація унікальності, обробка помилок Repository та Service, відображення помилок у View.

Валідація та обробка помилок у MVVM

Вступ: Валідація — частина презентаційної логіки

Користувач відкриває форму додавання аудіокниги. Вводить назву: "" (порожній рядок). Вводить тривалість: -10 секунд. Вводить рік випуску: 3000. Натискає "Save". Що має статися?

У наївному підході валідація відбувається у Repository або Service: repository.save(audiobook) викидає ValidationException("Title is required"). Але це означає, що користувач побачить помилку лише після натискання "Save" — погана UX. Крім того, Repository не повинен знати про UI-правила валідації (чи поле обов'язкове, чи має бути червоним).

У MVVM валідація — це частина презентаційної логіки. Вона відбувається у ViewModel, до передачі даних у Model. Користувач вводить текст → ViewModel перевіряє → якщо помилка, показує її одразу (реактивна валідація). Кнопка "Save" неактивна, поки всі поля не валідні.

Ця стаття про те, як побудувати систему валідації у MVVM. Ми розглянемо:

  • Реактивну валідацію на рівні Properties (перевірка при кожній зміні).
  • Validator Pattern для централізації правил валідації.
  • CompositeValidator для об'єднання кількох валідаторів.
  • Агрегацію валідності всіх полів у isValidProperty (для активації кнопки "Save").
  • Асинхронну валідацію (перевірка унікальності через запит до БД).
  • Обробку помилок з Repository та Service (винятки, системні помилки).
  • Відображення помилок у View (inline errors, toast notifications, alert dialogs).

Але перш за все, потрібно зрозуміти фундаментальний принцип: валідація у MVVM — це не перевірка перед збереженням, а постійний процес. Кожна зміна Property → перевірка → оновлення error Property → оновлення UI. Це реактивність у дії.

Два рівні валідації: UI-рівень (формат, обов'язковість, довжина) — у ViewModel. Бізнес-рівень (унікальність, бізнес-правила, консистентність даних) — у Service або Domain Model. ViewModel перевіряє "чи можна відправити ці дані", Service перевіряє "чи можна зберегти ці дані".

Реактивна валідація на рівні Properties

Найпростіший спосіб валідації — слухати зміни Property та оновлювати error Property.

Приклад: Валідація title у AudiobookFormViewModel

package dev.kostyl.audiobook.viewmodel;

import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;

public class AudiobookFormViewModel {
    
    // ===== Properties =====
    
    private final StringProperty title = new SimpleStringProperty("");
    private final StringProperty titleError = new SimpleStringProperty();
    
    // ===== Constructor =====
    
    public AudiobookFormViewModel() {
        setupValidation();
    }
    
    // ===== Validation =====
    
    private void setupValidation() {
        // Валідація title при кожній зміні
        title.addListener((observable, oldValue, newValue) -> {
            validateTitle(newValue);
        });
    }
    
    private void validateTitle(String value) {
        if (value == null || value.trim().isEmpty()) {
            titleError.set("Title is required");
        } else if (value.length() > 255) {
            titleError.set("Title is too long (max 255 characters)");
        } else if (value.matches(".*[<>\"'].*")) {
            titleError.set("Title contains invalid characters");
        } else {
            titleError.set(null); // Немає помилки
        }
    }
    
    // ===== Getters =====
    
    public StringProperty titleProperty() {
        return title;
    }
    
    public StringProperty titleErrorProperty() {
        return titleError;
    }
}

Розбір коду

Рядки 10-11: Properties для значення та помилки. title — введене користувачем значення. titleError — повідомлення про помилку (або null, якщо помилки немає).

Рядки 21-25: Listener для реактивної валідації. title.addListener() викликається при кожній зміні title. Користувач вводить символ → listener викликає validateTitle()titleError оновлюється → UI автоматично показує/ховає помилку (через Binding).

Рядки 27-36: Логіка валідації. Три перевірки: порожність, довжина, заборонені символи. Якщо хоча б одна не пройдена → встановлюємо titleError. Якщо всі пройдені → titleError.set(null).

Чому null замість порожнього рядка? null означає "помилки немає", порожній рядок "" означає "помилка є, але повідомлення порожнє". У View ми прив'яжемо видимість Label до titleError.isNotNull().

Binding у View

У Controller прив'язуємо titleError до Label:

@FXML private TextField titleField;
@FXML private Label titleErrorLabel;

private void setupBindings() {
    // Bidirectional binding для введення
    titleField.textProperty().bindBidirectional(viewModel.titleProperty());
    
    // Unidirectional binding для помилки
    titleErrorLabel.textProperty().bind(viewModel.titleErrorProperty());
    titleErrorLabel.visibleProperty().bind(viewModel.titleErrorProperty().isNotNull());
    titleErrorLabel.managedProperty().bind(viewModel.titleErrorProperty().isNotNull());
}

Рядок 9: Видимість Label. titleErrorLabel.visibleProperty().bind(viewModel.titleErrorProperty().isNotNull()) — Label видимий, коли titleError != null.

Рядок 10: Managed Property. managedProperty() контролює, чи займає елемент місце у Layout. Якщо managed = false, елемент невидимий і не займає місце (інші елементи зсуваються). Це важливо для динамічних форм: коли помилки немає, Label не залишає порожній простір.

FXML з error Label

<VBox spacing="5">
    <Label text="Title:"/>
    <TextField fx:id="titleField" promptText="Enter audiobook title"/>
    <Label fx:id="titleErrorLabel" 
           styleClass="error-label"
           visible="false"
           managed="false"/>
</VBox>

CSS для стилізації помилки:

.error-label {
    -fx-text-fill: #e74c3c;
    -fx-font-size: 12px;
    -fx-padding: 2 0 0 0;
}
Реактивна валідація покращує UX: Користувач бачить помилку одразу після введення, а не після натискання "Save". Це дозволяє виправити помилку до відправки форми. Але будьте обережні: не показуйте помилку, поки користувач не почав вводити (порожнє поле не має бути червоним одразу).

Валідація кількох полів

Розширимо AudiobookFormViewModel для валідації всіх полів форми:

public class AudiobookFormViewModel {
    
    // ===== Properties =====
    
    private final StringProperty title = new SimpleStringProperty("");
    private final StringProperty titleError = new SimpleStringProperty();
    
    private final IntegerProperty duration = new SimpleIntegerProperty(0);
    private final StringProperty durationError = new SimpleStringProperty();
    
    private final IntegerProperty releaseYear = new SimpleIntegerProperty(2024);
    private final StringProperty releaseYearError = new SimpleStringProperty();
    
    private final ObjectProperty<Author> selectedAuthor = new SimpleObjectProperty<>();
    private final StringProperty authorError = new SimpleStringProperty();
    
    // ===== Constructor =====
    
    public AudiobookFormViewModel() {
        setupValidation();
    }
    
    // ===== Validation =====
    
    private void setupValidation() {
        title.addListener((obs, old, newVal) -> validateTitle(newVal));
        duration.addListener((obs, old, newVal) -> validateDuration(newVal.intValue()));
        releaseYear.addListener((obs, old, newVal) -> validateReleaseYear(newVal.intValue()));
        selectedAuthor.addListener((obs, old, newVal) -> validateAuthor(newVal));
    }
    
    private void validateTitle(String value) {
        if (value == null || value.trim().isEmpty()) {
            titleError.set("Title is required");
        } else if (value.length() > 255) {
            titleError.set("Title is too long (max 255 characters)");
        } else {
            titleError.set(null);
        }
    }
    
    private void validateDuration(int value) {
        if (value <= 0) {
            durationError.set("Duration must be positive");
        } else if (value > 86400) { // 24 години
            durationError.set("Duration is too long (max 24 hours)");
        } else {
            durationError.set(null);
        }
    }
    
    private void validateReleaseYear(int value) {
        int currentYear = Year.now().getValue();
        if (value < 1900) {
            releaseYearError.set("Release year must be after 1900");
        } else if (value > currentYear) {
            releaseYearError.set("Release year cannot be in the future");
        } else {
            releaseYearError.set(null);
        }
    }
    
    private void validateAuthor(Author value) {
        if (value == null) {
            authorError.set("Author is required");
        } else {
            authorError.set(null);
        }
    }
}

Проблема цього підходу: Багато дублювання коду. Кожне поле має listener, метод валідації, error Property. Якщо у формі 10 полів, це 30+ рядків однотипного коду. Крім того, правила валідації розкидані по всьому ViewModel — складно підтримувати.

Рішення: Винести правила валідації у окремі класи — Validator Pattern.


Validator Pattern: Централізована валідація

Validator Pattern — це підхід, де кожне правило валідації інкапсульоване у окремий клас. Це дозволяє перевикористовувати валідатори, комбінувати їх, тестувати ізольовано.

Інтерфейс Validator

package dev.kostyl.audiobook.validation;

public interface Validator<T> {
    ValidationResult validate(T value);
}

Validator — це функція, що приймає значення типу T та повертає ValidationResult (успіх або помилка з повідомленням).

Клас ValidationResult

package dev.kostyl.audiobook.validation;

public class ValidationResult {
    private final boolean valid;
    private final String errorMessage;
    
    private ValidationResult(boolean valid, String errorMessage) {
        this.valid = valid;
        this.errorMessage = errorMessage;
    }
    
    public static ValidationResult success() {
        return new ValidationResult(true, null);
    }
    
    public static ValidationResult error(String message) {
        return new ValidationResult(false, message);
    }
    
    public boolean isValid() {
        return valid;
    }
    
    public String getErrorMessage() {
        return errorMessage;
    }
}

ValidationResult — це immutable об'єкт, що представляє результат валідації. Два статичні методи для створення: success() (валідація пройшла) та error(message) (валідація не пройшла).

Приклади Validators

NotEmptyValidator — перевірка на порожність:

package dev.kostyl.audiobook.validation.validators;

import dev.kostyl.audiobook.validation.ValidationResult;
import dev.kostyl.audiobook.validation.Validator;

public class NotEmptyValidator implements Validator<String> {
    
    @Override
    public ValidationResult validate(String value) {
        if (value == null || value.trim().isEmpty()) {
            return ValidationResult.error("This field is required");
        }
        return ValidationResult.success();
    }
}

MaxLengthValidator — перевірка максимальної довжини:

public class MaxLengthValidator implements Validator<String> {
    private final int maxLength;
    
    public MaxLengthValidator(int maxLength) {
        this.maxLength = maxLength;
    }
    
    @Override
    public ValidationResult validate(String value) {
        if (value != null && value.length() > maxLength) {
            return ValidationResult.error(
                String.format("Maximum length is %d characters", maxLength)
            );
        }
        return ValidationResult.success();
    }
}

RangeValidator — перевірка діапазону для чисел:

public class RangeValidator implements Validator<Integer> {
    private final int min;
    private final int max;
    
    public RangeValidator(int min, int max) {
        this.min = min;
        this.max = max;
    }
    
    @Override
    public ValidationResult validate(Integer value) {
        if (value == null) {
            return ValidationResult.success(); // null — це окрема перевірка
        }
        
        if (value < min || value > max) {
            return ValidationResult.error(
                String.format("Value must be between %d and %d", min, max)
            );
        }
        return ValidationResult.success();
    }
}

RegexValidator — перевірка через регулярний вираз:

public class RegexValidator implements Validator<String> {
    private final String pattern;
    private final String errorMessage;
    
    public RegexValidator(String pattern, String errorMessage) {
        this.pattern = pattern;
        this.errorMessage = errorMessage;
    }
    
    @Override
    public ValidationResult validate(String value) {
        if (value != null && !value.matches(pattern)) {
            return ValidationResult.error(errorMessage);
        }
        return ValidationResult.success();
    }
}

Використання Validators у ViewModel

public class AudiobookFormViewModel {
    
    // ===== Validators =====
    
    private final Validator<String> titleValidator = new NotEmptyValidator();
    private final Validator<String> titleLengthValidator = new MaxLengthValidator(255);
    private final Validator<Integer> durationValidator = new RangeValidator(1, 86400);
    private final Validator<Integer> releaseYearValidator = 
        new RangeValidator(1900, Year.now().getValue());
    
    // ===== Properties =====
    
    private final StringProperty title = new SimpleStringProperty("");
    private final StringProperty titleError = new SimpleStringProperty();
    
    // ===== Validation =====
    
    private void setupValidation() {
        title.addListener((obs, old, newVal) -> validateTitle(newVal));
    }
    
    private void validateTitle(String value) {
        // Перевірка через NotEmptyValidator
        ValidationResult result = titleValidator.validate(value);
        if (!result.isValid()) {
            titleError.set(result.getErrorMessage());
            return;
        }
        
        // Перевірка через MaxLengthValidator
        result = titleLengthValidator.validate(value);
        if (!result.isValid()) {
            titleError.set(result.getErrorMessage());
            return;
        }
        
        // Всі перевірки пройдені
        titleError.set(null);
    }
}

Переваги Validator Pattern:

  • Перевикористання: NotEmptyValidator можна використовувати для будь-якого текстового поля.
  • Тестування: Кожен Validator — це окремий клас, легко покрити unit-тестами.
  • Композиція: Можна комбінувати кілька Validators для одного поля (див. наступний розділ).
  • Читабельність: new MaxLengthValidator(255) зрозуміліше, ніж if (value.length() > 255).
Validator vs Bean Validation (JSR 303): Java має стандарт Bean Validation з анотаціями @NotNull, @Size, @Min. Але він розроблений для серверних додатків (Spring, Jakarta EE) та не інтегрується з JavaFX Properties. Для JavaFX краще використовувати власні Validators з реактивною валідацією.

CompositeValidator: Композиція валідаторів

Часто одне поле потребує кількох перевірок: title має бути не порожнім і не довшим за 255 символів і не містити спецсимволів. Замість виклику трьох Validators вручну, створимо CompositeValidator.

Реалізація CompositeValidator

package dev.kostyl.audiobook.validation.validators;

import dev.kostyl.audiobook.validation.ValidationResult;
import dev.kostyl.audiobook.validation.Validator;

import java.util.Arrays;
import java.util.List;

public class CompositeValidator<T> implements Validator<T> {
    private final List<Validator<T>> validators;
    
    @SafeVarargs
    public CompositeValidator(Validator<T>... validators) {
        this.validators = Arrays.asList(validators);
    }
    
    @Override
    public ValidationResult validate(T value) {
        for (Validator<T> validator : validators) {
            ValidationResult result = validator.validate(value);
            if (!result.isValid()) {
                return result; // Повертаємо першу помилку
            }
        }
        return ValidationResult.success();
    }
}

CompositeValidator приймає список Validators та викликає їх по черзі. Якщо хоча б один повертає помилку, CompositeValidator повертає цю помилку. Якщо всі пройшли — повертає success().

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

public class AudiobookFormViewModel {
    
    // ===== Validators =====
    
    private final Validator<String> titleValidator = new CompositeValidator<>(
        new NotEmptyValidator(),
        new MaxLengthValidator(255),
        new RegexValidator("^[^<>\"']*$", "Title contains invalid characters")
    );
    
    private final Validator<Integer> durationValidator = new CompositeValidator<>(
        new NotNullValidator<>(),
        new RangeValidator(1, 86400)
    );
    
    // ===== Validation =====
    
    private void validateTitle(String value) {
        ValidationResult result = titleValidator.validate(value);
        titleError.set(result.isValid() ? null : result.getErrorMessage());
    }
    
    private void validateDuration(Integer value) {
        ValidationResult result = durationValidator.validate(value);
        durationError.set(result.isValid() ? null : result.getErrorMessage());
    }
}

Тепер валідація title — це один виклик titleValidator.validate(value). Всі три перевірки (порожність, довжина, спецсимволи) інкапсульовані у CompositeValidator.

Порядок валідаторів має значення:CompositeValidator повертає першу помилку. Розташовуйте валідатори від найважливіших до найменш важливих. Наприклад, спочатку NotEmptyValidator (якщо поле порожнє, немає сенсу перевіряти довжину), потім MaxLengthValidator, потім RegexValidator.

Агрегація валідності: isValidProperty

Кнопка "Save" має бути активною лише коли всі поля форми валідні. Для цього створимо BooleanProperty isValid, що агрегує валідність всіх полів.

Реалізація isValidProperty

public class AudiobookFormViewModel {
    
    // ===== Properties =====
    
    private final StringProperty title = new SimpleStringProperty("");
    private final StringProperty titleError = new SimpleStringProperty();
    
    private final IntegerProperty duration = new SimpleIntegerProperty(0);
    private final StringProperty durationError = new SimpleStringProperty();
    
    private final IntegerProperty releaseYear = new SimpleIntegerProperty(2024);
    private final StringProperty releaseYearError = new SimpleStringProperty();
    
    private final ObjectProperty<Author> selectedAuthor = new SimpleObjectProperty<>();
    private final StringProperty authorError = new SimpleStringProperty();
    
    private final BooleanProperty isValid = new SimpleBooleanProperty(false);
    
    // ===== Constructor =====
    
    public AudiobookFormViewModel() {
        setupValidation();
        setupIsValidBinding();
    }
    
    // ===== Validation =====
    
    private void setupIsValidBinding() {
        // Створюємо BooleanBinding для кожного поля
        BooleanBinding titleValid = titleError.isNull();
        BooleanBinding durationValid = durationError.isNull();
        BooleanBinding releaseYearValid = releaseYearError.isNull();
        BooleanBinding authorValid = authorError.isNull();
        
        // Агрегуємо всі перевірки через AND
        isValid.bind(
            titleValid
                .and(durationValid)
                .and(releaseYearValid)
                .and(authorValid)
        );
    }
    
    // ===== Getters =====
    
    public BooleanProperty isValidProperty() {
        return isValid;
    }
}

Рядки 29-33: BooleanBinding для кожного поля. titleError.isNull() повертає BooleanBinding, що дорівнює true, коли titleError == null (немає помилки).

Рядки 36-41: Агрегація через AND. titleValid.and(durationValid).and(...) створює складний BooleanBinding, що дорівнює true лише коли всі поля валідні.

Рядок 36: isValid.bind(). Прив'язуємо isValid до агрегованого Binding. Тепер isValid автоматично оновлюється при зміні будь-якого error Property.

Binding у Controller

@FXML private Button saveButton;

private void setupBindings() {
    // Кнопка Save активна лише коли форма валідна
    saveButton.disableProperty().bind(viewModel.isValidProperty().not());
}

saveButton.disableProperty().bind(viewModel.isValidProperty().not()) — кнопка неактивна (disable = true), коли isValid = false. .not() інвертує Boolean Binding.

Діаграма потоку валідації

Loading diagram...

flowchart TB UserКористувач вводить текст TitleProptitle Property ListenerListener викликає validateTitle ValidatortitleValidator.validate ErrorProptitleError Property IsValidBindingisValid BooleanBinding SaveButtonsaveButton.disable

User -->|змінює| TitleProp
TitleProp -->|trigger| Listener
Listener -->|викликає| Validator
Validator -->|повертає ValidationResult| Listener
Listener -->|встановлює| ErrorProp
ErrorProp -->|titleError.isNull| IsValidBinding
IsValidBinding -->|bind| SaveButton

style User fill:#e1f5ff
style Validator fill:#fff4e1
style IsValidBinding fill:#e1ffe1
style SaveButton fill:#ffe1e1

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

  1. Користувач вводить текст → title Property змінюється.
  2. Listener викликає validateTitle().
  3. validateTitle() викликає titleValidator.validate().
  4. Validator повертає ValidationResult (success або error).
  5. titleError встановлюється у null (якщо success) або повідомлення (якщо error).
  6. titleError.isNull() Binding оновлюється.
  7. isValid Binding (агрегація всіх полів) оновлюється.
  8. saveButton.disable автоматично оновлюється через Binding.

Весь цей процес відбувається автоматично через Bindings — не потрібен ручний код оновлення UI.


Асинхронна валідація: Перевірка унікальності

Деякі перевірки потребують запиту до бази даних. Наприклад, перевірка унікальності username: чи не існує вже користувач з таким ім'ям? Це асинхронна операція — вона не може виконуватися у JavaFX Application Thread (блокування UI).

Проблема синхронної валідації

Якщо викликати repository.existsByUsername(username) у listener:

username.addListener((obs, old, newVal) -> {
    // ПОГАНО: блокує UI Thread!
    boolean exists = userRepository.existsByUsername(newVal);
    if (exists) {
        usernameError.set("Username already exists");
    } else {
        usernameError.set(null);
    }
});

Це блокує UI Thread на час виконання SQL-запиту (10-100 мс). Користувач вводить текст → UI заморожується на мить → погана UX.

Рішення: Асинхронна валідація через Task

public class UserFormViewModel {
    
    private final StringProperty username = new SimpleStringProperty("");
    private final StringProperty usernameError = new SimpleStringProperty();
    private final BooleanProperty isCheckingUsername = new SimpleBooleanProperty(false);
    
    private final UserRepository userRepository;
    private final ExecutorService executor;
    
    private Task<Boolean> currentValidationTask;
    
    @Inject
    public UserFormViewModel(UserRepository userRepository, ExecutorService executor) {
        this.userRepository = userRepository;
        this.executor = executor;
        setupValidation();
    }
    
    private void setupValidation() {
        username.addListener((obs, old, newVal) -> {
            validateUsernameAsync(newVal);
        });
    }
    
    private void validateUsernameAsync(String value) {
        // Скасувати попередню перевірку (якщо вона ще виконується)
        if (currentValidationTask != null && currentValidationTask.isRunning()) {
            currentValidationTask.cancel();
        }
        
        // Базова валідація (синхронна)
        if (value == null || value.trim().isEmpty()) {
            usernameError.set("Username is required");
            return;
        }
        
        if (value.length() < 3) {
            usernameError.set("Username must be at least 3 characters");
            return;
        }
        
        // Асинхронна перевірка унікальності
        isCheckingUsername.set(true);
        usernameError.set(null);
        
        currentValidationTask = new Task<>() {
            @Override
            protected Boolean call() {
                // Виконується у фоновому потоці
                return userRepository.existsByUsername(value);
            }
        };
        
        currentValidationTask.setOnSucceeded(event -> {
            // Виконується у JavaFX Thread
            boolean exists = currentValidationTask.getValue();
            if (exists) {
                usernameError.set("Username already exists");
            } else {
                usernameError.set(null);
            }
            isCheckingUsername.set(false);
        });
        
        currentValidationTask.setOnFailed(event -> {
            usernameError.set("Failed to check username availability");
            isCheckingUsername.set(false);
        });
        
        executor.submit(currentValidationTask);
    }
}

Розбір коду:

Рядки 26-28: Скасування попередньої перевірки. Якщо користувач швидко вводить текст ("john" → "johnd" → "johndo"), кожна зміна запускає нову перевірку. Ми скасовуємо попередню Task, щоб не виконувати непотрібні запити.

Рядки 31-39: Синхронна валідація спочатку. Перевіряємо базові правила (порожність, мінімальна довжина) синхронно. Якщо вони не пройдені, не запускаємо асинхронну перевірку.

Рядки 42-43: Індикатор завантаження. isCheckingUsername = true → у View можна показати ProgressIndicator біля поля.

Рядки 45-51: Створення Task. call() виконується у фоновому потоці — тут безпечно викликати repository.existsByUsername().

Рядки 53-61: Обробка результату. setOnSucceeded() виконується у JavaFX Thread — тут безпечно оновлювати Properties (usernameError, isCheckingUsername).

Рядки 63-66: Обробка помилки. Якщо запит до БД викинув виняток, показуємо загальне повідомлення про помилку.

Debouncing: Затримка перед перевіркою

Проблема: користувач вводить "johnsmith" → запускається 9 перевірок (по одній на кожну літеру). Це навантаження на БД.

Рішення: Debouncing — запускати перевірку лише після того, як користувач перестав вводити на 300-500 мс.

import javafx.animation.PauseTransition;
import javafx.util.Duration;

public class UserFormViewModel {
    
    private final PauseTransition debounceTimer = new PauseTransition(Duration.millis(500));
    
    private void setupValidation() {
        username.addListener((obs, old, newVal) -> {
            // Скидаємо таймер при кожній зміні
            debounceTimer.stop();
            
            // Базова валідація (одразу)
            if (newVal == null || newVal.trim().isEmpty()) {
                usernameError.set("Username is required");
                return;
            }
            
            // Асинхронна валідація (з затримкою)
            debounceTimer.setOnFinished(event -> validateUsernameAsync(newVal));
            debounceTimer.playFromStart();
        });
    }
}

Рядок 6: PauseTransition. Це JavaFX-таймер, що викликає callback після затримки.

Рядок 11: debounceTimer.stop(). При кожній зміні username скидаємо таймер. Якщо користувач вводить текст швидко, таймер постійно скидається і ніколи не спрацьовує.

Рядок 20: debounceTimer.playFromStart(). Запускаємо таймер. Якщо користувач перестав вводити на 500 мс, таймер спрацює і викличе validateUsernameAsync().

Асинхронна валідація може призвести до race conditions. Користувач вводить "john" → запускається перевірка A. Потім швидко вводить "jane" → запускається перевірка B. Якщо B завершиться раніше за A, результат A перезапише результат B. Рішення: скасовувати попередню Task або перевіряти, чи актуальне значення при обробці результату.

Обробка помилок Repository та Service

Валідація у ViewModel перевіряє формат даних. Але є помилки, що виникають на рівні Repository або Service: помилка з'єднання з БД, порушення унікальності (constraint violation), транзакційні помилки.

Приклад: Збереження аудіокниги

public class AudiobookFormViewModel {
    
    private final StringProperty errorMessage = new SimpleStringProperty();
    private final StringProperty successMessage = new SimpleStringProperty();
    private final BooleanProperty isSaving = new SimpleBooleanProperty(false);
    
    private final AudiobookService audiobookService;
    
    public void save() {
        // Очистити попередні повідомлення
        errorMessage.set(null);
        successMessage.set(null);
        
        // Створити Audiobook з Properties
        Audiobook audiobook = mapToModel();
        
        // Асинхронне збереження
        isSaving.set(true);
        
        Task<Void> saveTask = new Task<>() {
            @Override
            protected Void call() throws Exception {
                audiobookService.save(audiobook);
                return null;
            }
        };
        
        saveTask.setOnSucceeded(event -> {
            successMessage.set("Audiobook saved successfully");
            isSaving.set(false);
            clearForm();
        });
        
        saveTask.setOnFailed(event -> {
            Throwable exception = saveTask.getException();
            handleSaveError(exception);
            isSaving.set(false);
        });
        
        executor.submit(saveTask);
    }
    
    private void handleSaveError(Throwable exception) {
        if (exception instanceof DataIntegrityViolationException) {
            // Порушення унікальності або foreign key constraint
            errorMessage.set("Audiobook with this title already exists");
        } else if (exception instanceof DataAccessException) {
            // Помилка доступу до БД (з'єднання, timeout)
            errorMessage.set("Database error. Please try again later.");
        } else if (exception instanceof ValidationException) {
            // Бізнес-валідація у Service
            errorMessage.set(exception.getMessage());
        } else {
            // Невідома помилка
            errorMessage.set("An unexpected error occurred");
            logger.error("Error saving audiobook", exception);
        }
    }
    
    private Audiobook mapToModel() {
        return new Audiobook(
            title.get(),
            selectedAuthor.get(),
            selectedGenre.get(),
            duration.get(),
            releaseYear.get()
        );
    }
    
    private void clearForm() {
        title.set("");
        duration.set(0);
        releaseYear.set(Year.now().getValue());
        selectedAuthor.set(null);
    }
}

Рядки 34-38: Обробка помилки. setOnFailed() викликається, якщо call() викинув виняток. Ми отримуємо виняток через getException() та обробляємо його у handleSaveError().

Рядки 43-59: Класифікація помилок. Різні типи винятків обробляються по-різному:

  • DataIntegrityViolationException — порушення constraint (унікальність, foreign key) → показуємо зрозуміле повідомлення користувачу.
  • DataAccessException — помилка БД (з'єднання, timeout) → загальне повідомлення + можливість повторити.
  • ValidationException — бізнес-валідація у Service → показуємо повідомлення з винятку.
  • Інші винятки — логуємо для розробників, показуємо загальне повідомлення користувачу.

Типи помилок та їх обробка

== Валідаційні помилки Де виникають: ViewModel (формат, обов'язковість), Service (бізнес-правила).


Відображення помилок у View

Є три основні способи показати помилку користувачу:

1. Inline Errors: Label біля поля

Коли використовувати: Валідаційні помилки конкретного поля.

FXML:

<VBox spacing="5">
    <Label text="Title:"/>
    <TextField fx:id="titleField"/>
    <Label fx:id="titleErrorLabel" 
           styleClass="error-label"
           wrapText="true"
           visible="false"
           managed="false"/>
</VBox>

Controller:

titleErrorLabel.textProperty().bind(viewModel.titleErrorProperty());
titleErrorLabel.visibleProperty().bind(viewModel.titleErrorProperty().isNotNull());
titleErrorLabel.managedProperty().bind(viewModel.titleErrorProperty().isNotNull());

CSS:

.error-label {
    -fx-text-fill: #e74c3c;
    -fx-font-size: 12px;
    -fx-padding: 2 0 0 0;
}

.text-field:error {
    -fx-border-color: #e74c3c;
    -fx-border-width: 2px;
}

2. Toast Notifications: Тимчасове повідомлення

Коли використовувати: Успішні операції ("Saved successfully") або некритичні помилки.

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

import org.controlsfx.control.Notifications;

public class AudiobookFormViewModel {
    
    public void save() {
        // ... збереження
        
        saveTask.setOnSucceeded(event -> {
            Notifications.create()
                .title("Success")
                .text("Audiobook saved successfully")
                .showInformation();
        });
        
        saveTask.setOnFailed(event -> {
            Notifications.create()
                .title("Error")
                .text("Failed to save audiobook")
                .showError();
        });
    }
}

Переваги: Не блокує UI, автоматично зникає через кілька секунд, не вимагає дії користувача.

3. Alert Dialogs: Модальне вікно

Коли використовувати: Критичні помилки, що вимагають уваги користувача.

Controller:

private void showErrorDialog(String title, String message) {
    Alert alert = new Alert(Alert.AlertType.ERROR);
    alert.setTitle(title);
    alert.setHeaderText(null);
    alert.setContentText(message);
    alert.showAndWait();
}

// Використання
viewModel.errorMessageProperty().addListener((obs, old, newVal) -> {
    if (newVal != null && !newVal.isEmpty()) {
        showErrorDialog("Error", newVal);
    }
});

Недоліки: Блокує UI (модальне вікно), вимагає дії користувача (натиснути OK).

Комбінуйте підходи: Inline errors для валідації полів, Toast для успішних операцій та некритичних помилок, Alert для критичних помилок. Це забезпечує баланс між інформативністю та зручністю.

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

Рівень 1: Базова валідація

Завдання 1.1: Створіть EmailValidator, що перевіряє email через regex ^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$. Використайте його у UserFormViewModel для валідації поля email.

Завдання 1.2: Створіть PasswordValidator, що перевіряє пароль: мінімум 8 символів, хоча б одна велика літера, хоча б одна цифра. Використайте CompositeValidator для об'єднання цих правил.

Завдання 1.3: Додайте у AudiobookFormViewModel валідацію поля description: максимум 1000 символів. Прив'яжіть error Label у View до descriptionErrorProperty.

Рівень 2: Агрегація валідності та асинхронна валідація

Завдання 2.1: Створіть форму реєстрації користувача з полями: username, email, password, confirmPassword. Реалізуйте isValidProperty, що агрегує валідність всіх полів. Кнопка "Register" має бути активною лише коли форма валідна.

Завдання 2.2: Додайте валідатор для confirmPassword: має співпадати з password. Використайте listener на обидва поля для перевірки.

Завдання 2.3: Реалізуйте асинхронну валідацію унікальності email з debouncing (500 мс). Показуйте ProgressIndicator біля поля під час перевірки.

Рівень 3: Обробка помилок та складні сценарії

Завдання 3.1: Створіть AudiobookFormViewModel.save() з обробкою помилок: DataIntegrityViolationException (показати "Title already exists"), DataAccessException (показати "Database error"), інші винятки (показати загальне повідомлення + логувати).

Завдання 3.2: Реалізуйте систему відображення помилок: inline errors для валідації полів, Toast notification для успішного збереження, Alert dialog для критичних помилок.

Завдання 3.3: Створіть ValidationService, що централізує всі Validators додатку. Використайте Guice для ін'єкції ValidationService у ViewModels. Додайте можливість реєструвати кастомні Validators через конфігурацію.


Підсумок

У цій статті ми побудували повноцінну систему валідації та обробки помилок для MVVM-додатку. Ключові висновки:

Валідація у MVVM — це реактивний процес. Кожна зміна Property → перевірка → оновлення error Property → автоматичне оновлення UI через Bindings. Користувач бачить помилки одразу, а не після натискання "Save".

Validator Pattern централізує правила валідації. Кожен Validator — це окремий клас з методом validate(value). Це дозволяє перевикористовувати валідатори (NotEmptyValidator, MaxLengthValidator), тестувати їх ізольовано, комбінувати через CompositeValidator.

CompositeValidator об'єднує кілька перевірок. Замість виклику трьох Validators вручну, створюємо CompositeValidator(validator1, validator2, validator3). Він викликає їх по черзі та повертає першу помилку.

isValidProperty агрегує валідність всіх полів. BooleanBinding з titleError.isNull().and(durationError.isNull()).and(...) автоматично оновлюється при зміні будь-якого error Property. Прив'язуємо saveButton.disableProperty() до isValid.not() — кнопка активна лише коли форма валідна.

Асинхронна валідація через Task. Перевірка унікальності (запит до БД) виконується у фоновому потоці. Debouncing (затримка 300-500 мс) зменшує кількість запитів. Скасування попередньої Task запобігає race conditions.

Два рівні валідації: UI-рівень (формат, обов'язковість) у ViewModel. Бізнес-рівень (унікальність, бізнес-правила) у Service. ViewModel перевіряє "чи можна відправити", Service перевіряє "чи можна зберегти".

Обробка помилок Repository та Service. Винятки класифікуються: валідаційні (показати біля поля), системні (показати Alert/Toast), критичні (логувати + загальне повідомлення). Task.setOnFailed() перехоплює винятки з фонового потоку.

Три способи відображення помилок: Inline errors (Label біля поля) для валідації, Toast notifications для успішних операцій та некритичних помилок, Alert dialogs для критичних помилок. Комбінація забезпечує баланс між інформативністю та зручністю.

Properties для помилок: Кожне поле має errorProperty (повідомлення про помилку або null). View прив'язує Label до errorProperty через Binding. Видимість Label прив'язана до errorProperty.isNotNull().

У наступній статті ми розглянемо навігацію та управління екранами у JavaFX MVVM-додатку: як організувати переходи між екранами, як передавати параметри між ViewModels, як реалізувати Navigator Pattern, як керувати історією навігації (Back button), та як відкривати модальні діалоги з поверненням результату.

Copyright © 2026