Skip to content

Entities Keys and Collections

Petrus Pradella edited this page Jun 18, 2026 · 2 revisions

Entities, Keys & Collections

What this page covers: the three things a backend needs to model your data portably — a valid collection name, a well-behaved key, and a serializable entity — and the rules each must satisfy so the same code behaves identically on SQL, Mongo, files, and memory.


The 30-second version

import br.com.finalcraft.everydatabase.*;
import br.com.finalcraft.everydatabase.codec.JacksonJsonCodec;

// A plain entity: no-arg constructor + accessors so the codec can (de)serialise it.
public class PlayerData {
    private UUID uuid;
    private String name;
    private int score;
    public PlayerData() {}                          // Jackson needs this
    public PlayerData(UUID uuid, String name, int score) { /* ... */ }
    public UUID getUuid() { return uuid; }          // the key
    // getters/setters omitted
}

EntityDescriptor<UUID, PlayerData> PLAYERS = EntityDescriptor.builder(UUID.class, PlayerData.class)
        .collection("players")                       // ^[a-zA-Z][a-zA-Z0-9_]*$
        .keyExtractor(PlayerData::getUuid)           // UUID: a great key
        .codec(new JacksonJsonCodec<>(PlayerData.class))
        .build();

The descriptor binds all three together. UUID keys, a players collection, and a Jackson- serializable POJO are the happy path — the rest of this page is the why and the edges.

📌 NoteEntityDescriptor.builder(...) takes the key type first, entity type second. See Defining Entities for the full builder and Architecture Overview for where the descriptor sits.


Collections: the name rule

A collection is one logical bucket — a SQL table, a Mongo collection, a LocalFile directory. Its name is validated at build() time against:

^[a-zA-Z][a-zA-Z0-9_]*$
  • must start with a letter (a–z / A–Z);
  • the rest may be letters, digits, or underscores (_);
  • no spaces, hyphens, dots, backticks, quotes, or any other special character.

This is the intersection of safe identifiers across every backend — it needs no quoting or escaping in SQL (any dialect), is a legal Mongo collection name, and is a legal directory name. A name that breaks the rule throws IllegalStateException from build():

.collection("player-data")   // ✗ IllegalStateException: hyphen
.collection("2players")      // ✗ IllegalStateException: starts with a digit
.collection("player_data")   // ✓

⚠️ Gotcha — the validation fires at build(), not at repository(...). A bad collection name fails fast when you construct the descriptor, which is usually at class-load time for a static final constant — so you find out immediately, not on first I/O.


Keys: the cross-backend contract

A key is persisted by its toString() — the SQL primary key (storage_key VARCHAR(255)), the Mongo unique index, the LocalFile filename — and matched by equals/hashCode (the in-memory backend and the manager cache look keys up by value). For a key to behave identically everywhere, its type must have:

  1. a stable, unique toString() of at most 255 characters, and
  2. value-based equals/hashCode.

These types qualify out of the box:

Key type Why it works
UUID canonical 36-char toString(), value equality — the idiomatic player key
String is its own toString(), value equality
Long / Integer numeric toString(), value equality
a record compiler-generated value toString()/equals/hashCode — great for composite keys
// A composite key as a record: stable toString(), value equality — qualifies.
public record GuildMember(UUID guildId, UUID playerId) {}
EntityDescriptor.builder(GuildMember.class, Membership.class) /* ... */;

⚠️ Gotcha — a key type with the default Object.toString() (the identity hash like Foo@1a2b3c) is poison: it's neither stable across runs nor meaningful, so it silently corrupts persistence. If you key by a custom class, give it a real toString() and value equals/hashCode. A record gives you all three for free — prefer it.

Oversized keys are rejected up front

A key whose toString() exceeds 255 characters (StorageKeys.MAX_KEY_LENGTH) is refused before it reaches storage: save/saveAll return a future that completes exceptionally with IllegalArgumentException.

String huge = "x".repeat(300);
repo.save(new Thing(huge, ...))
    .exceptionally(ex -> {
        // ex.getCause() instanceof IllegalArgumentException — "Storage key ... is too long: 300 ..."
        return null;
    });

This is deliberate. 255 is the safe intersection across backends (the SQL column width, within Mongo's index-key limit and the filesystem filename limit). Failing fast beats the alternatives: an opaque "Data too long" / "File name too long" later, or — worst, on SQL — a silent truncation that collides with a different key. Reads aren't validated: an oversized key simply matches nothing.

📌 Note — the rejection is an exceptional future, not a synchronous throw, matching the library's error-propagation contract. See The Async API.

💡 Tip — when a key is used inside a Ref (the Caching & References) it additionally has to be JSON-(de)serializable, since the Ref persists as just that key. UUID, String, Long, Integer, and records all satisfy that too.


Entities: what serializes, and how

The entity is whatever your Codec can round-trip. With the default JacksonJsonCodec, that means a standard Jackson-serializable POJO:

  • a no-arg constructor (or a Jackson-recognised creator) so the codec can rebuild instances on read;
  • accessible fields — getters/setters, or Lombok @Data, or public fields.
// Lombok keeps it terse and is used throughout the tests.
@lombok.Data @lombok.NoArgsConstructor @lombok.AllArgsConstructor
public class PlayerData {
    private UUID uuid;
    private String name;
    private int score;
}

The key field is part of the entitykeyExtractor just reads it (PlayerData::getUuid). You don't store the key separately; the backend derives it from the entity at save time.

How the serialized payload lands at rest depends on the backend:

Backend Entity stored as
MySQL / MariaDB native JSON column
PostgreSQL native JSON column
MongoDB native BSON sub-document
H2 TEXT column (JSON text)
LocalFile one file per entity (JSON or YAML bytes)
InMemory parsed JSON, in a map

The codec choice and a backend's storage format are linked: most backends require a JSON codec; only LocalFile accepts YAML. That seam is its own page — see Codecs.

📌 Note — the entity's indexed fields are a separate concern: declare them with @Indexed or IndexHint on the descriptor and the backend materialises queryable index columns/fields alongside the payload. See Indexing & Queries.


Putting it together

// Collection name, key contract, and a JSON-serializable entity — all validated at build().
EntityDescriptor<UUID, PlayerData> PLAYERS = EntityDescriptor.builder(UUID.class, PlayerData.class)
        .collection("players")                          // valid identifier
        .keyExtractor(PlayerData::getUuid)              // UUID key: stable, unique, value-equal
        .codec(new JacksonJsonCodec<>(PlayerData.class))// JSON: works on every backend
        .build();

Repository<UUID, PlayerData> repo = storage.repository(PLAYERS);
repo.save(new PlayerData(UUID.randomUUID(), "Alice", 100)).join();

Mirror real tested entity shapes when in doubt — TestPlayer in the core test sources is the canonical reference for a keyed, indexed entity.


See also

Clone this wiki locally