Skip to content

Extending the Library

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

Extending the Library

What this page covers: the extension points that are actually meant to be extended — writing a custom Codec<V> (and the one method you must override when your content-type lacks "json"), reusing the manager seam (RefRegistry.codec) instead of re-wiring Jackson by hand, and an honest map of where the library stops being pluggable. Everything here is a real, supported seam; this page deliberately does not overpromise a backend-plugin SPI that doesn't exist.

📌 Note — most "extension" you'll ever need is composition, not subclassing: a custom ObjectMapper passed into JacksonJsonCodec, a domain-named CachingManager subclass, your own StorageLogSink. Reach for a brand-new Codec only when JSON/YAML genuinely don't fit.


The smallest custom codec

A Codec<V> converts an entity to/from bytes and names the format. The full SPI is four methods, two of them defaulted:

package br.com.finalcraft.everydatabase.codec;

public interface Codec<V> {
    byte[] encode(V value) throws CodecException;     // entity -> bytes
    V      decode(byte[] data) throws CodecException; // bytes  -> entity
    String contentType();                             // e.g. "application/json"

    default boolean isJsonCodec() { return contentType().contains("json"); }
    default String  fileExtension() { /* derives from contentType() */ }
}

Here's a complete codec that stores entities as compact JSON via a custom-configured mapper — the common real-world case (custom date format, a registered module, a naming strategy):

import br.com.finalcraft.everydatabase.codec.Codec;
import br.com.finalcraft.everydatabase.codec.CodecException;
import com.fasterxml.jackson.databind.ObjectMapper;

public final class MyJsonCodec<V> implements Codec<V> {
    private final ObjectMapper mapper;   // your fully-configured mapper
    private final Class<V> type;

    public MyJsonCodec(Class<V> type, ObjectMapper mapper) {
        this.type   = type;
        this.mapper = mapper;
    }

    @Override public byte[] encode(V value) throws CodecException {
        try { return mapper.writeValueAsBytes(value); }
        catch (Exception e) { throw new CodecException("encode " + type.getSimpleName(), e); }
    }

    @Override public V decode(byte[] data) throws CodecException {
        try { return mapper.readValue(data, type); }
        catch (Exception e) { throw new CodecException("decode " + type.getSimpleName(), e); }
    }

    @Override public String contentType() { return "application/json"; }
    // isJsonCodec()/fileExtension() defaults are correct here — "application/json" contains "json".
}

Attach it like any built-in codec:

EntityDescriptor<UUID, PlayerData> PLAYERS = EntityDescriptor.builder(UUID.class, PlayerData.class)
        .collection("players")
        .keyExtractor(PlayerData::getUuid)
        .codec(new MyJsonCodec<>(PlayerData.class, myMapper))
        .build();

💡 Tip — if all you need is a configured Jackson mapper, you don't even need a new class. Both built-ins take one: new JacksonJsonCodec<>(Type.class, mapper) and new JacksonYamlCodec<>(Type.class, mapper). Custom serializers, date formats, naming strategies — all of it rides in on the mapper. See Codecs.


The one rule: isJsonCodec() is a backend-compatibility seam

Several backends (SQL, MongoDB, InMemory) parse or store the payload as native JSON, so they require a JSON codec. They decide by calling isJsonCodec(), whose default checks whether contentType() contains the substring "json":

default boolean isJsonCodec() { return contentType().contains("json"); }

That default is correct for "application/json". But if your format is JSON while your content-type doesn't spell it (a vendor type, say "application/vnd.myapp.v2"), the default returns false and those backends will reject your codec with an IllegalArgumentException at repository(...) time.

⚠️ Gotchaoverride isJsonCodec() to return true whenever your codec emits JSON but its content-type string lacks the literal "json". Otherwise SQL/Mongo/InMemory refuse it even though the bytes are perfectly valid JSON. Conversely, never return true for a non-JSON format — those backends will try to parse it as JSON and fail at runtime. (LocalFile is the only backend that accepts a non-JSON codec; it treats the payload as opaque bytes — which is why JacksonYamlCodec is LocalFile-only.)

@Override public String  contentType() { return "application/vnd.myapp.v2"; }
@Override public boolean isJsonCodec()  { return true; }   // it IS json; tell the JSON-requiring backends so
@Override public String  fileExtension(){ return "json"; } // and give LocalFile a sensible filename

📌 NotefileExtension() only matters for LocalFile (one file per entity). The default derives it from the content-type (…/jsonjson, …/yamlyml, otherwise the last path segment). Override it when the derivation would produce something ugly.

A non-JSON codec (e.g. a binary format) is fine — just keep it on backends that store opaque bytes:

public final class SmileCodec<V> implements Codec<V> {
    @Override public String  contentType() { return "application/x-jackson-smile"; }
    @Override public boolean isJsonCodec()  { return false; }  // binary — LocalFile only
    @Override public String  fileExtension(){ return "smile"; }
    // encode/decode via a SmileMapper ...
}

The manager seam: RefRegistry.codec(...) instead of hand-wiring Jackson

If your entities carry Ref fields (the Caching & References add-on), they need a codec that knows how to write a Ref as its key and recover the target type on read. :core can't depend on :manager, so the ref-awareness can't be baked into core's codecs — the manager module provides the thin seam.

The idiomatic path is RefRegistry.codec(Type.class), which returns a JacksonJsonCodec whose mapper already has the ref module bound to that registry:

RefRegistry refRegistry = new RefRegistry();

EntityDescriptor<UUID, Player> PLAYERS = EntityDescriptor.builder(UUID.class, Player.class)
        .collection("players")
        .keyExtractor(Player::getUuid)
        .codec(refRegistry.codec(Player.class))      // ref-aware codec bound to 'refRegistry'
        .build();

If you maintain your own ObjectMapper and want it ref-aware (custom serializers and Ref support), compose the module yourself — this is the supported extension point:

import br.com.finalcraft.everydatabase.manager.jackson.RefModule;
import br.com.finalcraft.everydatabase.manager.jackson.RefCodecs;
import com.fasterxml.jackson.databind.ObjectMapper;

// (a) register the module onto an existing mapper:
ObjectMapper mine = new ObjectMapper()
        .registerModule(myDomainModule)
        .registerModule(new RefModule(refRegistry));     // <- binds Refs read by this mapper to 'refRegistry'

Codec<Player> codec = new JacksonJsonCodec<>(Player.class, mine);

// (b) or let RefCodecs build a fresh bound mapper for you:
ObjectMapper bound = RefCodecs.newMapper(refRegistry);

⚠️ Gotcha — a Ref read through a codec is bound to that codec's registry. A bare Ref.of(key, type, null) (the explicit way to make an unbound ref, with a null registry) is fine to store (it serializes as just the key) but resolve()/peek() fail fast. If you build a custom mapper and forget the RefModule, your refs round-trip as unbound and won't resolve. See Caching & References.


Domain-named caching managers

A CachingManager<K,V> is meant to be subclassed for a readable domain name and a baked-in cache policy — pass the registry up to super(...):

public final class GuildManager extends CachingManager<UUID, Guild> {
    public GuildManager(Storage storage, RefRegistry registry) {
        super(GUILDS, storage, CacheOptions.builder()
                .policy(CachePolicy.always())
                .maxSize(1000)
                .build(), registry);            // self-registers in 'registry' for Guild
    }
}

Subclassing the cache internals

For most needs you configure through CacheOptions and CachePolicy and never touch the internals. But the cache layer is now deliberately open for extension: the manager.cache classes (CacheEntry, CacheOptions, CachePolicy, LruCacheStore) are non-final with protected members, and CachingManager itself is non-final and exposes its key fields (store, repository, options, stampGen, …) as protected. So a deployment can subclass — for example, to add cache metrics, or to swap LruCacheStore's backing map for a lock-striped one (e.g. Caffeine):

public class MeteredLruCacheStore<K, V> extends LruCacheStore<K, V> {
    public MeteredLruCacheStore(int maxSize) { super(maxSize); }

    @Override public CacheEntry<V> get(K key) {
        CacheEntry<V> e = super.get(key);
        metrics.record(e == null ? "miss" : "hit");
        return e;
    }
}

⚠️ Gotcha — the cell/stamp/tombstone model is load-bearing for the concurrency guarantees (a monotonic stamp is what stops a slow reload regressing a newer write or resurrecting a newer delete). Subclass to observe or swap the backing map, but preserve the stamp ordering of the compound operations (installColdMiss, tombstone, markStale) or you reintroduce the races they exist to prevent. Prefer CacheOptions/CachePolicy whenever it suffices. See Caching Managers and Cache Policies & Freshness.


Logging sinks

Routing log events somewhere custom is a first-class seam — install a sink once and the whole library uses it:

StorageLogSinks.installDefault(event -> myLogger.info(event.format()));

The default resolves per event: a host sink installed via installDefault wins, else SLF4J if present, else a no-op. A throwing sink never breaks a storage operation. Full surface on Logging & Diagnostics.


What is not a public extension point (yet)

Be honest about the boundary so you don't build against something that isn't there:

Want to… Supported? Do this instead
Add a new codec format implement Codec<V> (this page)
Customize JSON/YAML pass your own ObjectMapper to the built-in codecs
Make a custom mapper ref-aware new RefModule(registry) / RefCodecs.newMapper(registry)
Name + policy a cache per type subclass CachingManager
Add cache metrics / swap the backing map subclass manager.cache classes (non-final, protected) — preserve the stamp ordering
Invalidate caches across instances wire CacheSync — see Cross-Process Cache Sync
Route logs anywhere StorageLogSinks.installDefault(...)
Add a new IndexHint.fieldType use the existing types (string/integer/bigInt/decimal/bool/timestamp) — see Indexing & Queries
Add native OR to Query union two queries client-side — see Indexing & Queries
Write a brand-new backend module ⚠️ not a stable published SPI. The modules/* packages (sql, mongo, localfile, memory) are internal; subclassing SqlStorage for a new dialect works but is unversioned territory — open an issue first.

🧭 Decision — need a new SQL dialect? The existing pattern is a thin subclass of SqlStorage overriding identifier quoting (q()), column types, and upsert SQL (that's how PostgreSqlStorage and H2SqlStorage are built). It's doable, but those classes are internal and may change between versions — treat it as a contribution to the project rather than a public API you build a product on. For a genuinely new engine (not SQL), prefer StorageTransfer to bridge data and raise the request upstream.


See also

Clone this wiki locally