Understand

How it works

Bonsai is an embedded MongoDB emulation, specifically a driver-level shim over Nitrite. It implements the MongoDB Java driver's own interfaces and swaps them in underneath your application: everything above the driver seam is real Spring Data, everything below it is a set of Java engines emulating a server.

Choosing the seam

A test double for MongoDB can plug in at three heights, and the choice decides how much of your application the test actually exercises.

  • At the repository layer: supply store-backed implementations of Spring Data repositories. Rejected: MongoTemplate usage, @Query annotations, and any direct MongoCollection access bypass it entirely, so coverage would be a fraction of a real application.
  • At MongoOperations: replace the MongoTemplate bean. Rejected: the interface is enormous, and Spring Data's query derivation, mapping and lifecycle-event machinery would have to be re-implemented rather than reused. Applications that inject MongoClient or MongoDatabase directly still break.
  • At the driver interfaces: implement MongoClient, MongoDatabase, MongoCollection, ClientSession and the iterables, and replace the application's MongoClient / MongoDatabaseFactory beans with them. This is the one the library uses.

The driver seam wins because everything above it keeps working unmodified and real. Spring Data compiles derived queries and @Query strings into BSON documents and calls MongoCollection.find(Bson), so a single translation layer covers repositories, templates, Criteria and hand-built Document filters uniformly. Your converters, custom conversions, @Document/@Field/@Indexed mappings, auditing annotations and AbstractMongoEventListeners all run for real. The test exercises your actual mapping configuration, which is what an integration test is for.

The layers

The four layers of a Bonsai test, from your application down to the embedded storeTop layer contains your application, including MongoRepository, @Query methods, MongoTemplate, Criteria and Query, and @Transactional, running on real Spring Data query derivation, MappingMongoConverter, lifecycle events, auditing and MongoTransactionManager. Below it the seam: the org.mongodb driver interfaces MongoDatabaseFactory, MongoDatabase, MongoCollection of Document and ClientSession, which this library implements. Below the seam, the Bonsai NitriteMongoClient and five engines emulated in Java: codec, filter and index path, update operators, aggregation, and transactions. At the bottom, Nitrite, the embedded in-memory document store holding committed state. Everything above the seam is real and unmodified; everything below it is emulation.Your applicationREAL SPRING DATAnothing here is stubbedMongoRepository@Query methodsMongoTemplateCriteria / Query@TransactionalSpring Data query derivation, @Query parsingMappingMongoConverter (POJO ↔ BSON), custom conversionslifecycle events, auditing, MongoTransactionManagerorg.mongodb driver interfacesMongoDatabaseFactory → MongoDatabase → MongoCollection<Document>THE SEAMimplemented by this librarybonsaiNitriteMongoClient: one per JVMEMULATED IN JAVACodecorg.bson ↔ Nitrite_id ↔ __mid rewriteBSON types keptFilter + indexBSON query → filterindex-backed leafpredicate decidesUpdate$set $inc $push …read, mutate, writecollection-lockedAggregation$match $group $sort$lookup $unwind …staged over cursorsTransactionssession overlaycommit validationretry-able labelsNitriteembedded in-memory document store · indexes · fluent filters · per-collection lockingEMBEDDED STOREcommitted state only
The two heavy rules mark the driver seam. Above them nothing is emulated. Your repositories, converters, listeners and transaction manager are the real Spring Data classes. Below them, five Java engines answer the way a server would.

The driver surface is wide but shallow: the library implements the subset Spring Data actually calls: find, insert, update, replace, delete, count, distinct, aggregate, index management, drop, sessions and the iterables. Everything else throws MongoTestUnsupportedException, naming the method and the compatibility-matrix row that documents the boundary.

text
com.mongodb.MongoException: Unsupported by bonsai:
aggregation stage $facet (see compatibility matrix: aggregation.facet)

The engines

Five components do the work a server would. They are ordinary Java classes over documents that fit in memory, which is definitionally true in an integration test.

BsonDocumentCodec: documents and _id

Each org.bson.Document is walked recursively into a Nitrite Document. ObjectId, Decimal128, Binary, Date, UUID and Pattern are stored directly; a BSON null becomes a Java null. Mongo's _id never touches Nitrite's own _id. It lives in the reserved field __mid, backed by a unique index, and every query, update and sort that mentions _id is rewritten on the way in and back on the way out. An insert with no _id gets a real ObjectId, written back into the caller's document instance exactly as the driver does. An application document that contains a field literally named __mid is rejected rather than silently mangled.

BsonFilterTranslator: two tiers, and index-backed reads

Query translation is a two-tier strategy. Tier 1 compiles operators with a native equivalent: implicit and explicit $eq, $ne, $gt/$gte/$lt/$lte, $in/$nin, $and/$or/$not/$nor, $exists, $regex, $elemMatch and dotted paths. Tier 2 compiles the rest: $size, $all, $type, $mod, array-position paths, and the BSON type-bracket comparison rules, into predicate lambdas that evaluate MongoDB semantics over the document. Correct, at the price of a scan.

Reads are index-backed without giving that correctness up. The translator has a second compilation that produces a fluent leaf AND the whole predicate form: the leaf only has to be a superset of what MongoDB matches, so the store's planner can elect it for an index scan while the predicate, still the authority on missing/null, array, regex-type and cross-numeric rules, filters whatever the index returned. A conjunct qualifies only when it is top-level, undotted, on a single-field index, and compares against a non-null scalar.

The effect is that an _id point lookup stays flat as a collection grows rather than degrading linearly with a full predicate scan.

UpdateOperatorEngine: update operators

Nitrite can only deep-merge, so MongoDB's update operators run in the shim: match documents through the translated filter, apply the operators to the materialised document in Java, write the whole document back under the per-collection lock. The set covers $set, $unset, $inc, $mul, $min, $max, $rename, $setOnInsert, $currentDate, $push with its modifiers, $addToSet, $pull, $pullAll and $pop, plus upserts built from the filter-derived seed document.

AggregationEngine: pipelines as staged transforms

A pipeline is a sequence of transforms over a cursor, so the engine runs it as Java stages over materialised documents: $match (which reuses the filter translator, so a pre-$group match still hits indexes), $project, $sort, $skip, $limit, $count, $unwind, $group, $addFields, $replaceRoot, $sortByCount and the equality form of $lookup. Stages outside that set fail fast by name. This is deliberately a test-grade engine: correctness first, over data that fits in memory.

TransactionEngine: overlay, commit lock, sharding

The embedded store's own session support is not used at all. Each active transaction owns a staging overlay per touched namespace. It tracks pending inserts, updates and deletes keyed by _id, plus the revision of every document it read or wrote. Reads through the session see base state plus overlay; reads outside it never see uncommitted state. Commit validates the recorded revisions against current state, snapshots the affected documents, and applies the overlay; a version conflict aborts with a TransientTransactionError label, so withTransaction retry loops genuinely execute and are genuinely testable.

Because uncommitted writes never touch the store, abortTransaction() is a pure in-memory discard: there is no compensation logic to get wrong and no window in which another reader observes half-applied state. Mutual exclusion is per-collection rather than process-wide wherever the store provides its own locking. Commits on disjoint collection sets proceed concurrently, overlapping sets still serialize.

The full session API, every abort path, and where the emulation stops are covered on the Transactions page.

Why Nitrite

Underneath the engines is Nitrite (org.dizitart:nitrite), an embedded NoSQL document store. It was chosen for what it costs, not for how MongoDB-shaped it is:

  • Pure Java, in-process. No Docker daemon, no downloaded binary, no spawned mongod, no port to collide with. The store starts inside the test JVM in milliseconds.
  • In-memory by default. The in-memory store is built into the core artefact. No storage adapter is needed for the default mode.
  • Apache-2.0, and light. Java 11 bytecode, with slf4j, jasypt and commons-codec as its only compile dependencies.
  • The right primitives. Collections of documents, fluent filters, compound sort with skip and limit, inclusion projection, unique / non-unique / full-text / compound indexes, per-collection event listeners, thread-safe collections, and a query plan the library can assert index usage against in its own tests.

Nitrite is not MongoDB-compatible

This is the honest part, and it is worth stating plainly: Nitrite has a MongoDB-flavoured API, and that is all. It is not a MongoDB implementation, it does not speak the wire protocol, and several of its behaviours are the opposite of MongoDB's. Every one of those gaps is closed above it, in the shim. This is precisely why the shim exists rather than a thin adapter.

Nitrite realityWhat the shim does about it
No server and no wire protocol.Intercepts at the driver-interface level, inside the JVM. There is nothing to connect to, and nothing that needs to be connectable.
_id is a numeric identifier; storing a non-numeric one throws.MongoDB's _id, whether ObjectId, String, or anything else, lives in the reserved __mid field under a unique index, and is rewritten in and out by the codec.
Updates are deep-merge only. No $inc, $push, $unset or array operators.UpdateOperatorEngine implements the operator set in Java: read, mutate, write back under the collection lock.
No aggregation framework at all.AggregationEngine runs pipeline stages over cursors; stages outside the supported set fail fast, naming the stage and the matrix row.
createIndex defaults to unique, the opposite of MongoDB.Index translation always passes explicit options, so a plain @Indexed field is non-unique exactly as it would be against a real server. Nitrite's default is never relied on.
Index builds are asynchronous.Every index creation blocks until the build has finished, so a test can never observe a half-built index or race its own writes.
Its transactions are compensation-based: partial commits are observable on failure, collections must pre-exist, and there is no read-your-writes overlay.Not used. TransactionEngine implements the MongoDB transaction API itself; Nitrite is only the committed-state store.

How the fidelity claim is proven

None of the above is worth anything as an assertion. The promise is not “an in-memory database that feels like MongoDB”. It is that your tests behave the same as they would against real MongoDB, and that the claim is checked.

A differential lane runs a backend-agnostic scenario corpus twice: once against the shim and once against a real mongod. Testcontainers runs where a Docker runtime exists, a flapdoodle-launched server as the Docker-less fallback. Both observations are captured in the same canonical Extended JSON form and diffed. Real MongoDB is the reference backend: when the two disagree, the shim is wrong by definition, and the divergence has exactly four permitted resolutions: a shim fix, a fail-fast guard plus a matrix row, a reviewed normalization rule, or a documented surprise about MongoDB itself. “Close enough” is not a verdict.

How the differential lane proves the fidelity claimOne backend-agnostic scenario corpus fans out to two runners. Runner A is the in-process shim; Runner B is a real mongod started by Testcontainers, and is the reference. Each run emits an observation in canonical Extended JSON. Both observations converge on a normalizer, which applies the reviewed list of permitted differences, and then on a differ, which compares what remains. The verdict: equal means the compatibility-matrix row is proven for this release; a divergence has exactly four permitted resolutions: a shim fix, a fail-fast guard plus a matrix row, a reviewed normalization rule, or a documented surprise about MongoDB itself.Scenario corpusbackend-agnosticone spec, two runsSUBJECTRunner A: the shimin-process driver shim, no serverobservation → canonical Extended JSONREFERENCERunner B: real MongoDBmongod started by Testcontainersobservation → canonical Extended JSONNormalizerpermitted diffsmasked, not ignoredDiffercompares the restno third verdictVerdictREAL MONGODB IS THE REFERENCEequal → the compatibility-matrix row is proven for this releasedivergent → shim fix · fail-fast guard + matrix row · reviewed normalization rule · documented MongoDB surprise
Real MongoDB is the reference backend, so a disagreement means the shim is wrong by definition. “Close enough” is not one of the four resolutions.

The corpus covers BSON data types, query operators, update operators, collation, aggregation, indexes, transactions, the Spring surface, error-signature parity and the driver surface, alongside a property-based generative lane. The emulation targets the MongoDB 7.x and 8.x server API, and the two lines also cross-check each other: if they ever disagree, that is flagged independently of the shim.

Separately, the performance lane enforces byte-for-byte integrity under load. Each workload is replayed deterministically against both backends, then every document in every touched collection is read back, serialized with one fixed canonical Extended JSON writer, and compared byte for byte. A widened Int32, a reordered key, a lost Binary subtype or a differently rounded Date fails the run.

Where the guarantee stops is documented as explicitly as the guarantee itself: Supported operations lists what the emulation covers and every boundary that fails fast, and the compatibility matrix names every edge and the key its error carries, with a fourth category, “silently different”, that is empty by policy. If you would rather verify the claim on your own codebase than take either page's word for it, that is what passthrough mode is for.