A Clean Alternative: Mediator-Based MVVM for Wizards in JavaFX + Spring Boot
After exploring multiple architectural options and learning from excellent feedback on StackOverflow and SoftwareEngineering.SE (thanks @James_D, @DaveB, @Ewan, and others), I implemented a third solution that closely follows the Mediator Pattern, and itβs worked great in production.
Using WizardViewModel as a Mediator
Each StepViewModel is fully decoupled β it doesnβt call the master directly or publish Spring Events.
Instead:
The WizardViewModel tracks the current workflow step and ViewKey.
The Controller (not the ViewModel!) listens for validation triggers and invokes wizardViewModel.onStepCompleted() when appropriate.
All validation, error state, and workflow logic is centralized in the mediator.
UI transitions are driven reactively using JavaFX Properties.
Controller β Mediator Communication (Example)
@Component
public class AdminPinController {
@FXML private PasswordField user1EnterPin;
private final WizardViewModel wizardVm;
private final ValidationHelper validationHelper;
private final ValidationState validationState;
private final Validator validator = new Validator();
public AdminPinController(WizardViewModel wizardVm,
ValidationHelper validationHelper,
ValidationState validationState) {
this.wizardVm = wizardVm;
this.validationHelper = validationHelper;
this.validationState = validationState;
}
@FXML
public void initialize() {
validationHelper.registerAdminsLoginValidations(validator, user1EnterPin);
validationState.formInvalidProperty().bind(validator.containsErrorsProperty());
wizardVm.validationRequestedProperty().addListener((obs, oldVal, newVal) -> {
if (Boolean.TRUE.equals(newVal) && isCurrentStepRelevant()) {
handleValidation();
wizardVm.validationProcessed();
}
});
}
private boolean isCurrentStepRelevant() {
return wizardVm.getCurrentContext().getStep() == WizardStep.LOGIN_USER1;
}
private void handleValidation() {
if (!validator.validate()) {
wizardVm.setErrorMessage("PIN validation failed.");
return;
}
wizardVm.onUserCompletedStep();
}
}
Inside the Mediator: WizardViewModel
@Component
public class WizardViewModel {
private final ObjectProperty<WizardStep> currentWorkflowStep = new SimpleObjectProperty<>(WizardStep.LOGIN_USER1);
private final ObjectProperty<ViewKey> currentViewKey = new SimpleObjectProperty<>();
public void onUserCompletedStep() {
WizardStep next = currentWorkflowStep.get().next();
currentWorkflowStep.set(next);
currentViewKey.set(resolveViewFor(next));
}
public void setErrorMessage(String message) { /* ... */ }
public void validationProcessed() { /* ... */ }
public ReadOnlyObjectProperty<WizardStep> currentWorkflowStepProperty() { return currentWorkflowStep; }
public ReadOnlyObjectProperty<ViewKey> currentViewKeyProperty() { return currentViewKey; }
}
Validation is performed in the Controller using ValidatorFX
.
ViewModel exposes a BooleanProperty
for form validity:
validationState.formInvalidProperty().bind(validator.containsErrorsProperty());
Errors are managed centrally via:
wizardViewModel.setErrorMessage("PIN validation failed.");
Pattern | Pros | Cons |
---|---|---|
JavaFX Property Binding | Reactive, type-safe | Wizard must reference every StepViewModel |
Spring Events | Fully decoupled, modular | Async, UI-thread issues, more boilerplate |
Mediator (this) | Centralized logic, sync, testable | No boilerplate, Requires Controller to forward calls |
β
Centralized workflow logic
β
Fully decoupled StepViewModels
β
No Spring Events or property wiring overhead
β
MVVM-pure: Controller handles UI β ViewModel handles state
β
Reactive, testable, and easy to debug
β
Works cleanly with JavaFX threading