Skip to content

Entity Binding

Petrus Pradella edited this page Jul 1, 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 write a POJO back into the tree when you want to persist — but the tree stays the source of truth. A write can either replace the target subtree (setValue) or merge into it (mergeValue); see the write section.

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/mergeValue, @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/getList 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.getListResult("servers", Server.class);

The same BindResult comes off an EntityBinder via binder.readResult(""), and the stateful EntityBinder.lastLoadIssues() still works unchanged.

Write a POJO: setValue (override) vs mergeValue (merge)

Both project the POJO annotation-aware — @Key/@Section/@Comment honored, enum-by-name, java.time, Optional, and @Comment seeded — and both leave sibling keys outside the target path untouched. They differ in what happens to the subtree at the path:

db.maxPool = 25;
cfg.setValue("", db);     // OVERRIDE: the subtree at the path becomes exactly what the POJO declares
cfg.mergeValue("", db);   // MERGE: the POJO wins for its keys; unknown keys under the path survive
  • setValue(path, pojo) REPLACES the subtree at path. Only the keys the POJO declares remain; a key (and its comment) that the POJO does not declare does not survive. Sibling keys outside path are untouched.
  • mergeValue(path, pojo) MERGES into the subtree at path. The POJO is the source of truth only for the keys it declares; a key a user added by hand that the POJO doesn't declare stays (the tree wins). The POJO value wins for declared keys, and its @Comments are seeded (never over a user-edited comment). A mergeValue(path, pojo, comment) overload seeds the path's own comment when absent.
cfg.setValue("legacy.extra", "kept");
cfg.mergeValue("", new DbConfig());
cfg.contains("legacy.extra");   // still true — mergeValue keeps unknown keys (setValue would drop them)

The typed binder still merges. mergeValue is the façade over the binder's merge; the underlying EntityBinder.write has always merged and still does (see the next sections). setValue is the override façade — it projects the POJO the same way, then replaces the node at the path.

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