Skip to content

Architecture Overview

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

Architecture Overview

What this page covers: the five types you actually touch — EntityDescriptor, Codec, Storages, Storage, Repository — how they fit together, and the one idiom that runs through the whole library: capabilities are interfaces you instanceof-check, not flags you read.


The 30-second version

import br.com.finalcraft.everydatabase.*;
import br.com.finalcraft.everydatabase.codec.JacksonJsonCodec;
import br.com.finalcraft.everydatabase.modules.sql.SqlConfig;

// 1. Describe the entity once: key type FIRST, entity type second.
EntityDescriptor<UUID, PlayerData> PLAYERS = EntityDescriptor.builder(UUID.class, PlayerData.class)
        .collection("players")
        .keyExtractor(PlayerData::getUuid)
        .codec(new JacksonJsonCodec<>(PlayerData.class))
        .build();

// 2. Pick a backend (a Storage).
Storage storage = Storages.createSQL(new SqlConfig("jdbc:mariadb://localhost:3306/mydb", "root", "root"));
storage.init().join();

// 3. Get a typed Repository and do CRUD. Everything is async.
Repository<UUID, PlayerData> repo = storage.repository(PLAYERS);
repo.save(new PlayerData(aliceId, "Alice", 100)).join();
Optional<PlayerData> alice = repo.find(aliceId).join();

storage.close().join();

That is the entire surface for plain persistence. Swapping createSQL(...) for createMongo(...) or createH2(...) changes nothing below storage.repository(...). See Quick Start for the full runnable version and Choosing a Backend for picking one.

📌 Note — every I/O call returns a CompletableFuture. These examples use .join() for brevity, but you normally compose with thenApply/thenCompose. There are no blocking variants. See The Async API.


The five types

Type Role You hold it…
EntityDescriptor<K, V> Immutable metadata: collection name, key/entity types, keyExtractor, codec, index hints, optional version accessors. once per entity type, as a static final constant
Codec<V> The serialization strategy (entity ↔ bytes + a content type). Attached to the descriptor. inside the descriptor
Storages The static factory. Typed builders (createSQL, createMongo, …) return concrete types; the generic create(StorageConfig) dispatches by config type. call once at startup
Storage A live backend: lifecycle (init/close/health) and a factory for repositories. one per backend, app-lifetime
Repository<K, V> Typed CRUD for one collection: find/findMany/save/saveAll/delete/exists/count/all, plus index reads findBy/query. cached per descriptor by the storage

The data flows in exactly that order: describe → build a storage → ask it for a repository → operate. The descriptor is the contract; the storage is the connection; the repository is the typed door into one collection.

flowchart LR
    ED["EntityDescriptor&lt;K,V&gt;<br/>collection · keyExtractor · codec · indexes"]
    C["Codec&lt;V&gt;<br/>encode / decode"]
    F["Storages<br/>(factory)"]
    S["Storage<br/>init / close / health"]
    R["Repository&lt;K,V&gt;<br/>find / save / query …"]
    DB[("backend<br/>SQL · Mongo · File · …")]

    C -->|attached to| ED
    F -->|create*| S
    ED -->|repository(descriptor)| S
    S -->|repository(d)| R
    R <-->|async I/O| DB
Loading

Storage — lifecycle + repository factory

public interface Storage {
    CompletableFuture<Void> init();                 // idempotent
    CompletableFuture<Void> close();                // idempotent
    CompletableFuture<HealthStatus> health();       // fast ping
    <K, V> Repository<K, V> repository(EntityDescriptor<K, V> descriptor);
    StorageLogConfig getStorageLogConfig();
    Storage setStorageLogConfig(StorageLogConfig config);
}

repository(...) is the only non-async method here — it returns the typed door synchronously (it opens no connection by itself). A storage caches repositories per descriptor: calling storage.repository(PLAYERS) twice with the same descriptor returns the same object.

📌 Noteinit, close, and health are all idempotent. Calling init() twice is safe; close() on an already-closed storage is a no-op.

Repository<K, V> — typed CRUD for one collection

CompletableFuture<Optional<V>> find(K key);                  // empty if absent
CompletableFuture<List<V>>     findMany(Collection<K> keys); // missing keys omitted
CompletableFuture<Void>        save(V entity);               // upsert
CompletableFuture<Void>        saveAll(Collection<V> entities);
CompletableFuture<Boolean>     delete(K key);                // true if it existed
CompletableFuture<Boolean>     exists(K key);
CompletableFuture<Long>        count();
CompletableFuture<Stream<V>>   all();
CompletableFuture<List<V>>     findBy(String fieldPath, Object value); // indexed
CompletableFuture<List<V>>     query(Query query);                     // indexed, composite

save/saveAll are upserts (insert or replace). findMany silently omits keys that aren't present — the result list is never padded with nulls. The findBy/query pair only works on fields you declared as index hints; see Indexing & Queries.

EntityDescriptor, Codec, Storages

These three are covered in depth on their own pages — they're listed here so you can see where they sit in the flow:

  • EntityDescriptor is built with EntityDescriptor.builder(KeyType.class, EntityType.class). See Entities, Keys & Collections and Defining Entities.
  • Codec is the serialization seam; JacksonJsonCodec is the default. See Codecs.
  • Storages is the factory; prefer the typed builders. See the gotcha below.

⚠️ GotchaEntityDescriptor.builder(...) takes the key type first, entity type second. Both are Class objects, so flipping them compiles but produces a descriptor with <K, V> reversed — caught only when the generics no longer line up at the repository(...) call. Read it as "a descriptor from UUID to PlayerData".


Capabilities are interfaces, not flags

This is the central design idiom. Optional features are extra interfaces a Storage may implement. You discover them with instanceof, not by reading a boolean — so the type system tells you, per backend, what's available, and a missing capability is a compile-time absence rather than a runtime exception.

Storage storage = Storages.createSQL(sqlConfig);

// Transactions? Only if this backend implements TransactionalStorage.
if (storage instanceof TransactionalStorage tx) {
    tx.inTransaction(scope -> {
        Repository<UUID, Account> accounts = scope.repository(ACCOUNTS);
        return accounts.save(debited).thenCompose(v -> accounts.save(credited));
    }).join();
}

// Migrations? Only if this backend implements SchemaAwareStorage.
if (storage instanceof SchemaAwareStorage schema) {
    schema.register(new AddEmailColumn()).migrate().join();
}

The two core capabilities:

Capability interface Adds Supported by Not by
tx.TransactionalStorage inTransaction(scope -> future); repositories from the TransactionScope share one connection, commit on success, roll back on exception or scope.rollback() SQL (incl. H2), Mongo (replica set required), InMemory (no isolation) LocalFile
schema.SchemaAwareStorage register(migrations…).migrate(); forward-only, applied versions tracked in a reserved _schema_migrations table/collection/file SQL, Mongo, LocalFile InMemory

The payoff of typed builders: Storages.createSQL(...) returns SqlStorage (which is a TransactionalStorage and a SchemaAwareStorage), so when you know the backend at compile time you can skip the instanceof entirely and call inTransaction directly. Reach for instanceof when the backend is chosen at runtime from config.

🧭 Decision — use the typed builder (createSQL/createPostgreSQL/createH2/ createMongo/createLocalFile/createInMemory) when the backend is known at compile time: you get the concrete type and its capabilities without a cast. Use the generic Storages.create(StorageConfig) when the backend is selected at runtime — then instanceof-check for the capabilities you need.

⚠️ Gotcha — the generic Storages.create(StorageConfig) always picks the MySQL/MariaDB dialect for any SqlConfig — it can't tell PostgreSQL/H2 apart from the config alone (the dialect lives in the Storage subclass, not the config). Use createPostgreSQL / createH2 when you need those dialects.

Deeper dives on each capability: Transactions and Schema Migrations. Other features follow the same pattern — optimistic locking is opted into per descriptor (Optimistic Locking), and cross-backend copying is a standalone tool (Moving Data Between Backends).


What lives where

  • Core (everydatabase-core) is everything above: the contract types, the six backends, codecs, indexing, transactions, migrations, transfer, and logging. It's the recommended flavor.
  • The manager add-on (everydatabase-manager) is an optional feature layer that sits in front of :core: typed references (Ref) and per-type caching managers. It's not a backend and not a capability interface — it's a separate artifact you opt into. See Caching & References.
  • Distribution flavors (core / standalone / libby) differ only in how the dependencies are packaged, not in the API. See Distribution Flavors.

See also

Clone this wiki locally