-
Notifications
You must be signed in to change notification settings - Fork 1
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).
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.
📌 Note —
inTransactionreturns aCompletableFuture<R>; the example uses.join()for brevity. Everything composes withthenApply/thenCompose, and there are no blocking variants. See The Async API.
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 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 |
⚠️ Gotcha —scope.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. Soreturn computeResult()after callingrollback()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";
});
});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
scopeafter the lambda returns. It's a live context valid only for the duration of the work; onceinTransactionresolves, 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 manualsupplyAsync,new Thread) inside the lambda escapes the transaction silently. NestinginTransactioninside the lambda fails withIllegalStateException.
💡 Tip — within a transaction you can
.join()intermediate futures or compose them withthenCompose— 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 |
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 standalonemongod, callinginTransaction(...)throws at runtime — theinstanceofcheck 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.InMemoryStorageimplementsTransactionalStorageso 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.
-
The Async API — the
CompletableFuturemodelinTransactionreturns, and exceptional completion. -
CRUD Operations — the
find/saveoperations you run inside a transaction. - Optimistic Locking — the single-entity alternative to a transaction.
- Choosing a Backend — the full capability matrix, including transactions.
- MongoDB — the replica-set requirement in detail.
- In-Memory — the no-isolation caveat.
- Gotchas & Pitfalls — the replica-set and no-isolation traps in one place.
EveryDatabase · Home · made by Petrus Pradella
Getting Started
Core Concepts
Working with Data
Backends
- Choosing a Backend
- MySQL & MariaDB
- PostgreSQL
- H2
- MongoDB
- Local Files
- Grouped Files
- In-Memory
- Benchmarks
Manager Module
- Caching & References
- Typed References (Ref)
- Caching Managers
- Cache Policies & Freshness
- Cross-Process Cache Sync
- Write-Back & Conflict Resolution
- Payload Schema Evolution
- One Entity, Many Databases
Operations
Advanced
Reference
Contributing