Binding — Connecting Models to Views¶
Overview and Motivation¶
The Tentackle binding API connects the members of plain Java objects (the model) to the components of a user interface (the view), keeping the two synchronized in both directions. A binding moves a value from the model into the view (to-view) and writes an edited value back from the view into the model (to-model), running validation, mandatory/changeable evaluation, and veto-capable listeners along the way.
The model side is deliberately UI-agnostic. The base API in
org.tentackle.bind (module tentackle-core) knows nothing about JavaFX, Swing,
or any concrete widget toolkit — it works purely by reflection over annotated
members. A concrete toolkit binds to it by providing implementations of a
handful of interfaces. The reference implementation for JavaFX lives in
org.tentackle.fx.bind (module tentackle-fx).
Typical models are PDOs (Persistent Domain Objects) or controllers,
but any object will do — a bean, a record, an immutable value object, or a
controller field holding a char[]. The binding works by declaration, never
by keeping a reference to the bound object instance: the same binder can serve
many model instances over its lifetime.
Why a dedicated binding layer?¶
- Separation of concerns — domain interfaces declare what is bindable with a single annotation; they never import a UI class. The view declares which widget displays a value through a naming convention. Neither side depends on the other.
- No boilerplate glue code — there is no
field.textProperty().bind(...)per widget. A binder scans the controller, matches components to model members by name, and creates all bindings automatically. - Aggregate- and graph-aware — bindings follow nested member paths
(
customer.address.zip), walking the object graph at transfer time. Anullanywhere along the path simply clears the view instead of throwing. - Validation built in — field- and getter-level validation annotations are discovered together with the binding, so mandatory/changeable state and validation messages flow to the view without extra wiring.
- Immutable members supported — members of immutable objects are written through a from-method that returns a new object; the binder propagates the replacement up the parent chain.
Design principles¶
- The binding holds no reference to the bound model instance. The root object is resolved on each transfer (in the FX case, from the controller).
- Only the leaves of a binding hierarchy are actually bound. Annotated objects may contain further annotated members; the binder recurses and binds the terminal values.
- A binder is a scanner and registry, not a data holder. It discovers eligible components and bindable members and pairs them up.
- The base layer is abstract; concrete behavior (how to read/write a widget, what the root object is) is supplied by toolkit subclasses.
Key Concepts¶
Bindable — marking model members¶
@Bindable (org.tentackle.bind.Bindable) marks a field or method of any class
as eligible for binding. It targets FIELD and METHOD and is retained at
runtime.
public interface UserPersistence {
@Bindable
boolean isLoginAllowed();
@Bindable
void setLoginAllowed(boolean loginAllowed);
@Bindable
TrackedList<UserGroup> getUserGroups();
}
It carries two attributes:
| Attribute | Meaning |
|---|---|
options |
UI binding options as a comma-separated string, e.g. @Bindable(options = "UC,MAXCOLS=5"). Interpreted by the concrete binding (see Binding options). |
ordinal |
Sort key. The binder processes members ordered by ordinal then name. Useful when configurations (e.g. table column order) are generated from the binding. |
An annotated object member may itself contain annotated members; only the leaf values are bound. The class need not be a bean — getters, setters, fields, and from-methods are all discovered.
BindableElement — the reflective descriptor¶
A BindableElement is the cached, reflection-level description of one annotated
member: its camel-case name, its ordinal and options, and the
Field / getter Method / setter Method / from-Method that read and write
it. DefaultBindableElement implements the actual access logic:
- Reading (
getObject): getter takes precedence over field. - Writing (
setObject): setter → from-method → field, in that order. Primitive targets receive a safe default when the incoming value isnull. - Read-only when there is no field, no setter, and no from-method.
- Type is derived lazily from the getter return type or field type.
BindableCache caches the sorted BindableElements per class (declared-only or
including inherited), so the relatively expensive reflection scan happens once
per class.
From- or with-method — binding immutable members¶
When a member belongs to an immutable object, there is no setter. Instead, the
member may expose a from-method or a with-method that takes the new value and returns a new
immutable instance. On a to-model transfer the binding calls that method,
gets the replacement object, and then walks up the parent chain writing the
replacement into each parent until it finds a parent with a setter or field
(AbstractBinding.setModelValue). If the chain cannot absorb the replacement, a
BindingException is thrown.
This feature is essential for application-specific DataTypes, for example, a
Position3D that is immutable but can be constructed from a 3-tuple of doubles.
BindingMember and the binding path¶
A BindingMember is one node in a declaration chain. Each binding records the
chain of parent members (getParents()) leading to the bound leaf member
(getMember()). The dotted concatenation of member names is the binding
path, e.g. customer.address.zip. DefaultBindingMember wraps a
BindableElement together with its position in the chain.
At transfer time the binding resolves the actual parent object by starting at the
root object and calling getObject along the chain
(AbstractBinding.getParentObject). Any null encountered stops the walk and
results in the view being cleared rather than an exception.
Binding — one model member ↔ one view component¶
Binding is the link between a single bound member and a single view component.
It exposes the four fundamental transfer operations:
| Method | Direction |
|---|---|
getModelValue() |
reads the value out of the model |
setViewValue(Object) |
pushes a value into the view |
getViewValue() |
reads the current value from the view |
setModelValue(Object) |
writes a value back into the model |
AbstractBinding implements the model-side logic (path walking, listener firing,
veto handling, validation) and leaves the toolkit-specific parts abstract:
updateView(value), getViewValue(), getBoundRootObject(), getViewType(),
viewComponentToString(), isValidationRequired(). A binding also manages its
own listener lists, its validators, and the mandatory/changeable state of the
bound element.
Binder — the scanner and registry¶
Binder owns a set of bindings for one view. Its central job is to scan a
view, find eligible components and annotated model members, and pair them up by
binding path. Key responsibilities:
- Binding modes —
bind()(declared members and components only),bindAllInherited(), and (FX)bindWithInheritedBindables()/bindWithInheritedComponents(). The declared-only path is the fast path; the inherited variants are more expensive and only run when components remain unbound. - Registry —
getBinding(path),addBinding,removeBinding,unbind,getBindings. - Completeness check —
assertAllBound()throws aBindingExceptionlisting every component that was left unbound. - Binding properties — a
key → valuemap (putBindingProperty/getBindingProperty) for extra context a component may need but the model does not provide, most commonly theDomainContext:
getBinder().putBindingProperty(DomainContext.class, pdo.getDomainContext());
// ...later, inside a component or translator:
DomainContext ctx = getBinder().getBindingProperty(DomainContext.class);
- Listeners and validation scope — view/model/validation listeners and the
active
ValidationScopeare held at the binder level and shared by its bindings.
AbstractBinder provides the bookkeeping (unique instance number for ordering,
property map, listener lists, scope) and leaves the actual scan,
doBind(...), to the concrete subclass.
BindingFactory — pluggable construction¶
BindingFactory creates BindingMembers and BindableElements and owns the
BindableCache. It also maintains a model class → element class mapping so an
application can register a custom BindableElement subclass for specific model
types. AbstractBindingFactory implements the generic logic, including
superclass-walking lookup of the element class, with results memoized in a
concurrent map.
The concrete factory adds the toolkit pieces and is discovered as a service.
How a Binding Runs¶
To-view (model → view)¶
- The binder/binding resolves the parent object by walking the member path from the root object.
getModelValue()reads the leaf value (getter or field).ToViewListeners registered on both binding and binder fire; any may throw aBindingVetoExceptionto suppress the update (optionally requesting a resync).- Fixed mandatory/changeable validators are evaluated and pushed to the component (it may become required or read-only).
updateView(value)sets the value into the widget.
To-model (view → model)¶
getViewValue()reads the current widget value.ToModelListeners on binding and binder fire; a veto suppresses the write.member.setObject(parent, value)writes the value (setter → from-method → field). If a from-method returned a new object, the binding propagates the replacement up the parent chain.- The binder flags that mandatory/changeable state may have changed and need a refresh.
Vetoes and resync¶
A listener throws BindingVetoException to stop a transfer. The exception
carries two flags:
resyncRequested— re-push the other side's current value so model and view stay consistent (e.g., on a rejected to-model write, refresh the view from the model).rethrowEnabled— after processing, rethrow as an uncheckedBindingExceptionrather than swallowing silently.
BindingException is the unchecked runtime exception used throughout the API
(unbound components, inaccessible members, failed from-method propagation, etc.).
Validation, mandatory and changeable¶
Validators are discovered from the bound member's field/getter annotations
(AbstractBinding.determineValidators) and filtered by a binding scope that
always includes the mandatory and changeable scopes plus the binder's active
scope. Binding.validate() runs them and fires ValidationListeners.
Mandatory and changeable state can be fixed (constant for the binding) or
dynamic (recomputed as the model changes — e.g., a field becomes mandatory
only when another field has a certain value). Fixed evaluators are applied by the
binding on each to-view transfer; dynamic evaluators are collected by the binder
and refreshed via requestMandatoryUpdate() / requestChangeableUpdate() after
model changes.
The JavaFX Implementation¶
The tentackle-fx module implements the API for JavaFX in
org.tentackle.fx.bind. The concrete factory DefaultFxBindingFactory is
annotated @Service(FxBindingFactory.class) and obtained as a singleton via
FxBindingFactory.getInstance() (resolved through ServiceFactory, so it works
both modular and non-modular).
| Base interface | FX interface | FX default implementation |
|---|---|---|
BindingFactory |
FxBindingFactory |
DefaultFxBindingFactory |
Binder |
FxComponentBinder |
DefaultFxComponentBinder |
Binding |
FxComponentBinding |
DefaultFxComponentBinding |
Binder |
FxTableBinder<S> |
DefaultFxTableBinder |
Binding |
FxTableBinding<S,T> |
DefaultFxTableBinding |
Controllers, components, and matching by name¶
A FxController owns its binder lazily (AbstractFxController.getBinder() →
FxBindingFactory.createComponentBinder(this)). The controller is the root
object: DefaultFxComponentBinding.getBoundRootObject() returns the controller,
and bound model members are reached as fields of the controller annotated
@Bindable.
The binder scans the controller for FxComponent fields (typically @FXML
widgets) and the @Bindable members reachable from the controller, then matches
components to binding paths by name. For each component it derives one or more
candidate binding paths from the field name:
- the field name itself (
userNameField); - the field name with a trailing component-type name removed
(
userNameField→userName, since the widget is anFxTextField); - the field name with a well-known suffix removed. The suffix list is
DefaultFxComponentBinder.bindingPathSuffixes={ "Field", "Node", "Comp", "Component", "Control" }and is application-modifiable.
So a controller field FxTextField userNameField binds to the model path
user.userName when the controller has a @Bindable User user. An application
may instead set an explicit path with FxComponent.setBindingPath(...).
The binder recurses through nested annotated members (with loop detection bounded
by the longest component path) so deep paths such as
customer.address.zip resolve automatically. assertAllBound() reports any
FxComponent left without a binding.
A complete example¶
From the archetype-generated myapp project, ChangePasswordView binds three
char[] controller fields directly to password widgets purely by name:
@FxControllerService
public class ChangePasswordView extends AbstractFxController {
@Bindable private char[] oldPassword;
@Bindable private char[] newPassword;
@Bindable private char[] confirmedNewPassword;
@FXML private FxPasswordField oldPasswordField; // -> oldPassword
@FXML private FxPasswordField newPasswordField; // -> newPassword
@FXML private FxPasswordField confirmedNewPasswordField; // -> confirmedNewPassword
// ...
private void setUser(User user, boolean admin) {
// ...
getContainer().updateView(); // push model values into the widgets
}
}
Here the model lives directly on the controller. UserEditor instead binds a
whole PDO and supplies its domain context as a binding property:
@FxControllerService
public class UserEditor extends PdoEditor<User> {
@Bindable private User user;
@FXML private FxTextField userNameField; // -> user.userName
@FXML private FxCheckBox userLoginAllowedField; // -> user.loginAllowed
@FXML private FxTableView<UserGroup> userUserGroupsNode; // -> user.userGroups
@Override
public void setPdo(User pdo) {
user = pdo;
getBinder().putBindingProperty(DomainContext.class, pdo.getDomainContext());
}
}
The binding is triggered by the framework after the controller is loaded; the
mode (declared-only vs. inherited) is selected via the controller's binding
configuration (DefaultFxFactory maps it to bind(),
bindWithInheritedComponents(), bindWithInheritedBindables() or
bindAllInherited()).
Value translation¶
A widget rarely speaks the model's type directly: a text field shows a String,
but the bound member may be a BigDecimal, a LocalDate, an enum, a char[],
or an application-specific money type. The bridge is the ValueTranslator<M,V>
(org.tentackle.fx.ValueTranslator), where M is the model type and V the
view type. It exposes two functions — toViewFunction() (M → V) and
toModelFunction() (V → M), with the convenience methods toView(M) and
toModel(V). Tentackle deliberately calls them translators rather than
converters so they are not confused with JavaFX StringConverters.
Why this matters: the view and the controller carry no type-specific code. Because the translator encapsulates all parsing, formatting, and conversion, the view and the controller never deal with model data types:
- The view (FXML, CSS, widget choice) describes only the look — which
component, its layout, and styling. It is completely independent of the model's
data types. An
FxTextFieldis just a text field; it does not know or care whether it ultimately edits a number, a date or a string. - The controller only declares
@Bindablemodel members and@FXMLcomponents. There is noInteger.parseInt(...), noDateTimeFormatter, no per-fieldStringConverter, no validation of raw text — none of the conversion plumbing that normally clutters UI code.
A direct consequence: changing a model field's type (say int → BigDecimal)
requires no change to the view or the controller. The binding simply selects
a different translator for the new type. The look stays; only the model evolves.
How a translator is chosen. When a binding is created it applies the member's
type to the component (DefaultFxComponentBinding.applyType() →
FxComponent.setType(member.getType())). For text components,
FxTextComponentDelegate.setType then lazily creates a translator via
FxFactory.createValueTranslator(type, String.class, component) — unless the
application has already set one with setValueTranslator(...). The factory
(DefaultFxFactory) builds a registry at startup by scanning every
@ValueTranslatorService(modelClass = ..., viewClass = ...) provider through the
ServiceFinder SPI,
keyed by (modelClass, viewClass). Lookup
walks up the model's superclasses (DefaultClassMapper.mapLenient), so one
Number- or Temporal-based translator covers all its subclasses.
Out of the box (org.tentackle.fx.translate) the framework ships translators for
String ↔ the numeric types (Integer, Long, Short, Byte, Double,
Float, BigDecimal/money, fraction numbers), String ↔ the temporal types
(LocalDate, LocalTime, LocalDateTime, Instant, offset/zoned variants,
legacy Date/Timestamp), enums, colors, char[] and I18NText, an
IdentityTranslator for the pass-through case, plus list- and tree-oriented
translators for tables and trees. An application adds support for a new type
simply by publishing its own @ValueTranslatorService provider — no change to
the binding layer.
At transfer time the component delegates conversion to its translator:
setViewValue(value) runs translator.toView(value) before pushing the result
into the widget, and getViewValue() runs translator.toModel(...) on the raw
widget value. The binding itself stays type-agnostic.
The same translators serve table and tree-table cells. Value translation is
not limited to single-component (FxComponentBinding) bindings. When a
ProductNumber (or any other type) appears as a column of a table or
tree-table, the cell obtains a translator from the very same factory and
registry: FxTableCell and FxTreeTableCell call
FxFactory.createValueTranslator(editor.getType(), cellType.getEditorType(),
editor), then use translator.toView(...) to render the cell and
translator.toModel(...) to commit an edit (see
Table binding). A type therefore needs one translator to be
displayed and edited everywhere — in form fields, in table columns, and in
tree-table columns alike — with no per-cell formatting code and no duplicate
registration.
Advanced behavior the translator interface also supports:
isLenient()— parse partial/loose input to the model without throwing.isMappingIncomplete()withsaveModelValue/isModelModified— for types likeI18NTextwhere the view may look unchanged while the model has actually changed; lets the binding still detect a modification.needsToModelTwice()— forces a second to-model round-trip when formatting can alter the value (e.g., aDoubleshown with scale 2 must re-read1.21after the view reformats1.211111).bindingPropertiesUpdated()— invoked by the binder'sbindingPropertiesUpdated()for every bound component's translator whenever a binding property changes. A translator that depends on, say, theDomainContextset viagetBinder().putBindingProperty(DomainContext.class, ...)(see theUserEditorexample above) re-initializes itself here.
A worked example: a domain value type with live input assistance¶
The built-in translators cover the common scalar types, but the real strength of
the mechanism shows when an application teaches the framework one of its own
domain types. The following translator, taken from a production project, binds an
industrial product number — an aluminum-coil identifier with a fixed composite
layout AAAA-QQQ-TTT-WWWW-LLLL-XXX (alloy, quality, thickness, width, length and
an optional test segment, each of a fixed width defined as a constant on the
model class). The model type ProductNumber is a domain value object; the view
is an ordinary text field.
@ValueTranslatorService(modelClass = ProductNumber.class, viewClass = String.class)
public class ProductNumberTranslator extends ValueStringTranslator<ProductNumber>
implements UnaryOperator<TextFormatter.Change> {
private boolean relaxDashHandling;
public ProductNumberTranslator(FxTextComponent component) {
super(component);
if (component instanceof TextInputControl) {
// install ourselves as the field's text formatter for live input assistance
((TextInputControl) component).setTextFormatter(new TextFormatter<>(this));
}
}
@Override
public Function<ProductNumber, String> toViewFunction() {
relaxDashHandling = false;
return m -> m == null ? null : m.toString(); // canonical dashed form
}
@Override
public Function<String, ProductNumber> toModelFunction() {
return s -> {
if (s == null) {
return null;
}
// normalize each dash-separated segment to its fixed width,
// left-padding alloy with spaces and the numeric segments with zeros,
// and reject anything that violates the segment-length rules...
// (see malformedProductNumber below)
return new ProductNumber(buf.toString(), isLenient());
};
}
private void malformedProductNumber(int errorOffset) {
getComponent().setError(PlsblGuiBundle.getString("malformed product number"));
getComponent().setErrorOffset(errorOffset); // mark the exact position
getComponent().setErrorTemporary(true);
throw new FxRuntimeException("malformed product number at " + errorOffset);
}
@Override
public TextFormatter.Change apply(TextFormatter.Change t) {
t.setText(t.getText().toUpperCase()); // force upper case
// ...auto-insert a dash once the current segment is complete and the
// text typed so far is well-formed, so the user need not type dashes...
return t;
}
}
What this single, self-contained class accomplishes — and where that code would otherwise have to live:
- It registers itself. The
@ValueTranslatorService(modelClass = ProductNumber.class, viewClass = String.class)annotation publishes the class through theServiceFinderSPI. From then on, any text field bound to anyProductNumbermember, in any controller, automatically uses this translator — there is no registration call, no controller wiring, no FXML attribute. Dropping the class into the GUI module is the entire integration. - It owns both conversions.
toViewFunction()renders the model as its canonical dashed string;toModelFunction()parses free-form user input, normalizing each segment to its fixed width and validating the layout. The view never sees aProductNumber, and the controller never parses or formats one. - It owns the input ergonomics. By also implementing
UnaryOperator<TextFormatter.Change>and installing itself as the field'sTextFormatter, the translator turns the otherwise tedious fixed-width format into a fast, guided entry. It upper-cases input and — the key ergonomic feature — autocompletes the segment separators: as soon as a segment reaches its defined width and everything typed so far is well-formed, the next dash is inserted automatically and the caret advanced. The user types only the significant characters (AA12010…) and the field fills in the canonicalAAAA-QQQ-TTT-WWWW-LLLL-XXXskeleton (AA12-010-…) as they go, and the matching product numbers automatically pop up as well. Because this behavior is part of how the type is edited, it lives with the type — not duplicated in every view that shows a product number, and it works identically in a form field or a table cell. - It owns error reporting. On malformed input it sets a localized, positioned
error directly on the component (
setError/setErrorOffset/setErrorTemporary) and aborts the parse with anFxRuntimeException. The binding surfaces the marker; the controller writes no validation code. - It honors lenient mode. Both parsing (
new ProductNumber(text, isLenient())) and the separator autocompletion consult the inheritedisLenient()flag, so the same translator serves strict entry forms and relaxed search fields without change. - please note that it's recommended to use the FX base classes in the FXML file,
such as
TextField, as they will be upgraded to the FX-specific classes by theFxBuilderFactory. - it is possible to use the binding without FXML completely by providing a
manually written view and annotating its bindable components with
@NoFXML. So you can switch between FXML and non-FXML without changing the controller. - manually writing traditional binding code is still possible, since each
control can opt out via
setBindable(false)from the framework's automatic binding.
The payoff restated: the FXML for a product-number field is just an
TextField, and the controller just declares @Bindable ProductNumber product
(or binds a PDO whose member is a ProductNumber). All knowledge of the type —
its format, parsing, validation, error positioning, and separator autocompletion —
is concentrated in one place and reused everywhere the type appears, whether the
product number is shown in a form field, a table column, or a tree-table column.
Binding options¶
For text components, @Bindable(options = "...") (or options set on the
component) tune input behavior.
See model-definition.md
for details.
Table binding¶
FxTableBinder<S> / FxTableBinding<S,T> bind the columns of a table to the
members of the row type S. Instead of a single root object resolved from a
controller, each binding's root is the row object, set per row via
FxTableBinding.setBoundRootObject(S). The table binder works from a
TableConfiguration<S>, pairs @Bindable members of S with column
configurations, and (like the component binder) reports bound and unbound
columns. This drives both how cell values are read/written and how column
configurations (order, etc.) are generated from the model.
Extending the API¶
- Custom binding for a component type — register a
FxComponentBindingsubclass withFxBindingFactory.setComponentBindingClass(componentClass, bindingClass); the factory instantiates it (via a constructor takingFxComponentBinder, BindingMember[], BindingMember, FxComponent, String) when binding components of that class or a subclass. - Custom bindable element — register a
BindableElementsubclass per model class withBindingFactory.putBindableElementClass(modelClass, elementClass). - Custom suffixes — replace
DefaultFxComponentBinder.bindingPathSuffixesto change how component field names are reduced to binding paths. - A new toolkit — implement
Binder,BindingandBindingFactory(the abstract base classes carry most of the model-side logic) and publish the factory as a@Service.
Package Reference¶
org.tentackle.bind (tentackle-core)¶
| Type | Role |
|---|---|
Bindable |
annotation marking a bindable member (options, ordinal) |
BindableElement / DefaultBindableElement |
reflective descriptor and access logic for a member |
BindableCache / DefaultBindableCache |
per-class cache of sorted bindable elements |
BindingMember / DefaultBindingMember |
one node in the declaration chain (the binding path) |
Binding / AbstractBinding |
one model-member ↔ view-component link; transfer, veto, validation |
Binder / AbstractBinder |
scans a view, registers and manages bindings |
BindingFactory / AbstractBindingFactory |
creates members/elements, owns the cache and class maps |
BindingEvent |
event carrying type, parent and value across a transfer |
ToViewListener / ToModelListener |
veto-capable transfer listeners |
ValidationListener / ValidationEvent |
validation result notification |
BindingVetoException |
thrown by listeners to suppress (and optionally resync/rethrow) a transfer |
BindingException |
unchecked runtime exception for binding failures |
org.tentackle.fx.bind (tentackle-fx)¶
| Type | Role |
|---|---|
FxBindingFactory / DefaultFxBindingFactory |
@Service singleton factory for FX |
FxComponentBinder / DefaultFxComponentBinder |
binds a controller's FxComponents by name |
FxComponentBinding / DefaultFxComponentBinding |
binds one member to one FxComponent |
FxTableBinder / DefaultFxTableBinder |
binds table columns to row-type members |
FxTableBinding / DefaultFxTableBinding |
binds one column to a row-type member |
See also: pdo.md for the model objects most often bound, and trip.md for how those objects travel between JVMs.