Skip to content

Gotchas and Pitfalls

Petrus Pradella edited this page Jun 23, 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


15. A write-back dirty flag that gets persisted comes back wrongly "dirty"

A dirty-trackable entity's dirty flag (whether backing IDirtyable or annotated with @DirtyFlag) is in-memory bookkeeping, not state. It must be transient / @JsonIgnore so it never reaches the backend; if it is serialized, a decoded entity can come back already marked dirty and trigger a spurious re-save (or pin a fresh cell against reloads). A plain entity is never affected — but the moment you opt in, audit that the flag is excluded from the codec.

Cache Policies & Freshness


16. Decoding a write-back entity must not mark it dirty

Your domain mutator should flip the flag — markDirty() from inside the setter is the idiom — but Jackson must not trip that on decode, or every value you read back starts life dirty. Use field access (@JsonAutoDetect field visibility, no public setters) so deserialization writes the field directly without running your dirtying setter.

// ⚠️ a public setBalance(...) that calls markDirty() also fires during Jackson decode → loaded dirty
// ✅ field-access codec writes `balance` directly; only your own setter marks it dirty

Cache Policies & Freshness · Codecs


17. "Dirty wins" means a dirty cell ignores TTL, invalidate, and noCache

A cell that is dirty (isDirty() true) is always served and never reloaded: an elapsed TTL won't refetch it, invalidate(key) won't drop it, and even a noCache per-ref override won't bypass it. This is deliberate — local changes win over the freshness policy, so a reload can't lose an unflushed edit. The flip side: if you genuinely need the backend's copy of a dirty entity, you must flush or evict it first, knowingly discarding the local change.

Cache Policies & Freshness


18. flushDirty() is at-least-once, not exactly-once

A flush clears each cell's dirty flag before persisting. If another thread re-dirties a value while the batch is in flight, it simply gets picked up by the next flush — so a save can happen twice (a redundant, identical re-save), but a change is never lost. Don't build on "this was saved exactly once"; build on "an unflushed change is eventually saved."

Concurrency & Threading · Caching Managers


19. An optimistic-lock conflict during a flush discards the local change

When flushDirty() hits an OptimisticLockException for a cell, the default reaction is to evict it: the local unsaved value is dropped and the next read reloads the backend's winning copy. A non-conflict error is treated as transient — the cell is kept and re-marked dirty for the next flush to retry. So a write-back entity in a versioned collection can silently lose its in-memory edit on a concurrent-write conflict; if that matters, reconcile explicitly rather than relying on the flush.

Optimistic Locking · Cache Policies & Freshness


20. Cache-sync polling catches only deletes without versioning — and Postgres NOTIFY is lossy

Cross-Process Cache Sync has two sharp edges. On a poll backend (MySQL/MariaDB, H2, LocalFile) PollingCacheSync detects an in-place update only when the descriptor is versioned (@OptimisticLock); a non-versioned descriptor — and H2, which doesn't enforce lock_version — reports version 0 for every key, so polling there catches deletes only. And the PostgreSQL push transport (LISTEN/NOTIFY) is fire-and-forget: an instance disconnected during a NOTIFY misses that event.

⚠️ Gotcha — always keep a CachePolicy.ttl(...) as the safety net under cache sync, and use a versioned descriptor if you need updates (not just deletes) to propagate on a poll backend.

Cross-Process Cache Sync · Optimistic Locking


See also

Clone this wiki locally