Валідація та обробка помилок у MVVM
Валідація та обробка помилок у 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. Це реактивність у дії.
Реактивна валідація на рівні 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;
}
Валідація кількох полів
Розширимо 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).
@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.
Діаграма потоку валідації
Пояснення потоку:
- Користувач вводить текст →
titleProperty змінюється. - Listener викликає
validateTitle(). validateTitle()викликаєtitleValidator.validate().- Validator повертає
ValidationResult(success або error). titleErrorвстановлюється уnull(якщо success) або повідомлення (якщо error).titleError.isNull()Binding оновлюється.isValidBinding (агрегація всіх полів) оновлюється.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().
Обробка помилок 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 (бізнес-правила).
Як обробляти: Показувати біля відповідного поля (inline error) або у загальному блоці помилок форми.
Приклад: "Title is required", "Duration must be positive".
== Системні помилки Де виникають: Repository (помилка БД), Service (помилка зовнішнього API).
Як обробляти: Показувати Alert dialog або Toast notification. Логувати для розробників.
Приклад: "Database connection failed", "External API timeout".
== Критичні помилки Де виникають: Непередбачені винятки (NullPointerException, OutOfMemoryError).
Як обробляти: Логувати з повним stack trace. Показувати загальне повідомлення користувачу. Можливо, перезапустити додаток або відправити звіт розробникам.
Приклад: "An unexpected error occurred. Please restart the application."
Відображення помилок у 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).
Практичні завдання
Рівень 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), та як відкривати модальні діалоги з поверненням результату.
Інтеграція MVVM з Guice: Автоматична ін'єкція залежностей
Від ручного створення об'єктів до Dependency Injection: Guice Module, ControllerFactory, Constructor Injection, Scopes (Singleton vs Prototype), AssistedInject для параметризованих ViewModel.
Навігація та управління екранами у JavaFX MVVM
Від хаотичних переходів до централізованої навігації: Navigator Pattern, ScreenRegistry, передача параметрів між екранами, Navigation Stack для історії переходів, модальні діалоги з поверненням результату, інтеграція з ViewModel через Events.