-
Notifications
You must be signed in to change notification settings - Fork 1
One Entity Many Databases
What this page covers: the flagship payoff of the manager module — a single root entity whose
references fan out across heterogeneous databases and heterogeneous key types, each resolved
through its own manager, with the root neither knowing nor caring where each piece lives. Then the
mirror image: two independent RefRegistry registries that register managers for the same types on
different backends and never collide. Both are runnable end-to-end in
MultiBackendRefExampleTest. This expands the ⭐ section of Caching & References.
📌 Note — requires the optional
everydatabase-manageradd-on. The mechanics —Ref,CachingManager,RefRegistry— are covered in Typed References (Ref), Caching Managers, and Caching & References. This page is the worked example.
A Ref resolves through its entity type's manager — and a manager can be backed by any
Storage. So nothing ties a root entity's references to the root's own backend: each reference's
target is wherever that type's manager points. Put the Clan manager on PostgreSQL and the
Wallet manager on MongoDB, and profile.getClan() hits Postgres while profile.getWallet() hits
Mongo — from the same root, transparently.
And because a Ref serializes as just its key, the key type is free: UUID, String, Long,
Integer, even composite or wrapper record keys all serialize inside the root's JSON and are
recovered on read. (Keys persist by toString(), ≤ 255 chars — see Entities, Keys & Collections.)
The test's PlayerProfile is keyed by UUID and holds five references, each a different key
type bound for a different backend (mirroring PlayerProfile in the test sources):
import br.com.finalcraft.everydatabase.manager.Ref;
import lombok.*;
@Data @NoArgsConstructor @AllArgsConstructor
public class PlayerProfile {
private UUID uuid; // root key: UUID -> MariaDB
private Ref<String, Clan> clan; // String key -> PostgreSQL
private Ref<Long, Wallet> wallet; // Long key -> MongoDB
private Ref<Integer, Stats> stats; // Integer key -> H2
private Ref<Settings.Key, Settings> settings; // record key -> LocalFile
private Ref<Session.Id, Session> session; // record key -> InMemory
}The two record keys are real composite/wrapper keys from the test data:
public record Key(UUID owner, String section) {} // Settings.Key — composite
public record Id(String token) {} // Session.Id — wrapperMake one RefRegistry for this context and create one manager per entity type — each pointed at
its own Storage, all registered in the same registry. Every descriptor uses
refRegistry.codec(Type.class) so the root's refs bind to refRegistry when it's read back:
RefRegistry refRegistry = new RefRegistry();
// each descriptor's codec is refRegistry.codec(...), so refs read back bind to 'refRegistry'
CachingManager<UUID, PlayerProfile> profiles =
refRegistry.manager(profileDesc, mariadb, CachePolicy.always());
CachingManager<String, Clan> clans =
refRegistry.manager(clanDesc, postgres, CachePolicy.always());
CachingManager<Long, Wallet> wallets =
refRegistry.manager(walletDesc, mongo, CachePolicy.always());
CachingManager<Integer, Stats> stats =
refRegistry.manager(statsDesc, h2, CachePolicy.always());
CachingManager<Settings.Key, Settings> settings =
refRegistry.manager(settingsDesc, localFile, CachePolicy.always());
CachingManager<Session.Id, Session> sessions =
refRegistry.manager(sessionDesc, inMemory, CachePolicy.always());A descriptor is the usual core builder, with the ref-aware codec bound to the registry (note the
key type comes first — easy to flip, both are Class; see Defining Entities):
EntityDescriptor<String, Clan> clanDesc = EntityDescriptor.builder(String.class, Clan.class)
.collection("clans")
.keyExtractor(Clan::getTag)
.codec(refRegistry.codec(Clan.class)) // ref-aware codec bound to 'refRegistry'
.build();Build the root with bound refs (refRegistry.ref(key, Type.class)), save it, then resolve each
reference — every one hits a different database:
profiles.saveAndCache(new PlayerProfile(
profileId,
refRegistry.ref("KNIGHTS", Clan.class), // String -> PostgreSQL
refRegistry.ref(100_000_001L, Wallet.class), // Long -> MongoDB
refRegistry.ref(7, Stats.class), // Integer -> H2
refRegistry.ref(settingsKey, Settings.class), // record -> LocalFile
refRegistry.ref(sessionId, Session.class) // record -> InMemory
)).join();
// reload the root (force a decode so its refs are recovered + bound), then fan out:
profiles.evict(profileId);
PlayerProfile loaded = profiles.resolve(profileId).join().orElseThrow();
String clanName = loaded.getClan().resolve().join().orElseThrow().getName(); // PostgreSQL
long balance = loaded.getWallet().resolve().join().orElseThrow().getBalance(); // MongoDB
int kills = loaded.getStats().resolve().join().orElseThrow().getKills(); // H2
String lang = loaded.getSettings().resolve().join().orElseThrow().getLanguage(); // LocalFile
String server = loaded.getSession().resolve().join().orElseThrow().getServer(); // InMemory.join() is shown for brevity; in real code compose with thenApply/thenCompose and resolve the
independent references in parallel — see The Async API.
💡 Tip — where each piece is stored is now a deployment decision, not a code decision. Move wallets from Mongo to Postgres by pointing the
Walletmanager at a differentStorage; thePlayerProfileentity and everyprofile.getWallet()call site are untouched.
flowchart TB
P["PlayerProfile<br/>(MariaDB)"] --> Rg[refRegistry: RefRegistry]
Rg -->|Clan → ClanManager| PG[(PostgreSQL)]
Rg -->|Wallet → WalletManager| MG[(MongoDB)]
Rg -->|Stats → StatsManager| H2[(H2)]
Rg -->|Settings → SettingsManager| LF[(LocalFile)]
Rg -->|Session → SessionManager| IM[(InMemory)]
📌 Note — in the test this runs against all six real backends when Docker is up (
a_root_entity_fans_out_across_six_different_databases) and against an embedded-only arrangement that always runs — H2, LocalFile, InMemory — inthe_same_fan_out_resolves_across_embedded_backends. The fan-out code is identical; only the backends differ. See Choosing a Backend.
Now the mirror image. Because there is no global registry, two independent contexts (two plugins,
two authors) can each register a manager for the same entity type, backed by different
storages, and never interfere. A Ref resolves only against the registry it was bound to — so the
same key resolves to different data in different registries.
This is straight from two_subsystems_resolve_the_same_types_through_their_own_registries. Two roots
(SurvivalProfile / LobbyProfile) share the Hero and Wallet types:
@Data @NoArgsConstructor @AllArgsConstructor
public class SurvivalProfile {
private UUID uuid;
private Ref<UUID, Hero> hero; // shared type -> Survival's hero store
private Ref<String, Clan> clan; // Survival-only
private Ref<Long, Wallet> wallet; // shared type -> Survival's wallet store
}Each registry owns its own stores; both register a Hero manager and a Wallet
manager — the same types:
// --- Survival: hero + clan in H2, wallet on disk ---
RefRegistry survival = new RefRegistry();
survival.manager(heroDesc(survival), survivalDb, CachePolicy.always()); // Hero -> H2
survival.manager(clanDesc(survival), survivalDb, CachePolicy.always());
survival.manager(walletDesc(survival), survivalWallets, CachePolicy.always()); // Wallet -> LocalFile
survival.manager(profDesc(survival), survivalDb, CachePolicy.always());
// --- Lobby: hero + cosmetics in memory, wallet in a DIFFERENT H2 ---
RefRegistry lobby = new RefRegistry();
lobby.manager(heroDesc(lobby), lobbyMem, CachePolicy.always()); // Hero -> InMemory
lobby.manager(cosmeticsDesc(lobby), lobbyMem, CachePolicy.always());
lobby.manager(walletDesc(lobby), lobbyWalletDb, CachePolicy.always()); // Wallet -> H2
lobby.manager(profDesc(lobby), lobbyWalletDb, CachePolicy.always());Use the same ids in both registries, deliberately, to prove they don't collide. A SurvivalProfile
read through survival.codec(...) binds its refs to survival; a LobbyProfile read through
lobby.codec(...) binds to lobby:
UUID heroId = UUID.randomUUID(); // the SAME id in both registries
survival.manager(...); // ... saves a Hero(heroId, "Aragorn the Survivor", level 80)
lobby.manager(...); // ... saves a Hero(heroId, "Aragorn in the Lobby", level 1)
Hero survivalHero = survivalProfile.getHero().resolve().join().orElseThrow();
Hero lobbyHero = lobbyProfile.getHero().resolve().join().orElseThrow();
survivalHero.getName(); // "Aragorn the Survivor" (level 80) — from Survival's H2
lobbyHero.getName(); // "Aragorn in the Lobby" (level 1) — from Lobby's InMemory
// assertNotSame(survivalHero, lobbyHero) — two registries, independent instancesThe same Wallet account number likewise resolves to different balances across different backends
(Survival's wallet on LocalFile, Lobby's on H2). And the registries are genuinely isolated — neither
knows the other's root type:
survival.isRegistered(SurvivalProfile.class); // true
survival.isRegistered(LobbyProfile.class); // false
lobby.isRegistered(SurvivalProfile.class); // false🧭 Decision — one registry for a single-plugin app: make it at startup, pass it to your codecs and managers, done. One registry per subsystem when independent modules each own their refs and you want them isolated. Never share a registry across modules that don't trust each other's type usage.
This is impossible with a single global Class → resolver map: the second Hero manager would
overwrite the first, and you'd discover the collision only at runtime, in production. Dropping the
global default turns a silent last-writer-wins bug into a thing that can't happen.
⚠️ Gotcha — a ref is only as good as its binding. APlayerProfile(orSurvivalProfile) must be read through the right registry's codec for its refs to resolve in the right registry. If you hand-build a root to store and later resolve, build its refs withthatRegistry.ref(...), not a bareRef.of(key, Type.class, null)— see Typed References (Ref) → Binding.
Full isolation is the right default, but independent plugins often want to share a few entities and
keep the rest private. A RefRegistry can therefore be created as a child of another, with
new RefRegistry(parent). Resolution against a child checks it locally first, then falls back up
the parent chain — so a plugin keeps its own private registry but can still reach a Player (or
any other entity) published in a shared parent:
RefRegistry global = new RefRegistry(); // shared, framework-owned
RefRegistry plugin = new RefRegistry(global); // a plugin's own registry, child of global
global.manager(PLAYERS, storage, CachePolicy.always()); // shared (published in the parent)
plugin.manager(JOBS, storage, CachePolicy.always()); // private (local to the plugin)
plugin.resolver(Jobs.class); // found locally
plugin.resolver(Player.class); // not local -> resolved via the parent (fallback)register(...) / manager(...) are always local — registering in a child never touches the
parent — so the parent is the only place a shared type lives, and child types stay private. The
fallback is read-only: a child type with the same id as a parent type wins in that child, exactly as
two unrelated registries don't collide above. To check membership, isRegistered(type) is local
only; isRegisteredInChain(type) walks the chain.
This is the cross-plugin "bus": to let other plugins reference your entity, register it in the
shared parent; keep everything else in your own registry. A child Ref bound through the
plugin's codec still resolves private-then-shared — local managers win, shared ones fill the gaps.
flowchart TB
PR["plugin: RefRegistry<br/>(JOBS — private)"] -->|resolver miss falls back| GR["global: RefRegistry<br/>(PLAYERS — shared)"]
🧭 Decision — keep the chain shallow: one parent is plenty (yours → shared), and a single shared parent is the cross-plugin bus every plugin chains to. Publish only the entities other plugins genuinely need in that parent; leave the rest private. Reach directly into another plugin's registry only for the rare case it didn't publish what you need.
Everything from the rest of the manager docs holds per type, independently:
-
Caching & freshness are per manager — give the
SessionmanagernoCache(), theWalletmanager a TTL, theClanmanageralways(). See Cache Policies & Freshness. -
Batching the N+1 antidote is per type —
wallets.getAll(accountNumbers)for a screenful of profiles. See Caching Managers. -
Optimistic locking is enforced per backend — versioned
Walletwrites are protected on Mongo but degrade to last-write-wins on H2/InMemory/LocalFile. See Optimistic Locking. -
Nesting falls out for free — a resolved
Clanmay itself hold refs into yet another backend; resolution stays lazy, so cycles are safe.
- Caching & References — the overview; the ⭐ section this page expands.
- Typed References (Ref) — serialization-as-key, heterogeneous key types, and binding.
-
Caching Managers — one manager per type;
getAllbatching across stores. - Cache Policies & Freshness — per-type/per-reference freshness in a multi-backend setup.
- Choosing a Backend — pick the right store per reference; data-at-rest formats.
- Optimistic Locking — which referenced backends enforce versioning.
- The runnable end-to-end example:
MultiBackendRefExampleTest.
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