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:
- Kotlin
- Java
- Scala
// Single read operation — no transaction overhead
fun allUsers(): List<User> =
findAll.transactRead(tx)
// Single read operation — no transaction overhead
List<User> allUsers() {
return findAll.transactRead(tx);
}
// Single read operation — no transaction overhead
def 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:
- Kotlin
- Java
- Scala
// 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)
}
// Multiple reads in one session — same connection, auto-commit mode
record Dashboard(List<User> users, long count, List<User> recent) {}
Dashboard dashboard() {
return tx.transactRead(conn -> {
var users = conn.execute(findAll);
var count = conn.execute(countUsers);
var recent = conn.execute(findRecent);
return new Dashboard(users, count, recent);
});
}
// Multiple reads in one session — same connection, same transaction
case class Dashboard(users: List[User], count: Long, recent: List[User])
// ConnectionRead is available implicitly inside transactRead { }
def dashboard(): Dashboard =
tx.transactRead {
val users = findAll.run
val count = countUsers.run
val recent = findRecent.run
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
| Mode | Use for |
|---|---|
transactRead | SELECT queries, reports, dashboards, read replicas |
transact | INSERT, UPDATE, DELETE, DDL, anything that modifies data |