Skip to main content

Read-Only Transactions

foundations-jdbc separates read and write paths at the type level. Queries that only read data can use transactRead, which skips transaction overhead and restricts what code can do with the connection.

Two Transaction Modes

transact opens a full read-write transaction (auto-commit off, explicit BEGIN/COMMIT). transactRead uses auto-commit mode: no BEGIN, no COMMIT, no rollback.

One-Liner Convenience

Every OperationRead has a .transactRead(tx) method that executes the operation directly:

// Single read operation — no transaction overhead
fun allUsers(): List<User> =
findAll.transactRead(tx)

This is equivalent to tx.transactRead(conn -> conn.execute(findAll)).

Multiple Reads in One Session

transactRead reuses a single connection for the duration of the block, which avoids repeated connection acquisition:

// Multiple reads in one session — same connection, auto-commit mode
data class Dashboard(val users: List<User>, val count: Long, val recent: List<User>)

fun dashboard(): Dashboard =
tx.transactRead { conn ->
val users = conn.execute(findAll)
val count = conn.execute(countUsers)
val recent = conn.execute(findRecent)
Dashboard(users, count, recent)
}

Each query runs on the same connection in auto-commit mode — no transaction coordination, but consistent connection-level settings (schema, timeout, etc.).

Type Safety

The two modes use different connection types. ConnectionRead exposes only read methods:

public interface ConnectionRead {
<T> T execute(OperationRead<T> op);
<T> List<T> query(Fragment sql, RowCodec<T> codec);
<T> Optional<T> queryFirst(Fragment sql, RowCodec<T> codec);
}

public interface Connection extends ConnectionRead {
<T> T execute(Operation<T> op);
int update(Fragment sql);
java.sql.Connection unwrap();
}

Inside a transactRead block, the compiler prevents writes:

tx.transactRead(conn -> {
conn.query(selectSql, codec); // OK
conn.execute(readOp); // OK — OperationRead accepted

conn.update(insertSql); // compile error — no update() on ConnectionRead
conn.unwrap(); // compile error — no unwrap() on ConnectionRead
conn.execute(writeOp); // compile error — Operation rejected, OperationRead required
return null;
});

The Variance Model

The type hierarchy follows a deliberate variance pattern:

  • Operations: OperationRead <: Operation — read-only is a subtype of general operation. A read operation can be used anywhere an operation is expected.
  • Connections: Connection extends ConnectionRead — a read-write connection can do everything a read-only connection can.

Operations require capabilities (fewer requirements = more general = subtype). Connections grant capabilities (more capabilities = more powerful = subtype). This means a Connection can always be passed where a ConnectionRead is expected, and an OperationRead can always be passed where an Operation is expected.

When to Use Which

ModeUse for
transactReadSELECT queries, reports, dashboards, read replicas
transactINSERT, UPDATE, DELETE, DDL, anything that modifies data