Skip to content
Exposed docs1.11

Coroutine transactions

Latest stable Based on Exposed release 1.11.0

An R2DBC repository does not create an application transaction merely because its methods are suspend. The caller chooses the R2dbcDatabase, opens suspendTransaction, and decides which operations must succeed or fail as one unit.

suspend fun renameActor(
database: R2dbcDatabase,
repository: ActorRepository,
id: Long,
newName: String,
) = suspendTransaction(db = database) {
val actor = repository.findById(id)
repository.updateById(id) { it[ActorTable.name] = newName }
actor.copy(name = newName)
}

R2dbcRepository delegates to Exposed statements. It does not hide the database or start a transaction around each method. This keeps multi-step consistency visible: the service method owns the transaction because it knows which reads and writes form one business operation.

Several repository reads return Flow<E>. Building a Flow is not the same as consuming the query. Collect it, call toList, or apply a terminal operator while the transaction context is still active.

suspend fun activeActors(database: R2dbcDatabase): List<ActorRecord> =
suspendTransaction(db = database) {
ActorRepository()
.findBy { ActorTable.active eq true }
.toList()
}

Do not return the cold flow from the transaction and collect it later. The later collector may no longer have the transaction and connection context used to construct the query.

Keep database work in the structured scope that received the transaction context. Launching work in an unrelated application scope makes ownership unclear and may detach it from caller cancellation. Parallel database work also needs enough pool capacity and a deliberate transaction model; one transaction should not be treated as a general-purpose concurrent work queue.

virtualThreadTransactionAsync attaches a child async to the current coroutine context and runs suspendTransaction on the selected dispatcher. A caller-provided ExecutorService remains caller-owned. The helper does not close it.

Pass transactionIsolation and readOnly when the operation requires a non-default contract. Do not assume that a retry is harmless: inserts, external calls, and event publication can duplicate effects. The repository test support deliberately uses maxAttempts = 1, which makes failures easier to attribute during integration tests.

SymptomCheck first
Query fails when a returned Flow is collectedWhether collection escaped suspendTransaction
Partial business updateWhether all required statements share one caller-owned transaction
Pool acquisition timeoutIn-flight requests, pool size, held-connection time, and nested transactions
Cancellation reported as an application errorBroad exception handlers and CancellationException propagation
Executor threads remain after shutdownWhich component created and therefore owns the executor or pool

Do not promise a universal rollback or resource-close order from this module alone. Those details also depend on JetBrains Exposed, the R2DBC driver, the connection pool, and the framework lifecycle. Test the selected stack with the same cancellation and shutdown path used in production.

Continue with repository patterns, then validate failure behavior in cancellation and testing.