§ reference

TransactionManager

ActionExecutor is the right entry point for business operations — anything that mutates a domain object and emits events. But sometimes you need raw transactional database access outside that pipeline: an admin script that backfills a column, a startup hook that reconciles two tables, a custom multi-step read that doesn't fit the Action shape, or batch maintenance that has no associated event.

For those, the framework gives you TransactionManager directly. It’s the same primitive ActionExecutor.persistChanges() uses internally — one transaction, one JDBC connection, auto commit-or-rollback, with the connection bound to the calling thread via ScopedValue so any repository methods you invoke from inside automatically join the transaction.

What it owns

You typically don’t construct TransactionManager yourself in application code — you reach for one through DatabaseRegistry:

TransactionManager tm = databaseRegistry.defaultTransactionManager();
// or, on a sharded system:
TransactionManager mexico = databaseRegistry.transactionManager(MEXICO_SHARD);

The inTransaction(...) family

Two flavors, mirrored for Function (returning a value) and Consumer (no return), checked-vs-unchecked variants:

// Returns a value, wraps checked exceptions in RuntimeException
<R> R inTransaction(Function<DSLContext, R> block);
   void inTransaction(Consumer<DSLContext> block);

// Returns a value, allows the block to throw checked exceptions
<R> R inTransactionChecked(CheckedFunction<DSLContext, R> block) throws Exception;
   void inTransactionChecked(CheckedConsumer<DSLContext> block) throws Exception;

The block receives a jOOQ DSLContext, not a Transaction. Transaction is package-private framework internals — it is not reachable from application code, and you never need it: the DSLContext you are handed is already bound to the transaction’s connection.

What each call does, in order:

  1. Borrow a Connection from the primary ConnectionProvider.
  2. Save its current autoCommit, set autoCommit = false.
  3. Build a JOOQ DSLContext over the connection and bind it (along with the Connection) into the internal ScopedValue<Transaction>.
  4. Run your lambda, passing that DSLContext as its argument.
  5. On normal return: commit(), then restore autoCommit, then release the connection back to the pool.
  6. On exception: rollback() (errors during rollback are logged but don’t mask the original throwable), then restore autoCommit, release the connection, rethrow.

If the commit() or rollback() itself fails, autoCommit is deliberately NOT restored. Re-enabling auto-commit on a connection whose transaction may still be open is itself a commit — pgjdbc issues an explicit COMMIT, MySQL and MariaDB send SET autocommit=1, which they document as an implicit commit. Restoring it after a failed rollback would therefore commit the very transaction the framework just failed to roll back. Instead the transaction is marked dirty and the connection is evicted rather than returned to the pool; the physical close makes the server discard whatever was still pending.

A failed commit() is not on its own grounds for eviction: auto-commit is left disabled so the follow-up rollback() can do its job, and if that rollback succeeds the connection is clean and goes back to the pool normally.

Do not nest. The framework does not simulate nested transactions or savepoints. A nested inTransaction(...) on the same tm does not fail loudly — ScopedValue rebinding shadows the outer transaction, so the inner block quietly runs on a second pooled connection with its own commit boundary. That is almost never what you want. Structure your code to do all the work inside one outer block.

Direct usage — a worked example

A backfill script that adds a default currency to wallets that don’t have one yet:

DatabaseRegistry registry = …;          // wired by DI or by hand
TransactionManager tm = registry.defaultTransactionManager();

tm.inTransactionChecked(db -> {
    // `db` is the transaction-bound DSLContext

    // Find wallets missing a currency
    List<UUID> orphans = db.select(WALLETS.ID)
            .from(WALLETS)
            .where(WALLETS.CURRENCY.isNull())
            .forUpdate()                 // hold them for the duration of this transaction
            .fetch(WALLETS.ID);

    if (orphans.isEmpty()) return;

    // Patch each one and write an audit row in the same transaction
    db.update(WALLETS)
            .set(WALLETS.CURRENCY, "EUR")
            .where(WALLETS.ID.in(orphans))
            .execute();

    db.insertInto(AUDIT_LOG, AUDIT_LOG.AT, AUDIT_LOG.NOTE, AUDIT_LOG.AFFECTED_COUNT)
            .values(Instant.now(), "currency backfill", orphans.size())
            .execute();
});

Either both writes commit together, or neither does. Same atomicity guarantee an Action gives you, with none of the Action machinery (no ActionPlan, no eventlog.events row, no retries on StaleRecordException).

The lambda receives the transaction-bound DSLContext directly — use it for raw jOOQ. If you genuinely need the JDBC Connection, reach it through jOOQ (db.configuration().connectionProvider()); the framework’s Transaction type is internal and not exposed.

Repository writes join automatically

Because the open transaction is bound into a ScopedValue<Transaction>, inherited repository writes and custom queries that use txDbElseDb(...) reuse the open transaction’s connection. No need to pass transaction or db around manually for writes:

WalletRepository walletRepository = …;

tm.inTransactionChecked(db -> {
    // Inherited reads use primary connections by design. If this read
    // must see uncommitted writes from this block, use `db` directly
    // or a custom repository query that uses txDbElseDb(...).
    Wallet wallet = walletRepository.getById(walletId);

    // Custom write inside the repository uses txDbElseDb() — same connection.
    walletRepository.markAllSettled(List.of(walletId));

    // Direct JOOQ on the same DSLContext — same connection.
    db.update(WALLETS)
            .set(WALLETS.STATE, "ARCHIVED")
            .where(WALLETS.ID.eq(walletId))
            .execute();
});

This is what makes the “use repositories outside actions” path painless. You don’t have to drop into raw JOOQ for writes; inherited write methods and custom txDbElseDb(...) writes participate in the same transaction. Reads are a choice: inherited reads use primary committed state, readonlyDb(...) is for explicit replica reads, and txDbElseDb(...) or the DSLContext handed to the block is for custom reads that must see the current transaction.

When NOT to use it directly

If your operation is a domain operation — it mutates a Model and emits a ModelEvent — use an Action instead. The Action path gets you:

tm.inTransaction(...) is the escape hatch — it gives you a transaction without any of that. Reach for it when the operation legitimately doesn’t fit the Action shape:

If you find yourself reaching for tm.inTransaction(...) for normal business work, that’s a signal — the operation probably wants to be an Action instead.

Read-only access (no transaction needed)

If you only need to read, you don’t have to open a transaction at all. DatabaseRegistry exposes DSLContexts directly:

// DatabaseRegistry exposes per-shard DSLContexts as maps, not accessor methods.
DSLContext replica = registry.secondary.get(shard);   // replica reads, tolerates lag
DSLContext primary = registry.primary.get(shard);     // freshest committed state

// For the default shard:
DSLContext defaultPrimary = registry.primary.get(registry.defaultShard);

These are bare connections from the pool — no transaction is opened, no ScopedValue is bound. Use them when nothing about your read needs the all-or-nothing semantics.

Cross-shard

TransactionManager is per-shard. There’s no distributed 2PC: if you need writes across multiple shards atomically, you can’t. The Action layer’s allowCrossShard=true mode runs each shard’s transaction separately and accepts the partial-failure risk. If you need the same shape outside an Action, you can write it by hand, but it has the same partial-commit behavior:

registry.transactionManager(GLOBAL_SHARD).inTransactionChecked(_ -> { /* work A */ });
registry.transactionManager(MEXICO_SHARD).inTransactionChecked(_ -> { /* work B */ });
// If work B fails, work A is already committed.

Treat this as an escape hatch. If partial commits would require compensation, use a saga instead of trying to make two shard transactions behave like one.

For the much more common single-shard case, just pick the right TransactionManager from the registry and you’re done.

Tracing

tm.inTransaction(...) produces an ekbatan.transaction OTel span with ekbatan.shard.group / ekbatan.shard.member attributes set automatically (the TransactionManager knows its own ShardIdentifier). On rollback the span is marked ERROR. See Observability for the full attribute table.

See also