Skip to content
Petrus Pradella edited this page Jun 26, 2026 · 4 revisions

Codecs

What this page covers: the Codec<V> serialization SPI — the built-in JacksonJsonCodec (compact vs pretty) and JacksonYamlCodec, why isJsonCodec() decides which backends accept a codec, and how to write your own. (The ref-aware codec for the manager add-on lives elsewhere — see the note at the end.)


The 30-second version

import br.com.finalcraft.everydatabase.codec.Codec;
import br.com.finalcraft.everydatabase.codec.JacksonJsonCodec;
import br.com.finalcraft.everydatabase.codec.JacksonYamlCodec;

// Compact JSON — the default; what a database wants.
Codec<PlayerData> json   = new JacksonJsonCodec<>(PlayerData.class);

// Indented JSON — for human-readable LocalFile output.
Codec<PlayerData> pretty = JacksonJsonCodec.pretty(PlayerData.class);

// YAML — LocalFile only.
Codec<PlayerData> yaml   = new JacksonYamlCodec<>(PlayerData.class);

// Attach one to the descriptor:
EntityDescriptor.builder(UUID.class, PlayerData.class)
        .collection("players")
        .keyExtractor(PlayerData::getUuid)
        .codec(json)                       // <- the serialization strategy
        .build();

A Codec is just entity ↔ bytes plus a content type. The backend takes it from there.


The SPI

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

    default boolean isJsonCodec()  { return contentType().contains("json"); }
    default String  fileExtension(){ /* "json" / "yml" derived from contentType() */ }
}
  • encode / decode are the round-trip; failures throw CodecException.
  • contentType() is the MIME type of the encoded form. Backends that store raw bytes (LocalFile) use it to log/pick a format; the two defaults below are derived from it.
  • isJsonCodec() is the backend-compatibility seam (next section).
  • fileExtension() drives per-entity filenames in LocalFile ("json"players/<key>.json, "yml".yml).

isJsonCodec() — the compatibility seam

This single predicate decides where a codec is allowed to run. Several backends — SQL, MongoDB, InMemory — parse or store the payload as native JSON, so they require a JSON codec. Only LocalFile treats the payload as opaque bytes, so it's the one backend that also accepts YAML (or anything else).

Backend Accepts Why
MySQL / MariaDB JSON only stored in a native JSON column
PostgreSQL JSON only stored in a native JSON column
H2 JSON only stored as JSON text in a TEXT column
MongoDB JSON only parsed into a native BSON sub-document
InMemory JSON only parses/stores the payload as JSON
LocalFile JSON or YAML opaque bytes, format is the codec's business
// ✗ A YAML codec on a JSON-requiring backend is rejected.
Storage sql = Storages.createSQL(sqlConfig);
sql.repository(EntityDescriptor.builder(UUID.class, PlayerData.class)
        .collection("players")
        .keyExtractor(PlayerData::getUuid)
        .codec(new JacksonYamlCodec<>(PlayerData.class))   // not JSON -> IllegalArgumentException
        .build());

// ✓ The same YAML codec is fine on LocalFile.
Storage file = Storages.createLocalFile(new LocalFileConfig(Path.of("data")));
file.repository(/* ...descriptor with the YAML codec... */);

⚠️ Gotcha — pairing a non-JSON codec with SQL, Mongo, or InMemory fails with IllegalArgumentException. If you write a custom codec whose contentType() doesn't contain the string "json" but does emit JSON, override isJsonCodec() to return true or those backends will reject it.


JacksonJsonCodec — compact by default, pretty on demand

The default output is compact (no indentation) — databases persist or re-parse the payload verbatim, so pretty-print whitespace would only inflate storage and I/O. For human-readable per-entity files in LocalFile, use the pretty(...) factory:

Codec<PlayerData> compact = new JacksonJsonCodec<>(PlayerData.class);   // {"uuid":"…","name":"Alice","score":100}
Codec<PlayerData> nice    = JacksonJsonCodec.pretty(PlayerData.class);  // indented, multi-line

Both report contentType() application/json, so both are accepted by every backend. The pretty variant is meant for LocalFile, where a human opens the files.

The default mapper is batteries-included (see the profiles below), so java.time, Optional, etc. already round-trip with no setup. Pass your own ObjectMapper only for truly custom needs — or apply an EveryDatabase profile to your own mapper so it keeps the storage defaults:

ObjectMapper mapper = JacksonConfig.storageSafe(new JsonMapper());
mapper.registerModule(new MyCustomModule());        // your extras, on top of the defaults
Codec<PlayerData> codec = new JacksonJsonCodec<>(PlayerData.class, mapper);

📌 Note — Jackson here is 2.x (com.fasterxml.jackson, jackson-databind). Keep your imports on the com.fasterxml.jackson.* packages — not the tools.jackson packages of Jackson 3.x. Versions live on Dependency Versions & Overrides.


The default mapper — JacksonConfig profiles

new JacksonJsonCodec<>(Type.class) (and the YAML codec) build their mapper with JacksonConfig.storageSafe(...) — a batteries-included default tuned for storage:

  • java.time + Optional modules registered (Instant, LocalDate, Duration, Optional<T>, … just work);
  • dates as ISO-8601 text, not numeric epochs;
  • map entries ordered alphabetically by key → deterministic, diff-friendly bytes regardless of the Map implementation or insertion order;
  • unknown properties tolerated on read, so a field removed in newer code doesn't break old stored data.

Three profiles are available via JacksonConfig; apply any of them to a JSON or YAML mapper (YAMLMapper extends ObjectMapper):

Behaviour baseReadContract storageSafe (default) compact
Instant / absolute dates numeric epoch ISO-8601 text ISO-8601 text
LocalDate / LocalDateTime numeric array [y,m,d,…] ISO-8601 text ISO-8601 text
Duration numeric ISO-8601 (PT1M30S) ISO-8601
Map key order alphabetical (sorted by key) alphabetical (sorted by key) alphabetical (sorted by key)
null properties kept kept dropped
empty Optional kept (as null) kept (as null) dropped
Typical use the shared read contract everything hot / large collections

All three share one frozen read contract (the modules + tolerate-unknown + the alphabetical map ordering). storageSafe and compact differ only in that compact omits null/absent properties (JsonInclude.Include.NON_ABSENT) — same dates, same ordering. That makes them fully interchange-compatible: any profile reads what any other wrote, so you can switch a collection between storageSafe and compact without migrating data.

// All three accept any ObjectMapper subtype and return it, configured:
ObjectMapper safe    = JacksonConfig.storageSafe(new JsonMapper());   // the codec default
ObjectMapper small   = JacksonConfig.compact(new JsonMapper());       // drops null/absent
ObjectMapper yamlSafe= JacksonConfig.storageSafe(new YAMLMapper());   // same rules, YAML syntax

🔎 Indexing uses the codec's own mapper. A Jackson codec exposes its ObjectMapper through the ObjectMapperAware capability, so the secondary-index tree is built with the same configuration the entity is persisted with — the indexed form of a field can never disagree with the stored form. A non-Jackson codec simply doesn't implement it, and indexing falls back to the default mapper.


JacksonYamlCodec — LocalFile only

Codec<PlayerData> yaml = new JacksonYamlCodec<>(PlayerData.class);  // contentType() = "application/yaml"

It reports application/yaml, so isJsonCodec() is false and fileExtension() is "yml". That's exactly what lets LocalFile write players/<key>.yml, and exactly what stops you attaching it to a JSON-requiring backend. Use it when you want config-style, hand-editable files on disk.


Writing a custom codec

Implement the four methods (two are usually fine via the defaults). The contract:

public final class GzipJsonCodec<V> implements Codec<V> {
    private final JacksonJsonCodec<V> delegate;
    GzipJsonCodec(Class<V> type) { this.delegate = new JacksonJsonCodec<>(type); }

    @Override public byte[] encode(V value) { return gzip(delegate.encode(value)); }
    @Override public V      decode(byte[] data) { return delegate.decode(gunzip(data)); }
    @Override public String contentType() { return "application/json+gzip"; }

    // contentType() still contains "json", so the default isJsonCodec() returns true.
    // If it did NOT, you'd override it:
    // @Override public boolean isJsonCodec() { return true; }
}

Three rules for a well-behaved codec:

  1. Round-trip faithfullydecode(encode(v)) must reconstruct an equal entity.
  2. Throw CodecException on (de)serialization failure, so the error propagates as the library expects.
  3. Get isJsonCodec() right — if a JSON-requiring backend must accept your codec, ensure it returns true (either via the "json" substring convention or an explicit override).

💡 TipfileExtension() only matters if you'll use the codec with LocalFile. Override it when your contentType() doesn't map cleanly to a conventional extension.

See Extending the Library for the broader set of extension seams.


The manager add-on uses a different codec factory

If your entities hold Ref fields (the optional Caching & References), don't attach a plain JacksonJsonCodec — a Ref needs Jackson taught to write it as just its key and recover the target type on read. That ref-aware codec is RefCodecs.json(...), vended by a RefRegistry (registry.codec(Type.class)). It lives in the everydatabase-manager module — not in core — precisely because :core must not depend on :manager. A leaf entity with no Ref fields can keep using JacksonJsonCodec.

// Manager module only — for entities that contain Ref fields.
RefRegistry refRegistry = new RefRegistry();
EntityDescriptor.builder(UUID.class, Player.class)
        .collection("players")
        .keyExtractor(Player::getUuid)
        .codec(refRegistry.codec(Player.class))   // ref-aware; from everydatabase-manager
        .build();

Full story: Caching & References.


See also

Clone this wiki locally