Skip to content

Entity Binding

Petrus Pradella edited this page Jun 27, 2026 · 8 revisions

Entity Binding

Binding is a derived view over the canonical tree. You read the tree into a POJO when you want types, and merge a POJO back into the tree when you want to write — but the tree stays the source of truth.

Read: loadAs / bind

DbConfig db = cfg.loadAs(DbConfig.class, codec);   // bind the whole tree + run @PostInject

loadAs binds the whole tree to a fresh instance and runs @PostInject. For control over options, get an EntityBinder:

EntityBinder<DbConfig> binder = cfg.bind(DbConfig.class, codec, options);
DbConfig db = binder.bind();
List<LoadIssue> issues = binder.lastLoadIssues();

Write: mergeFrom

db.maxPool = 25;
cfg.mergeFrom(db, codec);   // merge the POJO INTO the tree, then cfg.save()

A binding save merges, it never replaces:

  • The tree wins on conflict / unknown keys survive. A key a user added by hand that the POJO doesn't declare stays in the file.
  • The POJO value wins for declared keys. Your in-memory change is written.
  • Comments declared via @Comment are seeded during the merge.
cfg.setValue("legacy.extra", "kept");
cfg.mergeFrom(new DbConfig(), codec);
cfg.contains("legacy.extra");   // still true — unknown keys survive the merge

Lenient vs strict

BindOptions controls how a value that can't be coerced to its field type is handled.

import br.com.finalcraft.finalconfig.binding.BindOptions;

// LENIENT (default): a bad value is recorded as a LoadIssue and the field keeps its real default
EntityBinder<Cfg> b = cfg.bind(Cfg.class, codec);
Cfg c = b.bind();
b.lastLoadIssues();   // e.g. ['port' = 'NaN' (expected int): ...]

// STRICT: the first mismatch throws BindException
cfg.bind(Cfg.class, codec, BindOptions.defaults().withCoercion(BindOptions.Coercion.STRICT)).bind();

A LoadIssue carries the key, the rawValue, the targetType and a message. A @PostInject method may take a List<LoadIssue> to inspect them and decide whether to reject the config.

Lenient keeps the real default. The bad node is removed from a working copy of the tree and the bind is retried, so the field keeps the POJO's actual default value — not a zeroed placeholder.

Obsolete keys

BindOptions.ObsoletePolicy decides what happens to a file key the bound schema no longer declares:

  • PRESERVE (default) — keep it untouched (the tree always wins).
  • REMOVE — strip it from the tree during the merge (opt-in; destroys data the schema dropped).
cfg.bind(Cfg.class, codec, BindOptions.defaults().withObsoletePolicy(BindOptions.ObsoletePolicy.REMOVE));

Native Jackson annotations

Because the mapper is plain Jackson, native annotations work alongside FinalConfig's: @JsonProperty, @JsonIgnore, @JsonAlias, @JsonTypeInfo/@JsonSubTypes (polymorphism), etc.

class Holder {
    @JsonProperty("max-pool") int maxPool = 10;
    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
    @JsonSubTypes({@JsonSubTypes.Type(value = Circle.class, name = "circle")})
    Shape shape;
}

Polymorphic round-trips work and the type tag survives a merge.

→ See also Annotations · @Id Collections

Clone this wiki locally