-
Notifications
You must be signed in to change notification settings - Fork 1
Typed References
What this page covers: the Ref<K, V> type in depth — how it serializes as just its key,
how the target type V is recovered on read, the three read styles (peek/resolve/join),
how a resolved Ref memoizes its live cache cell, and the binding rule that decides which
registry (and therefore which manager and backend) a reference resolves against. This is the
deep dive behind the Caching & References overview — read that first for the big picture.
📌 Note —
Reflives in the optionaleverydatabase-manageradd-on. ARefis the pointer; the cache and the backend live in aCachingManager(see Caching Managers). The two stay separate on purpose.
import br.com.finalcraft.everydatabase.manager.Ref;
import br.com.finalcraft.everydatabase.manager.RefRegistry;
import lombok.*;
// An entity that points at a Guild by a typed reference — stored as just the key on disk.
@Data @NoArgsConstructor @AllArgsConstructor
public class Player {
private UUID uuid;
private Ref<UUID, Guild> guild; // -> "guild":"<uuid>" in the stored JSON
}
RefRegistry refRegistry = new RefRegistry(); // a registry owns your refs (see Caching & References)
// ... refRegistry.manager(GUILDS, storage, CachePolicy.always()) registers the Guild manager ...
Player p = playerRepo.find(playerId).join().orElseThrow(); // codec = refRegistry.codec(Player.class)
Optional<Guild> hot = p.getGuild().peek(); // sync, cache-only
p.getGuild().resolve().thenAccept(opt -> render(opt)); // async, load-on-missThe Player holds a Guild reference but stores only the key. peek() never touches the
backend; resolve() loads on a miss and caches the result. Everything async is a
CompletableFuture — .join() is used here for brevity; compose with thenApply/thenCompose
and see The Async API. There are no blocking variants.
On disk a Ref<UUID, Guild> guild is byte-for-byte what storing the raw UUID would produce —
no embedded entity, no @JsonIgnore tricks:
{ "uuid": "5f1e…", "guild": "9a2c…" }The Jackson glue is two small classes (jackson.RefSerializer / jackson.RefDeserializer):
-
RefSerializerwritesref.key()through Jackson's default value serializer, so aUUIDkey becomes a string, aLongkey becomes a number, arecordkey becomes its JSON object, and so on. An empty reference writesnull. -
RefDeserializeris aContextualDeserializer: it recoversKandVfrom the parameterisedRef<K, V>type at the field, so it works for a plain field and for collection and map elements (List<Ref<UUID, Guild>>, aMapvalue). A raw/rootRefwhose type parameters can't be resolved fails fast with a clear message.
📌 Note — because the JSON is just the key, a
Ref<UUID, Guild>field and a rawUUIDfield are interchangeable on disk. You can adoptRefon an existing entity without a migration, and a plain key round-trips back into aRefwhen the field type changes.
To read entities that contain Ref fields, attach a ref-aware codec from your registry —
refRegistry.codec(Type.class) — instead of a plain JacksonJsonCodec. That codec registers the
RefModule bound to refRegistry, which is also what establishes the binding
below. See Codecs for why this seam lives in the manager module rather than in core.
The JSON carries no type tag — it's just a key. So where does Ref<UUID, Guild> get Guild?
From the field's generic declaration, read by the ContextualDeserializer at deserialization
time. This is the same trick core's @Indexed scanner uses (read the declared field), and it's why
the on-disk form can stay clean while the Java side stays fully typed.
A @RefPolicy annotation on the same field is also read here and baked into the resulting Ref
(see Cache Policies & Freshness).
| Call | Blocks? | I/O? | Returns |
|---|---|---|---|
ref.peek() |
no | never |
Optional<V> — cache-only; empty if absent, evicted, deleted, or not fresh |
ref.resolve() |
no | on miss |
CompletableFuture<Optional<V>> — a fresh hit, or load-and-cache |
ref.join() |
yes | on cold miss |
V or null (blocking convenience over resolve()) |
Optional<Guild> cached = player.getGuild().peek(); // hot loop, lock-free
player.getGuild().resolve().thenAccept(opt -> ...); // load on miss, then cache
Guild g = player.getGuild().join(); // blocks only on a cold misspeek() is the hot-loop path: it never does I/O and returns empty when the value isn't currently
cached and fresh. resolve() serves a fresh cache hit, otherwise loads from the backend and caches
the result. join() is resolve().join().orElse(null) — fast on a hit, but it can block on a cold
miss, so prefer resolve() for cold data on a latency-sensitive thread.
📌 Note — these three are all the read API a
Refexposes. The resolution itself is delegated to the type'sCachingManager(its cache, its backend, its policy) — aRefadds no I/O of its own. When you hold keys rather than refs, read straight off the manager instead; see Caching Managers.
A null reference is modelled, not absent. Ref.empty(Guild.class) means "points at nothing": its
peek() returns Optional.empty() and its resolve() completes with Optional.empty(). A JSON
null round-trips to an empty Ref, never a bare null field — so player.getGuild() is
always safe to call.
Ref<UUID, Guild> none = Ref.empty(Guild.class);
none.isPresent(); // false (its key is null)
none.peek(); // Optional.empty()After the first resolution a Ref memoizes the live cache cell (the stable CacheEntry the
manager keeps for that key). Subsequent peek()/resolve() read that cell's volatile fields
directly — lock-free, no registry lookup, no map lookup, no manager round-trip:
Ref<UUID, Guild> ref = player.getGuild();
ref.resolve().join(); // first call: registry → manager → cache/backend; memoizes the cell
ref.peek(); // afterwards: reads the memoized cell directly (lock-free)Because the manager updates that cell in place (writes/reloads swap the value inside the same
cell), a memoized Ref always observes the latest value — a saveAndCache elsewhere is visible
through this handle on its next read. When the cell is evicted, the next access transparently
re-resolves. So a long-held Ref (e.g. on an online player) behaves like a self-refreshing live
handle. The cell/stamp/tombstone machinery is detailed in Caching Managers.
💡 Tip — keep the loaded entity (and thus its
Reffields) around for as long as the relationship is hot. You pay the registry/map lookup once; every laterpeek()is a couple of volatile reads.
A Ref resolves only against the RefRegistry it is bound to. There is no global registry to
fall back on (see Caching & References for why). Three ways a Ref gets bound — and one way it
doesn't:
How you got the Ref
|
Bound to | Resolvable? |
|---|---|---|
Deserialized via refRegistry.codec(Type.class)
|
refRegistry (set by the codec) |
yes |
refRegistry.ref(key, Type.class) |
refRegistry |
yes |
Ref.of(key, Type.class, refRegistry) |
refRegistry |
yes |
Ref.of(key, Type.class, null) (no registry) |
nothing — unbound | no — fails fast |
// Bound — resolvable:
Ref<UUID, Guild> bound = refRegistry.ref(guildId, Guild.class); // or Ref.of(guildId, Guild.class, refRegistry)
bound.resolve().thenAccept(opt -> ...);
// Unbound — fine to STORE, but resolving fails fast:
Ref<UUID, Guild> bare = Ref.of(guildId, Guild.class, null); // explicit null registry = unbound
player.setGuild(bare); // OK: serializes as just the key
playerRepo.save(player).join(); // OK
bare.peek(); // throws IllegalStateException ("not bound to a RefRegistry")A bare Ref.of(key, type, null) is perfectly fine to build and store — only the key is written, so it
saves exactly like a bound ref. The catch is purely on resolution: an unbound Ref has no
registry to ask. The two failure modes are deliberately different to match The Async API:
-
peek()on an unbound ref (or one whose type has no registered manager) throwsIllegalStateExceptionsynchronously —peekis a sync call, so a wiring bug surfaces sync. -
resolve()in the same situation returns a failed future (completed exceptionally), never a synchronous throw — consistent with the async error contract everywhere else in the library.
The exception message tells you which it is: "not bound to a RefRegistry" (unbound) vs "No RefResolver registered for …" (bound, but the type's manager hasn't been created yet).
⚠️ Gotcha — aRefcan only resolve after (a) it is bound to a registry, and (b) that registry has a manager for its type. Create your managers at startup, before any code resolves a ref. A profile loaded throughrefRegistry.codec(...)binds its refs torefRegistryautomatically — but if you hand-build aRef.of(key, type, null)to store and later resolve, bind it (refRegistry.ref(...)).
A Ref's identity is its key, type, and policyOverride — these drive equals/hashCode and
are immutable. The bound registry and the memoized cell are runtime wiring, excluded from
identity and never serialized. That's why two refs to the same key compare equal regardless of which
registry each is bound to.
A RefRegistry may be created as a child of another (new RefRegistry(parent)). A Ref still
binds to a single registry, but resolution against that registry checks it locally first, then
walks up the parent chain — so a Ref bound to a child resolves private-then-shared: its own
registry wins, and types the child doesn't register fall back to the parent. Local registration is
never overridden by the parent. The chain layout and the cross-plugin "shared parent" pattern are in
One Entity, Many Databases → Parent registries.
Ref is immutable; the with* methods return copies:
Ref<UUID, Guild> a = Ref.of(guildId, Guild.class, null);
Ref<UUID, Guild> bound = a.withRegistry(refRegistry); // now resolvable against 'refRegistry'
Ref<UUID, Guild> ttld = bound.withTtl(Duration.ofMinutes(3)); // a 3-minute freshness override
Ref<UUID, Guild> bypass = bound.withPolicy(CachePolicy.noCache()); // always hit the backend-
withRegistry(registry)— rebind a copy (e.g. to make a stored bare ref resolvable). -
withPolicy(policy)— carry a per-reference freshness override (the runtime equivalent of the field annotation@RefPolicy). -
withTtl(duration)— shorthand forwithPolicy(CachePolicy.ttl(duration)).
The policy override changes only this reference's freshness verdict; the cached value stays shared
with every other reference to the same entity. Freshness, TTL, and @RefPolicy are covered in full
in Cache Policies & Freshness.
Ref.of(key, Type.class, null) // unbound; store-only (resolve fails fast)
Ref.of(key, Type.class, registry) // bound; resolvable
Ref.empty(Type.class) // unbound empty (points at nothing)
Ref.empty(Type.class, registry) // bound empty
registry.ref(key, Type.class) // bound (the idiomatic way to build a resolvable ref)
registry.ref(key, Type.class, policyOverride) // bound + per-ref freshness override- Caching & References — the overview this page deepens; start there.
-
Caching Managers — the cache/identity-map/cell model a
Refresolves through. -
Cache Policies & Freshness —
withPolicy/withTtl/@RefPolicyin full. - One Entity, Many Databases — refs fanning out across heterogeneous backends and key types.
-
Codecs — why the ref-aware codec lives in the manager module (
RefCodecs). -
The Async API — the
CompletableFuturemodel behindresolve()and the failed-future contract.
EveryDatabase · Home · made by Petrus Pradella
Getting Started
Core Concepts
Working with Data
Backends
Manager Module
- Caching & References
- Typed References (Ref)
- Caching Managers
- Cache Policies & Freshness
- Cross-Process Cache Sync
- One Entity, Many Databases
Operations
Advanced
Reference
Contributing