Skip to content
Exposed docs1.11

JDBC repository patterns

Latest stable Based on Exposed release 1.11.0

JdbcRepository<ID, E> is a table-oriented repository contract. It maps ResultRow values to records and supplies common CRUD, batch, query, and paging operations. It is intentionally smaller than a domain service: validation, transaction ownership, authorization, and cross-repository workflows stay outside it.

class ActorRepository : LongJdbcRepository<ActorRecord> {
override val table = ActorTable
override fun extractId(entity: ActorRecord): Long = entity.id
override fun ResultRow.toEntity() = ActorRecord(
id = this[ActorTable.id].value,
firstName = this[ActorTable.firstName],
lastName = this[ActorTable.lastName],
)
override fun BatchInsertStatement.bindSave(entity: ActorRecord) {
this[ActorTable.firstName] = entity.firstName
this[ActorTable.lastName] = entity.lastName
}
}

The default bindSave throws. This makes saveAll an opt-in operation: the repository author must declare which columns participate in the insert.

NeedAPIContract to remember
Read onefindById, findByIdOrNull, findFirstOrNullfindById expects exactly one row
Read manyfindAll, findBy, findWithFiltersResults are materialized in the current transaction
Existence/countexistsById, existsBy, count, countByKeep predicates close to the table definition
WriteupdateById, updateAll, deleteById, deleteAllThese do not add audit fields
BatchsaveAll, batchInsert, batchUpsertGenerated-value and dialect behavior must be tested
PagefindPageCount and content are separate queries

Use findByIdOrNull at a boundary where absence is expected. Use findById when absence means a violated invariant and let the exception make that contract visible.

For an AuditableIdTable, implement AuditableJdbcRepository and call auditedUpdateById or auditedUpdateAll. They set updatedAt = CURRENT_TIMESTAMP and updatedBy = UserContext.getCurrentUser() before applying the caller’s column changes.

UserContext.withUser("admin") {
transaction {
repository.auditedUpdateById(id) {
it[Users.name] = "Alice"
}
}
}

Calling the generic updateById is valid SQL but bypasses the audit update contract.

SoftDeletedJdbcRepository provides active/deleted counts, filtered reads, page queries, softDeleteById, and restore operations. It does not change the behavior of every inherited generic method. Code that calls the base findAll still needs an explicit decision about deleted rows.

Put reusable, persistence-specific predicates and projections in the repository. Keep business branching in the service. A custom search may build a query with andWhere, but input validation and authorization should already be resolved.

For large ordered scans, fetchBatchedResultFlow exposes cursor batches as a coroutine Flow over JDBC. It supports an Int/Long or matching EntityID cursor and rejects a preconfigured manual limit/order. The flow restores its temporary query mutations in finally, including cancellation. The underlying I/O is still blocking JDBC.

  • A repository that opens its own transaction prevents a service from composing atomic operations.
  • Forgetting bindSave makes saveAll fail immediately.
  • Generic updates on an audited table omit update audit fields.
  • Base reads on a soft-delete table can include deleted rows.
  • Batch upsert behavior and generated values vary by dialect; verify the databases you deploy.
  • A cursor scan needs a stable, non-null numeric cursor. A mutable sort column can skip or repeat rows.

Start with the source repository’s ActorJdbcRepository and tests, then run the staged exposed-workshop SQL DSL, transaction, repository, and production-integration examples. See Operations and testing before copying a repository into a service.