Skip to content

Gotchas and Pitfalls

Petrus Pradella edited this page Jun 17, 2026 · 5 revisions

Gotchas & Pitfalls

What this page covers: the audit-surfaced traps in one place — the API shapes that compile but surprise you, and the backend behaviors that differ from what the call site implies. Each entry says what bites, why, and how to avoid it, and deep-links to the page that explains it in full.

📌 Note — none of these are bugs; they're sharp edges of an intentionally small, backend-agnostic API. Knowing them up front saves a debugging session.


1. The descriptor builder takes keyType first

// WRONG — compiles, then everything is keyed by the entity, not the UUID
EntityDescriptor.builder(Player.class, UUID.class) …

// RIGHT — keyType first, entity type second
EntityDescriptor.builder(UUID.class, Player.class) …

Both arguments are Class, so a flip is a clean compile and a runtime mess. The signature is builder(Class<K> keyType, Class<V> type). Read it as "key, then value." → Defining Entities


2. Storages.create(SqlConfig) always picks the MySQL dialect

SqlConfig is shared by MySQL/MariaDB, PostgreSQL, and H2 — the dialect comes from the Storage subclass, not the config. The generic create(...) can't disambiguate, so any SqlConfig becomes a MySQL/MariaDB SqlStorage, even with a jdbc:postgresql:// or jdbc:h2: URL.

Storages.create(new SqlConfig("jdbc:postgresql://…", …));   // ⚠️ MySQL dialect against Postgres!

Storages.createPostgreSQL(new SqlConfig("jdbc:postgresql://…", …));   // ✅ explicit dialect
Storages.createH2(new SqlConfig("jdbc:h2:file:./data", "", ""));     // ✅ explicit dialect

Use the typed builders when you need PostgreSQL or H2. → Choosing a Backend · PostgreSQL · H2


3. H2 does not enforce optimistic locking

A versioned descriptor (@OptimisticLock, .versioned(), or .version(...)) is enforced by MySQL/MariaDB, PostgreSQL, and MongoDB — but H2 deliberately opts out. On H2 a versioned descriptor silently degrades to a plain upsert: it never throws OptimisticLockException, and creating the storage never fails because of versioning. LocalFile and InMemory don't enforce it either.

⚠️ Gotcha — don't validate concurrent-write protection against H2 and assume it holds in production. Test optimistic locking against a server-grade backend.

Optimistic Locking


4. SQL / Mongo / InMemory reject a non-JSON codec

SQL, Mongo, and InMemory store the payload as native JSON (a JSON column, a BSON sub-document, a parsed in-memory tree), so they require a codec where isJsonCodec() is true. Pairing one of them with JacksonYamlCodec fails with IllegalArgumentException.

// ⚠️ rejected — YAML is not JSON
Storages.createSQL(cfg).repository(
        EntityDescriptor.builder(UUID.class, P.class).codec(new JacksonYamlCodec<>(P.class))…);

// ✅ LocalFile is the ONLY backend that accepts YAML
Storages.createLocalFile(cfg).repository(
        EntityDescriptor.builder(UUID.class, P.class).codec(new JacksonYamlCodec<>(P.class))…);

Use JacksonJsonCodec everywhere except when you specifically want human-readable .yml files on LocalFile. → Codecs


5. Keys persist by toString(), capped at 255 chars

A key is persisted by its toString() (the SQL primary key, the Mongo unique index, the LocalFile filename) and matched by equals/hashCode (InMemory and the manager cache). So a key type must have a stable, unique toString() of at most 255 characters and value-based equals/hashCode. UUID, String, Long, Integer, and records qualify; a plain object with the default identity Object.toString() does not.

An oversized key isn't silently truncated — save/saveAll reject it: the returned future completes exceptionally with IllegalArgumentException, before the key reaches storage.

Entities, Keys & Collections


6. Collection names follow one strict regex

A collection name must match ^[a-zA-Z][a-zA-Z0-9_]*$ — the safe intersection across every backend (no quoting or escaping ever needed). A name with a dash, dot, space, or leading digit is rejected at build(). → Defining Entities


7. Querying an undeclared field throws — on every backend

A field referenced by findBy(...) or query(...) must be declared as an index (@Indexed or IndexHint). An undeclared field throws IllegalArgumentException at execution time on every backend, LocalFile included — LocalFile validates the declaration before its full scan, so a query that works there keeps working when you swap in SQL or Mongo.

repo.query(Query.eq("nickname", "Al")).join();   // ⚠️ throws unless "nickname" is an index

Indexing & Queries


8. There is no native OR

Query.and(...) is the only combinator — conditions are intersected (AND). For OR, run two queries and union the results client-side. Query.range(...) bounds are inclusive, and null means an open end. → Indexing & Queries


9. MongoDB transactions require a replica set

MongoStorage implements TransactionalStorage, but inTransaction(...) only works against a MongoDB replica set (4.0+). On a standalone mongod it throws at runtime. The InMemory backend is transactional too, but with no isolation (atomic apply, no concurrent-reader guarantees). LocalFile is not transactional at all.

⚠️ Gotchainstanceof TransactionalStorage being true does not mean the deployment supports it. Mongo needs a replica set; gate accordingly.

Transactions · MongoDB


10. A bare Ref.of(key, type, null) is unbound and fails fast

In the manager module, a Ref resolves only against the RefRegistry it is bound to. The explicit way to make an unbound ref is Ref.of(key, type, null): perfectly fine to put on an entity and save (it serializes as just the key), but calling resolve() returns a failed future and peek() throws IllegalStateException.

Ref.of(id, Guild.class, null).resolve();        // ⚠️ failed future: "not bound to a RefRegistry"
refRegistry.ref(id, Guild.class).resolve();     // ✅ bound
Ref.of(id, Guild.class, refRegistry).resolve(); // ✅ bound

Refs read back through a refRegistry.codec(...) codec are bound automatically. → Caching & References


11. There is no global RefRegistry

The manager module dropped the process-wide static registry on purpose. Each context (new RefRegistry()) is its own registry, and managers self-register into the registry you pass them. This is what lets two subsystems register a manager for the same entity type on different storages without colliding — but it means you must wire your codecs and managers through your registry. Resolving a ref whose type has no manager in that registry fails fast ("No RefResolver registered").

Caching & References · Caching Managers


12. repository().save/delete leaves the cache stale

When you use a CachingManager, prefer saveAndCache(...) / deleteAndEvict(...). The plain repository().save(...) / delete(...) hit the backend but leave the cached cell behind — a memoized Ref keeps serving the old value. → Caching & References


13. TTL governs freshness, not memory

Cached entries are held by strong references. An expired-but-untouched entry under CachePolicy.ttl(...) is not GC-eligible until it's overwritten or evicted. Bound memory with CacheOptions.maxSize (LRU) and/or call purgeExpired() periodically — don't expect TTL alone to free memory. → Cache Policies & Freshness


14. The transfer future never fails for expected errors

StorageTransfer.execute() returns a future that does not complete exceptionally for expected failures (a duplicate key, a non-empty target, a per-entity codec error). Check report.success() and iterate report.errors() — a successfully-completed future does not mean a successful transfer.

Moving Data Between Backends


See also

Clone this wiki locally