-
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. 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.
📌 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 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 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 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
scopeafter the lambda returns. It is a live context valid only for the duration of the work; onceinTransactionresolves, repositories from it are spent.
💡 Tip — within a transaction you can still
.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
|
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. 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.
-
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
Manager Module
- Caching & References
- Typed References (Ref)
- Caching Managers
- Cache Policies & Freshness
- Cross-Process Cache Sync
- One Entity, Many Databases
Operations
Advanced
Reference
Contributing