Skip to content

Transactions

Petrus Pradella edited this page Jun 18, 2026 · 2 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. If the work future completes successfully (and you didn't call scope.rollback()), the transaction commits. If it completes exceptionally, or you called scope.rollback(), it 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 simply 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 you get 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 is a live context valid only for the duration of the work; once inTransaction resolves, repositories from it are spent.

💡 Tip — within a transaction you can still .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

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. Treat it as 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 to guard against concurrent overwrites, which works across more backends and needs no shared connection.


See also

Clone this wiki locally