Skip to content

Transactions

Petrus Pradella edited this page Jul 5, 2026 · 4 revisions

Transactions

What this page covers: running a unit of work atomically through the optional TransactionalStorage capability — the instanceof opt-in, the inTransaction(scope -> future) shape, scope-bound repositories and rollback(), the commit-on-success rule, and which backends support it (with the Mongo replica-set and in-memory caveats).


The 30-second version

Transactions are a capability, expressed as the interface tx.TransactionalStorage. Check for it with instanceof, then run your work inside inTransaction:

import br.com.finalcraft.everydatabase.*;
import br.com.finalcraft.everydatabase.tx.TransactionalStorage;

if (storage instanceof TransactionalStorage tx) {
    tx.inTransaction(scope -> {
        Repository<UUID, Account> accounts = scope.repository(ACCOUNTS);   // bound to THIS transaction
        return accounts.find(fromId).thenCompose(opt -> {
            Account from = opt.orElseThrow();
            from.setBalance(from.getBalance() - 100);
            return accounts.save(from);
        });
        // returning a successful future commits; throwing or scope.rollback() rolls back
    }).join();
}

Every repository you obtain from the scope shares one transaction. The work future completing successfully (without a scope.rollback()) commits; completing exceptionally, or calling scope.rollback(), rolls back.

📌 NoteinTransaction returns a CompletableFuture<R>; the example uses .join() for brevity. Everything composes with thenApply/thenCompose, and there are no blocking variants. See The Async API.


Capabilities are interfaces, not flags

There is no storage.supportsTransactions() boolean. A backend that supports transactions implements TransactionalStorage; one that doesn't omits it. The compiler then prevents you from calling inTransaction on a non-transactional backend without an explicit instanceof check — the trap can't reach runtime by accident:

// Won't compile: Storage has no inTransaction(...)
// storage.inTransaction(...);

// Correct: narrow first.
if (storage instanceof TransactionalStorage tx) {
    tx.inTransaction(scope -> /* … */);
}

The two interfaces involved are small:

public interface TransactionalStorage extends Storage {
    <R> CompletableFuture<R> inTransaction(Function<TransactionScope, CompletableFuture<R>> work);
}

public interface TransactionScope {
    <K, V> Repository<K, V> repository(EntityDescriptor<K, V> descriptor);
    void rollback();
}

The commit / rollback contract

The outcome is decided entirely by the future your lambda returns and whether you called rollback():

Work future scope.rollback() called? Result
completes successfully no commit
completes successfully yes rollback
completes exceptionally rollback

⚠️ Gotchascope.rollback() does not abort your lambda. It only marks the transaction for rollback; your work future should still complete normally. The backend then aborts instead of committing. So return computeResult() after calling rollback() is correct — don't also throw.

tx.inTransaction(scope -> {
    Repository<UUID, Account> accounts = scope.repository(ACCOUNTS);
    return accounts.find(id).thenApply(opt -> {
        if (opt.isEmpty()) {
            scope.rollback();        // abort the transaction…
            return "no-such-account";// …but complete normally with a sentinel
        }
        return "ok";
    });
});

Scope-bound repositories

Repositories from scope.repository(descriptor) are bound to the running transaction — their reads and writes participate in it and become visible to each other before commit. A repository obtained from the storage outside the scope is a separate, auto-committing path and does not join the transaction.

tx.inTransaction(scope -> {
    Repository<UUID, Account>  accounts = scope.repository(ACCOUNTS);
    Repository<UUID, Transfer> ledger   = scope.repository(TRANSFERS);   // same transaction

    Account from = accounts.find(fromId).join().orElseThrow();
    Account to   = accounts.find(toId).join().orElseThrow();
    from.setBalance(from.getBalance() - 100);
    to.setBalance(to.getBalance() + 100);

    return accounts.save(from)
            .thenCompose(__ -> accounts.save(to))
            .thenCompose(__ -> ledger.save(new Transfer(fromId, toId, 100)));
    // all three commit together, or none do
}).join();

📌 Note — don't retain the scope after the lambda returns. It's a live context valid only for the duration of the work; once inTransaction resolves, repositories from it are spent. Stay on the transaction thread: the scope's repositories bind via a per-thread handle, so an async continuation (thenApplyAsync, a manual supplyAsync, new Thread) inside the lambda escapes the transaction silently. Nesting inTransaction inside the lambda fails with IllegalStateException.

💡 Tip — within a transaction you can .join() intermediate futures or compose them with thenCompose — your choice. Composition keeps the work on the async path; .join() is fine inside the lambda when it reads more naturally, since you're already on a worker thread.


Backend support

Backend TransactionalStorage? Notes
MySQL / MariaDB full ACID transaction (shared JDBC connection)
PostgreSQL full ACID transaction
H2 full transaction — every SQL dialect, including H2, supports it
MongoDB (replica set required) multi-document transaction; needs a replica set
In-Memory (no isolation) atomic commit/rollback, but no isolation between concurrent transactions
Local Files does not implement TransactionalStorage
Grouped Files does not implement TransactionalStorage

Implementation note: SQL backends bind the transaction to one connection via a ThreadLocal<Connection>; Mongo uses a client session.

⚠️ Gotcha — MongoDB needs a replica set. Multi-document transactions require a MongoDB replica set (4.0+). On a standalone mongod, calling inTransaction(...) throws at runtime — the instanceof check passes (the class implements the interface), but the operation fails when it tries to start the session. Run a replica set if you need Mongo transactions. See MongoDB.

⚠️ Gotcha — In-Memory has no isolation. InMemoryStorage implements TransactionalStorage so code written against the capability runs unchanged in tests, and commit/rollback are atomic — but there is no isolation: a concurrent reader can observe uncommitted state. It's a contract-satisfying convenience for tests, not a concurrency guarantee. See In-Memory.

🧭 Decision — reach for a transaction when two or more entities must change together (a transfer, a paired upsert). For a single entity you usually don't need one — prefer Optimistic Locking, which guards against concurrent overwrites, works across more backends, and needs no shared connection.


See also

Clone this wiki locally