Skip to content

MongoDB

Petrus Pradella edited this page Jun 23, 2026 · 3 revisions

MongoDB

What this page covers: opening the MongoDB backend with Storages.createMongo, the two MongoConfig constructors (connection string + database, optionally a connect timeout), how entities are stored as native BSON sub-documents (not escaped JSON strings), how secondary indexes become real Mongo indexes, and the one thing to know up front — transactions need a replica set. Everything else (CRUD, queries, optimistic locking) is identical to every other backend.

📌 Note — switching to Mongo is a one-line change at construction. From storage.repository(...) onward your code is byte-for-byte identical to the SQL or in-memory path. See Choosing a Backend for the full capability matrix.


The 30-second version

import br.com.finalcraft.everydatabase.*;
import br.com.finalcraft.everydatabase.codec.JacksonJsonCodec;
import br.com.finalcraft.everydatabase.modules.mongo.MongoConfig;

import java.util.Optional;
import java.util.UUID;

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

// 2. Open the backend.
MongoStorage storage = Storages.createMongo(
        new MongoConfig("mongodb://localhost:27017", "mydb"));
storage.init().join();                                         // pings the server to verify the connection

// 3. Use it — exactly like any other backend.
Repository<UUID, PlayerData> repo = storage.repository(PLAYERS);
UUID id = UUID.randomUUID();
repo.save(new PlayerData(id, "Alice", 100)).join();            // upsert
Optional<PlayerData> alice = repo.find(id).join();             // -> Optional[Alice]

storage.close().join();

createMongo returns the concrete MongoStorage type, so its capability interfaces (TransactionalStorage, SchemaAwareStorage) are reachable without a cast. The PlayerData entity is the same plain Jackson POJO used throughout the wiki (Quick Start).

📌 Note — every I/O call returns a CompletableFuture. .join() is shown for brevity; in real code compose with thenApply / thenCompose. There are no blocking variants — see The Async API.


Configuration: MongoConfig

Two constructors, both taking a standard MongoDB connection string (URI format) and a database name:

import java.time.Duration;
import java.util.Optional;

// Minimal — uses the driver's default connect timeout.
new MongoConfig("mongodb://localhost:27017", "mydb");

// With auth and an explicit socket connect timeout.
new MongoConfig(
        "mongodb://user:pass@host:27017",
        "mydb",
        Optional.of(Duration.ofSeconds(10)));                  // empty = driver default
Argument Type Meaning
connectionString String MongoDB URI (mongodb://… / mongodb+srv://…), incl. credentials, hosts, options
database String the database name to use
connectTimeout Optional<Duration> socket connect timeout; empty leaves the driver default in place

The connection string is the canonical place for everything Mongo-specific — auth source, TLS, read preference, replica-set name. The third argument only overrides the socket connect timeout; put the rest in the URI.

📌 Noteinit() issues a ping command to verify the connection before completing. A bad URI or unreachable server makes init()'s future complete exceptionally (the cause is a RuntimeException wrapping the driver error). Always .join() / handle init() before opening repositories.


How your data is stored: native BSON

Each entity becomes one document in the collection named by your descriptor. The entity payload is stored as a native BSON sub-document — a real nested document, not an escaped JSON string — under storage_data:

// one document in the "players" collection
{
  "_id":          "5f1e8400-e29b-41d4-a716-446655440000",  // key.toString() — the entity key IS the document _id
  "storage_data": { "uuid": "5f1e…", "name": "Alice", "score": 100 },  // real sub-document
  "_idx_score":   100,        // present per declared IndexHint, populated at save time
  "lock_version": 0           // present only for versioned descriptors
}

📌 Note — the entity key is stored as the document _id (not a separate storage_key field), so identity is enforced by Mongo's built-in unique _id index for free. It also lets Cross-Process Cache Sync recover the key from a Change-Stream delete event (documentKey._id) with no pre-images.

Because storage_data is a genuine sub-document, you can read and query it with any standard Mongo tooling (Compass, mongosh, aggregation pipelines) — it isn't a blob you have to unescape.

⚠️ GotchaMongo requires a JSON codec. storage.repository(...) throws IllegalArgumentException if the descriptor's codec isn't a JSON codec (codec.isJsonCodec()), because the payload is stored and parsed as native JSON/BSON. JacksonYamlCodec is only accepted by Local Files. Use JacksonJsonCodec here. See Codecs.

The key is persisted as key.toString() and must be a stable, unique string of ≤ 255 characters (the value's equals/hashCode must be consistent). An oversized key makes save's future complete exceptionally with IllegalArgumentException — see Entities, Keys & Collections. Collection names must match ^[a-zA-Z][a-zA-Z0-9_]*$.


Indexes are real Mongo indexes

Secondary indexes are declared, not implicit — via @Indexed on entity fields or manual IndexHints on the descriptor (see Indexing & Queries). On Mongo, each declared hint becomes a sibling _idx_<field> field populated at save time, with a real Mongo index created over it:

EntityDescriptor<UUID, PlayerData> PLAYERS = EntityDescriptor.builder(UUID.class, PlayerData.class)
        .collection("players")
        .keyExtractor(PlayerData::getUuid)
        .index(IndexHint.integer("score"))           // -> _idx_score, real ascending index
        .codec(new JacksonJsonCodec<>(PlayerData.class))
        .build();

// queries hit the index, not a scan:
repo.findBy("score", 100).join();
repo.query(Query.range("score", 50, null)).join();   // score >= 50 (inclusive, null = open end)

Entity identity rides on the document _id (the key), which Mongo already indexes uniquely — so there's no separate identity index to create. The first time a repository is opened, Mongo reconciles the _idx_* indexes with the descriptor: new hints are created and backfilled onto existing documents; removed hints get their _idx_* index dropped. So adding or dropping an index is just editing the descriptor and reopening — no manual createIndex.

📌 Note — querying a field that was not declared as an index throws IllegalArgumentException, the same as on every backend. Declare it first.

TIMESTAMP index fields are compared as epoch-millis everywhere. See Indexing & Queries for the full IndexHint factory table and Query semantics.


Transactions need a replica set

MongoStorage implements tx.TransactionalStorage, so you can run a unit of work atomically — but multi-document transactions require a MongoDB replica set (MongoDB 4.0+).

// Repositories obtained from the scope share one session; commit on success,
// rollback on exception or scope.rollback().
storage.inTransaction(scope -> {
    Repository<UUID, PlayerData> txRepo = scope.repository(PLAYERS);
    return txRepo.save(alice)
            .thenCompose(__ -> txRepo.save(bob));
}).join();

⚠️ Gotcha — on a standalone mongod, inTransaction(...) throws at runtime (the driver rejects starting a session transaction). Run a replica set — even a single-node one (rs.initiate()) is enough to enable transactions. The library can't paper over this: it's a server-topology requirement, not a flag.

A first-insert race inside a transaction on a versioned descriptor surfaces as OptimisticLockException (the duplicate-key error is converted, never leaked as a raw MongoWriteException). See Transactions and Optimistic Locking.


Optimistic locking is enforced

Opt in per descriptor (annotate a long/Long field with @OptimisticLock, or .versioned() / .version(getter, setter) — see Optimistic Locking). Mongo enforces it: a lock_version field is stored, starts at 0, and an update only succeeds if the stored version matches — otherwise the future completes exceptionally with OptimisticLockException (surfaced as a CompletionException cause through .join()).

📌 Note — Mongo is one of the three backends that enforce optimistic locking (with MySQL/MariaDB and PostgreSQL). H2 silently degrades to plain upsert; local files and in-memory don't enforce it. See the matrix in Choosing a Backend.


Schema migrations

MongoStorage implements schema.SchemaAwareStorage. Applied versions are tracked in a reserved _schema_migrations collection; migrations are forward-only. Extend MongoMigration and override executeOnDatabase(MongoDatabase):

import br.com.finalcraft.everydatabase.modules.mongo.MongoMigration;
import com.mongodb.client.MongoDatabase;

class V1_SeedFlags extends MongoMigration {
    @Override public String version()     { return "001"; }
    @Override public String description() { return "seed feature flags"; }
    @Override protected void executeOnDatabase(MongoDatabase db) {
        db.getCollection("flags").insertOne(/* ... */);
    }
}

storage.register(new V1_SeedFlags()).migrate().join();

MigrationContext.getNativeClient(MongoDatabase.class) also exposes the raw database inside a migration. See Schema Migrations.


Cross-process cache sync: Change Streams

MongoStorage implements changefeed.ChangeFeedStorage, the best push transport the library has: MongoDB Change Streams are resumable, so an instance that briefly reconnects misses nothing within the oplog window. Both SAVE and DELETE propagate — because the entity key is the document _id, a delete event carries the key with no pre-images. Wire it through the manager's CacheSync so other instances invalidate their caches when one writes:

CacheSync.attach(mongoStorage)
        .bind(guildsManager)     // -> push (change stream); no pollEvery needed
        .start();

⚠️ Gotcha — Change Streams require a replica set (the same requirement as transactions). On a standalone mongod the feed isn't available; the manager falls back to polling only if you set .pollEvery(...). The docker-compose Mongo is a 1-node replica set for exactly this reason.

See Cross-Process Cache Sync for the full picture (push vs poll, key parsing, the own-origin skip).


When to pick Mongo

🧭 Decision — choose MongoDB when your data is document-shaped, you already run Mongo, or you want native BSON sub-documents queryable in Mongo tooling. It gives the same guarantees as the SQL backends — full transactions, enforced optimistic locking, real indexes — with the single caveat that transactions need a replica set. If you don't need documents-on-a-server, a SQL backend (MySQL & MariaDB / PostgreSQL) or H2 may be simpler to operate.

You can migrate live data into or out of Mongo with no code changes — see Moving Data Between Backends.


See also

Clone this wiki locally