Skip to content

Entity Binding

Petrus Pradella edited this page Jun 28, 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 @PostLoad

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

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

Read a subtree: getValue

getValue(path, type) is the path-scoped loadAs — it binds the subtree at a dotted path to a fresh POJO. An absent path binds the type's defaults; a root path ("" or null) binds the whole tree.

PoolConfig pool = cfg.getValue("database.pool", PoolConfig.class);   // bind just database.pool

The 2-arg form uses the codec the config was opened with (it throws IllegalStateException if the config was opened without one, e.g. new Config()); pass a codec explicitly to override:

PoolConfig pool = cfg.getValue("database.pool", PoolConfig.class, codec);

Binding needs a codec — even with no file. The codec supplies the mapper that drives the POJO ⇄ tree conversion. Config.open(...) carries one; a bare new Config() does not (and accepts only native values). For a file-less config that still wants the full typed/POJO API, use Config.inMemory() — it binds an in-memory codec, so getValue(path, type), a POJO setValue, @Key/@Section/@Comment, enums-by-name and java.time all work; only save() throws (there is no file). See The Dynamic API.

Value + issues together: BindResult

loadAs/readKeyIndexCollection discard the issues collected for the call. The *Result variants return a BindResult<T> — the value and the issue list (an unmodifiable snapshot) travel together, so a later bind can't overwrite them out from under you:

import br.com.finalcraft.everyconfig.binding.BindResult;

BindResult<DbConfig> r = cfg.loadAsResult(DbConfig.class, codec);
DbConfig db = r.value();
if (r.hasIssues()) log.warn("{} bind issue(s): {}", r.issues().size(), r.issues());

BindResult<List<Server>> servers = cfg.readKeyIndexCollectionResult("servers", Server.class, codec);

The same BindResult comes off an EntityBinder via binder.readResult(""). The old stateful getters (EntityBinder.lastLoadIssues(), Config.lastKeyIndexCollectionIssues()) still work unchanged.

Write: a POJO setValue (merge)

db.maxPool = 25;
cfg.setValue("", db);   // a POJO setValue MERGES into the tree (root here), 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.setValue("", new DbConfig());
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.everyconfig.binding.BindOptions;

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

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

A LoadIssue carries the key, the rawValue, the targetType and a message. A @PostLoad 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).
  • COMMENT_OUT — keep the key (its data survives, like PRESERVE) but stamp an authoritative deprecation block comment on it, overwriting any stale comment it carried.
cfg.bind(Cfg.class, codec, BindOptions.defaults().withObsoletePolicy(BindOptions.ObsoletePolicy.REMOVE));

COMMENT_OUT is LOSSLESS-codecs only. On a codec that can't round-trip a comment losslessly (a NONE/LOSSY codec, e.g. JSON/JSONC) it degrades to PRESERVE — the key is kept, no marker written.

Sectioned fields and @Section

@Section relocates a field under a dotted path. It applies to a field of a nested POJO too — there the path is relative to that POJO's own location, not the document root:

class Server {
    @Section("network") int port = 25565;   // under <server-location>.network.port
}

A @Section field inside a List/Map element is unsupported (a collection element has no stable path). A sectioned type now honors ObsoletePolicy.REMOVE — its obsolete keys are stripped like any other, no longer forced to PRESERVE.

Native Jackson annotations

Because the mapper is plain Jackson, native annotations work alongside EveryConfig'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 — including under ObsoletePolicy.REMOVE: the @JsonTypeInfo discriminator is treated as a declared key, not an obsolete one, so it is never stripped.

→ See also Annotations · @KeyIndex Collections

Clone this wiki locally