Skip to content

Typed References

Petrus Pradella edited this page Jun 20, 2026 · 4 revisions

Typed References (Ref)

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.

📌 NoteRef lives in the optional everydatabase-manager add-on. A Ref is the pointer; the cache and the backend live in a CachingManager (see Caching Managers). The two stay separate on purpose.


The smallest example

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-miss

The 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.


Serialization: a Ref is its key

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):

  • RefSerializer writes ref.key() through Jackson's default value serializer, so a UUID key becomes a string, a Long key becomes a number, a record key becomes its JSON object, and so on. An empty reference writes null.
  • RefDeserializer is a ContextualDeserializer: it recovers K and V from the parameterised Ref<K, V> type at the field, so it works for a plain field and for collection and map elements (List<Ref<UUID, Guild>>, a Map value). A raw/root Ref whose 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 raw UUID field are interchangeable on disk. You can adopt Ref on an existing entity without a migration, and a plain key round-trips back into a Ref when 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.


Type recovery: V comes from the field, not the JSON

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).


Reading a reference: peek / resolve / join

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 miss

peek() 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 Ref exposes. The resolution itself is delegated to the type's CachingManager (its cache, its backend, its policy) — a Ref adds no I/O of its own. When you hold keys rather than refs, read straight off the manager instead; see Caching Managers.

Empty references never NPE

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()

Memoization: a resolved Ref becomes a live handle

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 Ref fields) around for as long as the relationship is hot. You pay the registry/map lookup once; every later peek() is a couple of volatile reads.


Binding: which registry a Ref resolves against

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) throws IllegalStateException synchronouslypeek is 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 — a Ref can 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 through refRegistry.codec(...) binds its refs to refRegistry automatically — but if you hand-build a Ref.of(key, type, null) to store and later resolve, bind it (refRegistry.ref(...)).

Binding is runtime wiring, not identity

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.

Resolution can fall back to a parent registry

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.


Deriving refs: withRegistry / withPolicy / withTtl

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 for withPolicy(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.


Factory cheat sheet

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

See also

Clone this wiki locally