Skip to content
Exposed docs1.11

R2DBC repository patterns

Latest stable Based on Exposed release 1.11.0

R2dbcRepository<ID, E> supplies common Exposed CRUD operations while leaving table choice, ID extraction, row mapping, and insert binding in application code. This is a mapping and query abstraction, not an aggregate transaction manager.

object ActorTable : LongIdTable("actors") {
val name = varchar("name", 100)
}
data class ActorRecord(val id: Long = 0, val name: String)
class ActorRepository : LongR2dbcRepository<ActorRecord> {
override val table = ActorTable
override fun extractId(entity: ActorRecord) = entity.id
override suspend fun ResultRow.toEntity() = ActorRecord(
id = this[ActorTable.id].value,
name = this[ActorTable.name],
)
override fun BatchInsertStatement.bindSave(entity: ActorRecord) {
this[ActorTable.name] = entity.name
}
}

Override bindSave before using the default saveAll. The default implementation throws UnsupportedOperationException; this prevents an apparently successful batch call from silently omitting application columns.

NeedAPI shapeContract
Required rowfindByIdFails when no row exists
Optional rowfindByIdOrNullReturns null when absent
Stream/filterfindAll, findBy, findByFieldReturns Flow<E>; collect inside the transaction
Page with totalfindPageValidates page number and size and returns ExposedPage
Bulk insertsaveAll, batchInsertBinding and generated-ID behavior must match the table/driver
Bulk upsertbatchUpsertConflict behavior is database-specific and needs integration tests

Avoid converting every query to List by habit. Use a list when the complete result is required in memory; use Flow when downstream processing can consume rows incrementally. In both cases, the terminal operation remains inside suspendTransaction.

AuditableR2dbcRepository adds update helpers for updatedAt and updatedBy. Plain updateById and updateAll do not set audit columns automatically, so choose the audited methods where that contract is required.

SoftDeletedR2dbcRepository exposes active, deleted, soft-delete, and restore operations. A normal findAll is not automatically restricted to active rows. Make visibility explicit at the service boundary rather than assuming a global filter.

Map required columns directly and fail close to the database boundary. Do not turn every ResultRow into Map<String, Any?>; explicit mapping keeps nullability, ID conversion, and schema drift visible. If mapping itself performs suspension or external I/O, its latency extends the transaction and connection holding time.

  • Empty saveAll input is a no-op.
  • Generated IDs preserve the order reported by Exposed batchInsert; verify driver behavior for the selected database.
  • findPage computes a count and reads page content. Concurrent writes can change the database between those operations unless the chosen isolation level provides the needed view.
  • Large offsets remain a database concern. For deep navigation, evaluate keyset pagination in application-specific queries.

Start with one repository and H2, then repeat the same behavior against the production driver. Continue to exposed-r2dbc-workshop for DDL/DML, coroutine repositories, Spring WebFlux, Ktor, cache, multi-tenancy, and routing exercises. The workshop is a learning repository; the 1.11.0 source links below remain the API authority for this manual.