Guides

Transactions

The full session API is implemented by the library rather than delegated to the storage engine because the storage engine's own transaction model could not give the rollback guarantees this library promises.

With Spring

Nothing is different. MongoTransactionManager, @Transactional, TransactionTemplate, propagation, and rollbackFor / noRollbackFor all work, because the harness replaces the client underneath and leaves the transaction manager alone.

OrderService.java: unchanged production code
@Service
class OrderService {

    @Transactional
    public void placeOrder(String customerId, String sku, int qty) {
        orderRepository.save(new Order(customerId, sku, qty));
        outboxRepository.save(new OutboxEvent("ORDER_PLACED", customerId));
        // a runtime exception here rolls both writes back
    }
}

With the driver directly

The session API is the driver's own, so plain-driver tests read exactly as they would against a server.

Java
try (ClientSession session = client.startSession()) {
    session.startTransaction();
    orders.insertOne(session, new Document("_id", "ord-1"));
    outbox.insertOne(session, new Document("_id", "evt-1"));
    session.commitTransaction();
}

startSession() and startSession(ClientSessionOptions) both work. TransactionOptions: read concern, write concern, read preference, and max commit time are accepted and recorded, but select no behaviour in a single-node in-process store. Illegal states match the driver: starting a transaction inside one, committing with none active, or using a closed session all raise the same errors they would against a server.

How it works

Writes inside a session go to a per-namespace staging overlay rather than the store. The session reads its own writes; nobody else sees them. Commit locks the physical collections it touches, validates that every document read or written still has its recorded revision. The first committer wins and applies the overlay atomically. Disjoint-collection commits can overlap; only commits touching the same collection serialise. A conflict raises a MongoException carrying the TransientTransactionError label, exactly as a server would.

Collections and databases first referenced inside a transaction materialise in the overlay and are only created at commit, matching MongoDB 4.4 and later.

Rollback guarantees

Three properties hold, and each is covered by tests that fail if it stops holding.

  1. Uncommitted writes never touch the store. An abort is a pure in-memory discard, so there is nothing to undo.
  2. Commit application is atomic. It runs under the global lock against a pre-commit undo snapshot; a fault injected mid-apply restores the store to its pre-commit state exactly.
  3. Every abort path converges. Explicit abortTransaction(), an exception thrown from a withTransaction body, closing a session with a transaction still active, a Spring rollback, and a commit-time conflict all end in the same discard.

Sessions are leak-proof too: a test that ends with an open transaction has it aborted during cleanup, with a warning naming the test.

The retry contract

withTransaction implements the driver's documented behaviour: the body is retried on TransientTransactionError, the commit is retried on UnknownTransactionCommitResult, and the whole thing is bounded by the driver's 120-second cap.

Java
try (ClientSession session = client.startSession()) {
    session.withTransaction(() -> {
        orders.insertOne(session, order);
        inventory.updateOne(session, filter, decrement);
        return null;
    });
}

The whole collection surface is transaction-aware

findOneAndUpdate, findOneAndReplace, findOneAndDelete, distinct, aggregate, and collection-level bulkWrite all use the transaction overlay, including filtered distinct reads and aggregation $lookup sources. No otherwise supported collection operation fails merely because a transaction is active. The point worth stating plainly, since earlier versions did fail some of these fast. Features that already fail fast outside a transaction, including unsupported options, pipeline stages, and driver methods, continue to fail fast at the same named boundary inside one.

Java
// Both forms read through the transaction overlay, including staged writes:
collection.distinct(session, fieldName, resultClass);
collection.distinct(session, fieldName, filter, resultClass);

Where the emulation stops

The commit-time snapshot validation gives serializable-ish, test-grade isolation. It is not a WiredTiger MVCC replica, and this is worth knowing before you write a test that depends on the difference.

  • Conflict detection is content-based. A concurrent write that leaves a document byte-identical raises no write conflict. That is the safe direction to be wrong in. You get fewer spurious aborts, never a lost update, but it is a difference.
  • Transactional reads join conflict validation. Every document read has its revision recorded. If another transaction changes it before commit, even when this transaction writes elsewhere, commit fails with a write conflict carrying TransientTransactionError.
  • Commit locking is per collection. Commits touching disjoint physical collections overlap; only commits whose collection sets overlap serialise.
  • Timing-precise interleavings are out of scope. Tests that depend on exact write-conflict timing need a real server. So do write-concern durability, elections, and the oplog. An in-process store has none of these.