JDBC
The module provides a repository implementation based on JDBC for
working with relational databases and uses Hikari to manage the connection
pool.
You describe a repository interface and SQL queries with @Repository and @Query, and Kora generates an implementation
that obtains a connection from the pool, binds parameters, reads the result, and participates in transactions.
Common rules for entities, @Repository, @Query, @Batch, UpdateCount, macros, manual queries, and other repository
mechanisms are described in Common database rules.
For a step-by-step walkthrough before the reference details, see JDBC Database and Advanced JDBC Database.
Dependency¶
Dependency build.gradle:
Module:
Dependency build.gradle.kts:
Module:
You also must provide the database driver implementation as a dependency.
The module registers the connection pool as a JdbcDataSource component. It implements JdbcExecutor,
so repositories receive it automatically, and it wraps a javax.sql.DataSource,
so a plain javax.sql.DataSource can be injected into your own components when a third-party library requires one.
Configuration¶
Basic JDBC configuration parameters are read from the jdbc config section:
jdbc {
jdbcUrl = "jdbc:postgresql://localhost:5432/postgres" //(1)!
username = "postgres" //(2)!
password = "postgres" //(3)!
poolName = "kora" //(4)!
maxPoolSize = 10 //(5)!
}
JDBC URLfor database connection (required, no default)- Username for connection (
required, no default) - Password for connection (
required, no default) Hikariconnection pool name (required, no default)- Maximum
Hikariconnection pool size (default:10)
jdbc:
jdbcUrl: "jdbc:postgresql://localhost:5432/postgres" #(1)!
username: "postgres" #(2)!
password: "postgres" #(3)!
poolName: "kora" #(4)!
maxPoolSize: 10 #(5)!
JDBC URLfor database connection (required, no default)- Username for connection (
required, no default) - Password for connection (
required, no default) Hikariconnection pool name (required, no default)- Maximum
Hikariconnection pool size (default:10)
Full Configuration
Example of the complete configuration described by JdbcDatabaseConfig (example values or default values are shown):
jdbc {
jdbcUrl = "jdbc:postgresql://localhost:5432/postgres" //(1)!
username = "postgres" //(2)!
password = "postgres" //(3)!
schema = "public" //(4)!
poolName = "kora" //(5)!
maxPoolSize = 10 //(6)!
minIdle = 0 //(7)!
connectionTimeout = "10s" //(8)!
validationTimeout = "5s" //(9)!
idleTimeout = "10m" //(10)!
maxLifetime = "15m" //(11)!
leakDetectionThreshold = "0s" //(12)!
initializationFailTimeout = "10s" //(13)!
readinessProbe = false //(14)!
dsProperties { //(15)!
"hostRecheckSeconds": "2"
}
telemetry {
logging {
enabled = false //(16)!
}
metrics {
enabled = false //(17)!
driverMetrics = true //(18)!
slo = [ 1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 90000 ] //(19)!
tags = { // (20)!
"key1" = "value1"
"key2" = "value2"
}
}
tracing {
enabled = true //(21)!
attributes = { // (22)!
"key1" = "value1"
"key2" = "value2"
}
}
}
}
JDBC URLfor connecting to the database (required, no default)- Username for the connection (
required, no default) - User password for the connection (
required, no default) - Database schema for the connection (optional, no default)
Hikariconnection pool name (required, no default)- Maximum
Hikariconnection pool size (default:10) - Minimum number of idle ready connections in the
Hikaripool (default:0) - Maximum time to wait for a connection from the
Hikaripool (default:10s) - Maximum time for
Hikariconnection validation (default:5s) - Maximum idle time for a
Hikariconnection (default:10m) - Maximum lifetime of a
Hikariconnection (default:15m) - Time after which a busy connection is considered a possible leak (default:
0s) - Maximum time to wait for connection validation at service startup. When it is not set, the service starts without touching the database (optional, no default)
- Whether to enable the readiness probe for the database connection (default:
false) - Additional
JDBCconnection properties passed toHikaridataSourceProperties(default:{}) - Enables module logging (default:
false) - Enables module metrics (default:
false) - Whether to report the
Hikaripool's own metrics, such as pool size and connection acquire time (default:true) - Configures SLO for metrics in milliseconds (default:
io.koraframework.telemetry.common.TelemetryConfig.MetricsConfig#DEFAULT_SLO) - Configures metric tags (default:
{}) - Enables module tracing (default:
true) - Configures tracing attributes (default:
{})
jdbc:
jdbcUrl: "jdbc:postgresql://localhost:5432/postgres" #(1)!
username: "postgres" #(2)!
password: "postgres" #(3)!
schema: "public" #(4)!
poolName: "kora" #(5)!
maxPoolSize: 10 #(6)!
minIdle: 0 #(7)!
connectionTimeout: "10s" #(8)!
validationTimeout: "5s" #(9)!
idleTimeout: "10m" #(10)!
maxLifetime: "15m" #(11)!
leakDetectionThreshold: "0s" #(12)!
initializationFailTimeout: "10s" #(13)!
readinessProbe: false #(14)!
dsProperties: #(15)!
hostRecheckSeconds: "2"
telemetry:
logging:
enabled: false #(16)!
metrics:
enabled: false #(17)!
driverMetrics: true #(18)!
slo: [ 1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 90000 ] #(19)!
tags: #(20)!
key1: value1
key2: value2
tracing:
enabled: true #(21)!
attributes: #(22)!
key1: value1
key2: value2
JDBC URLfor connecting to the database (required, no default)- Username for the connection (
required, no default) - User password for the connection (
required, no default) - Database schema for the connection (optional, no default)
Hikariconnection pool name (required, no default)- Maximum
Hikariconnection pool size (default:10) - Minimum number of idle ready connections in the
Hikaripool (default:0) - Maximum time to wait for a connection from the
Hikaripool (default:10s) - Maximum time for
Hikariconnection validation (default:5s) - Maximum idle time for a
Hikariconnection (default:10m) - Maximum lifetime of a
Hikariconnection (default:15m) - Time after which a busy connection is considered a possible leak (default:
0s) - Maximum time to wait for connection validation at service startup. When it is not set, the service starts without touching the database (optional, no default)
- Whether to enable the readiness probe for the database connection (default:
false) - Additional
JDBCconnection properties passed toHikaridataSourceProperties(default:{}) - Enables module logging (default:
false) - Enables module metrics (default:
false) - Whether to report the
Hikaripool's own metrics, such as pool size and connection acquire time (default:true) - Configures SLO for metrics in milliseconds (default:
io.koraframework.telemetry.common.TelemetryConfig.MetricsConfig#DEFAULT_SLO) - Configures metric tags (default:
{}) - Enables module tracing (default:
true) - Configures tracing attributes (default:
{})
Pool customization¶
Configuration keys cover the Hikari settings that most services need.
If you need a setting that is not exposed by JdbcDatabaseConfig, provide a Configurer<HikariConfig> component.
Kora calls it with the HikariConfig it has already built from the configuration, right before the pool is created,
so your component only adjusts what it cares about.
@Component
public final class HikariConfigurer implements Configurer<HikariConfig> {
@Override
public HikariConfig configure(HikariConfig config) {
config.setTransactionIsolation("TRANSACTION_REPEATABLE_READ"); //(1)!
return config;
}
}
- Default isolation level of every connection in the pool, see also Isolation level
@Component
class HikariConfigurer : Configurer<HikariConfig> {
override fun configure(config: HikariConfig): HikariConfig {
config.transactionIsolation = "TRANSACTION_REPEATABLE_READ" //(1)!
return config
}
}
- Default isolation level of every connection in the pool, see also Isolation level
Additional data sources¶
A service can talk to several databases at once. JdbcDatabaseModule declares the default data source
over the jdbc config section, and every extra data source is declared as a tagged factory module
with its own config section:
@KoraApp
public interface Application extends JdbcDatabaseModule {
final class Analytics { } //(1)!
@Tag(Analytics.class)
@FactoryModule //(2)!
default JdbcDatabaseFactoryModule analyticsDatabase() {
return new JdbcDatabaseFactoryModule("analyticsJdbc"); //(3)!
}
}
- Marker class that identifies this data source in the application graph
- The tag of the factory module is propagated to every component it creates, so the analytics pool is registered as
@Tag(Analytics.class) JdbcDataSource - Config section this data source reads, described by the same
JdbcDatabaseConfig
@KoraApp
interface Application : JdbcDatabaseModule {
class Analytics //(1)!
@Tag(Analytics::class)
@FactoryModule //(2)!
fun analyticsDatabase(): JdbcDatabaseFactoryModule {
return JdbcDatabaseFactoryModule("analyticsJdbc") //(3)!
}
}
- Marker class that identifies this data source in the application graph
- The tag of the factory module is propagated to every component it creates, so the analytics pool is registered as
@Tag(Analytics::class) JdbcDataSource - Config section this data source reads, described by the same
JdbcDatabaseConfig
A repository points at a non-default data source with the executorTag attribute of @Repository;
repositories without the attribute use the default jdbc data source:
Usage¶
A JDBC repository is declared as an interface annotated with @Repository and must extend JdbcRepository.
Each method annotated with @Query contains a regular SQL query. Method parameters are bound by name with the
:parameter syntax, and object fields can be referenced as :entity.field.
Entities are described with the common database annotations and marked with @EntityJdbc
so that Kora generates the view mapper at compile time (see View):
@Repository
public interface EntityRepository extends JdbcRepository {
@EntityJdbc
@Table("entities")
record Entity(@Id long id,
String name,
@Nullable String description) {}
@Query("SELECT %{return#selects} FROM %{return#table} WHERE id = :id") //(1)!
@Nullable
Entity findById(long id);
@Query("SELECT id, name, description FROM entities") //(2)!
List<Entity> findAll();
@Query("INSERT INTO %{entity#inserts}") //(3)!
UpdateCount insert(Entity entity);
}
- Uses macros
%{return#selects}and%{return#table}. Expands to query: Method uses macros forSELECT. Details: Common Database Rules — Macros - Fields listed manually without macros — this is valid but requires maintenance when the view changes.
- Uses macro
%{entity#inserts}. Expands to query: Method uses macros forINSERT. Details: Common Database Rules — Macros
@Repository
interface EntityRepository : JdbcRepository {
@EntityJdbc
@Table("entities")
data class Entity(
@field:Id val id: Long,
val name: String,
val description: String?
)
@Query("SELECT %{return#selects} FROM %{return#table} WHERE id = :id") //(1)!
fun findById(id: Long): Entity?
@Query("INSERT INTO %{entity#inserts}") //(3)!
fun insert(entity: Entity): UpdateCount
}
- Uses macros
%{return#selects}and%{return#table}. Expands to query: Method uses macros forSELECT. Details: Common Database Rules — Macros - Uses macro
%{entity#inserts}. Expands to query: Method uses macros forINSERT. Details: Common Database Rules — Macros
SQL remains under the developer's control: you can use database-specific features, while Kora only handles safe
parameter binding, query execution, and result mapping.
Common rules for entities, @Table, @Column, @Id, @Embedded, @Batch, and macros are described in
Common database rules.
Parameter binding: Kora performs typed injection of arguments into the SQL query at compile time.
Query parameters (e.g., :id, :entity.name) are replaced in the generated code with corresponding PreparedStatement calls.
For example, for a String name parameter, something like statement.setString(1, name) will be generated, where the index corresponds to the parameter order in the query.
This ensures security (protection against SQL injection) and performance (using prepared statements).
Nullability: in Java, a nullable result is marked with the JSpecify annotation org.jspecify.annotations.Nullable,
in Kotlin it is expressed by the T? return type.
When a method is not marked as nullable and the mapper still produces null,
the generated code fails with NullPointerException and the message Result mapping is expected non-null, but was null.
Errors: a java.sql.SQLException raised by the driver never leaves a repository method as a checked exception.
It is wrapped into the unchecked UncheckedSqlException from io.koraframework.database.jdbc.exception,
and the original exception is available through getCause().
Mapping¶
You can override the mapping of different parts of an entity, a query result, and query parameters.
For this, Kora provides several mapper interfaces.
A mapper class referenced from @Mapping is created by Kora itself when it is final (Kotlin classes are final unless declared open)
and has a public constructor without arguments.
Such a mapper must not be annotated with @Component.
A mapper that has constructor dependencies is resolved from the application graph instead,
so it must be declared as a @Component or provided by a @Module.
Result¶
Use JdbcResultSetMapper<T> when you need to manually map the whole ResultSet.
This mapper receives the whole query result and decides how many rows to read and what to return.
final class ResultMapper implements JdbcResultSetMapper<UUID> {
@Override
public UUID apply(ResultSet rs) throws SQLException {
// mapping code
}
}
@Repository
public interface EntityRepository extends JdbcRepository {
@Mapping(ResultMapper.class)
@Query("SELECT id FROM entities")
List<UUID> getIds();
}
JdbcResultSetMapper also exposes static helpers singleResultSetMapper, listResultSetMapper,
and optionalResultSetMapper that build a full-ResultSet mapper from a JdbcRowMapper<T>.
View¶
Kora generates result mappers for your own types at compile time, and it needs the @EntityJdbc annotation
from io.koraframework.database.jdbc.annotation to know which types to generate them for.
Annotate every type that is returned from a repository method, including the identifier type of an
@Id method:
Without the annotation Kora has nothing to build a JdbcResultSetMapper / JdbcRowMapper from,
and the application graph fails to build with an unresolved mapper dependency —
unless you provide the mapper yourself with @Mapping.
Types that only appear as @Embedded fields of another entity
are flattened into the enclosing view and do not need their own annotation,
but annotating them as well keeps the mapping predictable when they later become a result type of their own.
Built-in types such as long or String do not need anything: the module provides
row, column, and parameter mappers for them out of the box, see Supported Types.
Row¶
Use JdbcRowMapper<T> when you need to manually map one row.
Keep in mind that in JDBC, column indexes in ResultSet start from 1:
final class RowMapper implements JdbcRowMapper<UUID> {
@Override
public UUID apply(ResultSet rs) throws SQLException {
return UUID.fromString(rs.getString(1));
}
}
@Repository
public interface EntityRepository extends JdbcRepository {
@Mapping(RowMapper.class)
@Query("SELECT id FROM entities")
List<UUID> findAll();
}
A JdbcRowMapper<T> is enough for any result shape: Kora wraps it with singleResultSetMapper,
optionalResultSetMapper, or listResultSetMapper depending on whether the method returns
T, Optional<T>, or List<T>.
Column¶
Use JdbcResultColumnMapper<T> when you need to manually map a single column value:
public final class ColumnMapper implements JdbcResultColumnMapper<UUID> {
@Override
public UUID apply(ResultSet row, int index) throws SQLException {
return UUID.fromString(row.getString(index));
}
}
@EntityJdbc
@Table("entities")
public record Entity(@Mapping(ColumnMapper.class) @Id UUID id, String name) { }
@Repository
public interface EntityRepository extends JdbcRepository {
@Query("SELECT id, name FROM entities")
List<Entity> findAll();
}
class ColumnMapper : JdbcResultColumnMapper<UUID> {
override fun apply(row: ResultSet, index: Int): UUID {
return UUID.fromString(row.getString(index))
}
}
@EntityJdbc
@Table("entities")
data class Entity(
@field:Id @Mapping(ColumnMapper::class) val id: UUID,
val name: String
)
@Repository
interface EntityRepository : JdbcRepository {
@Query("SELECT id, name FROM entities")
fun findAll(): List<Entity>
}
Parameter¶
Use JdbcParameterColumnMapper<T> when you need to manually map a query parameter value.
The contract declares the value as nullable, so the mapper is also responsible for binding NULL:
public final class ParameterMapper implements JdbcParameterColumnMapper<UUID> {
@Override
public void set(PreparedStatement stmt, int index, @Nullable UUID value) throws SQLException {
if (value == null) {
stmt.setNull(index, Types.VARCHAR);
} else {
stmt.setString(index, value.toString());
}
}
}
@Repository
public interface EntityRepository extends JdbcRepository {
@Query("SELECT id, name FROM entities WHERE id = :id")
List<Entity> findById(@Mapping(ParameterMapper.class) UUID id);
}
class ParameterMapper : JdbcParameterColumnMapper<UUID> {
override fun set(stmt: PreparedStatement, index: Int, value: UUID?) {
if (value == null) {
stmt.setNull(index, Types.VARCHAR)
} else {
stmt.setString(index, value.toString())
}
}
}
@Repository
interface EntityRepository : JdbcRepository {
@Query("SELECT id, name FROM entities WHERE id = :id")
fun findById(@Mapping(ParameterMapper::class) id: UUID): List<Entity>
}
Kotlin
Mapper contracts are annotated with JSpecify, so the mapped value is nullable in the contract.
A Kotlin override must declare it as T? — override fun set(stmt: PreparedStatement, index: Int, value: UUID?).
Declaring the parameter as non-null does not compile.
In Java, org.jspecify.annotations.Nullable is a type-use annotation, so its position matters.
For a nested type, the annotation goes before the nested part of the name:
void set(PreparedStatement stmt, int index, Entity.@Nullable Status value).
Supported Types¶
List of supported types for arguments/return values out of the box
These types are selected because they are supported by most popular databases.
Kora provides built-in row, column, and parameter mappers for them.
- void
- boolean / Boolean
- byte / Byte
- short / Short
- int / Integer
- long / Long
- double / Double
- float / Float
- byte[]
- String
- BigDecimal
- UUID
- LocalDate
- LocalTime
- LocalDateTime
- OffsetTime
- OffsetDateTime
View fields without an explicit @Mapping are read and written by inlined ResultSet / PreparedStatement calls
for boolean / Boolean, short / Short, int / Integer, long / Long, double / Double,
float / Float, byte[], String, BigDecimal, LocalDate, and LocalDateTime.
For other types, the built-in JdbcResultColumnMapper<T> / JdbcParameterColumnMapper<T> mappers are used,
or you declare custom mappers.
Select by List¶
Sometimes you need to select rows by a list of values.
At the JDBC level, such parameters must be prepared separately by the driver because the list length is not known in advance.
Kora tries to perform mappings at compile time and does not rewrite SQL at runtime, so such parameters require a custom mapper.
Kora does not provide this parameter mapping out of the box, but it is easy to add yourself.
The example below shows Postgres through a JDBC Array:
public final class ListOfStringJdbcParameterMapper implements JdbcParameterColumnMapper<List<String>> {
@Override
public void set(PreparedStatement stmt, int index, @Nullable List<String> value) throws SQLException {
if (value == null) {
stmt.setNull(index, Types.ARRAY);
return;
}
String[] typedArray = value.toArray(String[]::new);
Array sqlArray = stmt.getConnection().createArrayOf("VARCHAR", typedArray);
stmt.setArray(index, sqlArray);
}
}
@Repository
public interface EntityRepository extends JdbcRepository {
@Query("SELECT id, name FROM entities WHERE id = ANY(:ids)")
List<Entity> findAllByIds(@Mapping(ListOfStringJdbcParameterMapper.class) List<String> ids);
}
class ListOfStringJdbcParameterMapper : JdbcParameterColumnMapper<List<String>> {
override fun set(stmt: PreparedStatement, index: Int, value: List<String>?) {
if (value == null) {
stmt.setNull(index, Types.ARRAY)
return
}
stmt.setArray(index, stmt.connection.createArrayOf("VARCHAR", value.toTypedArray()))
}
}
@Repository
interface EntityRepository : JdbcRepository {
@Query("SELECT id, name FROM entities WHERE id = ANY(:ids)")
fun findAllByIds(@Mapping(ListOfStringJdbcParameterMapper::class) ids: List<String>): List<Entity>
}
A manually built query does not need such a mapper: JdbcQuery expands an IN (:name) clause
into one ? placeholder per element with bindIn.
JSON / JSONB¶
A JSON / JSONB column can be mapped to a view field by registering generic
JdbcParameterColumnMapper<T> and JdbcResultColumnMapper<T> as @Module components tagged with @Json.
These mappers bridge the JSON module JsonWriter<T> / JsonReader<T> to a driver-specific value.
The Postgres example below serializes the value into a PGobject of type jsonb when binding a parameter,
handles null via setNull(index, Types.NULL), and reads the column back as a String:
@Module
public interface JdbcJsonbMapperModule {
@Json
default <T> JdbcParameterColumnMapper<T> jdbcJsonParameterColumnMapper(JsonWriter<T> writer) {
return (stmt, index, value) -> {
if (value != null) {
PGobject jsonb = new PGobject();
jsonb.setType("jsonb");
jsonb.setValue(writer.toString(value));
stmt.setObject(index, jsonb);
} else {
stmt.setNull(index, Types.NULL);
}
};
}
@Json
default <T> JdbcResultColumnMapper<T> jdbcJsonResultColumnMapper(JsonReader<T> reader) {
return (row, index) -> {
var value = row.getString(index);
if (value == null) {
return null;
} else {
return reader.read(value);
}
};
}
}
@Module
interface JdbcJsonbMapperModule {
@Json
fun <T> jdbcJsonParameterColumnMapper(writer: JsonWriter<T>): JdbcParameterColumnMapper<T> {
return JdbcParameterColumnMapper { stmt, index, value ->
if (value == null) {
stmt.setNull(index, Types.NULL)
} else {
val jsonb = PGobject()
jsonb.type = "jsonb"
jsonb.value = writer.toString(value)
stmt.setObject(index, jsonb)
}
}
}
@Json
fun <T> jdbcJsonResultColumnMapper(reader: JsonReader<T>): JdbcResultColumnMapper<T> {
return JdbcResultColumnMapper { row, index ->
val value = row.getString(index)
if (value == null) null else reader.read(value)
}
}
}
Annotate the view field with @Json (and @Column if the column name differs), where the field type is itself a @Json type.
The INSERT uses the ::jsonb cast so Postgres accepts the serialized string as JSONB;
findById reads it back through the same @Json-tagged column mapper:
@Repository
public interface JdbcJsonbRepository extends JdbcRepository {
@EntityJdbc
record Entity(UUID id,
@Column("value") @Json JsonbValue value) {
@Json
record JsonbValue(String name, String surname) {}
}
@Query("SELECT * FROM entities_jsonb WHERE id = :id")
@Nullable
Entity findById(UUID id);
@Query("INSERT INTO entities_jsonb(id, value) VALUES (:entity.id, :entity.value::jsonb)")
void insert(Entity entity);
}
@Repository
interface JdbcJsonbRepository : JdbcRepository {
@EntityJdbc
data class Entity(
val id: UUID,
@field:Column("value") @Json val value: JsonbValue
) {
@Json
data class JsonbValue(val name: String, val surname: String)
}
@Query("SELECT * FROM entities_jsonb WHERE id = :id")
fun findById(id: UUID): Entity?
@Query("INSERT INTO entities_jsonb(id, value) VALUES (:entity.id, :entity.value::jsonb)")
fun insert(entity: Entity)
}
The JSON module is required so Kora can generate JsonWriter / JsonReader for the field type,
and the mapper @Module becomes part of the application graph.
Generated Identifier¶
If you need to return primary keys generated by the database,
use the @Id annotation on the method.
Kora then prepares the statement with RETURN_GENERATED_KEYS and maps PreparedStatement#getGeneratedKeys.
This approach also works for @Batch queries.
@Repository
public interface EntityRepository extends JdbcRepository {
@EntityJdbc
record Entity(@Id Long id, String name) {
public Entity(String name) {
this(null, name);
}
}
@Query("INSERT INTO entities(name) VALUES (:entity.name)")
@Id
Long insert(Entity entity);
@Query("INSERT INTO entities(name) VALUES (:entity.name)")
@Id
List<Long> insert(@Batch List<Entity> entities);
}
@Repository
interface EntityRepository : JdbcRepository {
@EntityJdbc
data class Entity(@field:Id val id: Long?, val name: String)
@Query("INSERT INTO entities(name) VALUES (:entity.name)")
@Id
fun insert(entity: Entity): Long
@Query("INSERT INTO entities(name) VALUES (:entity.name)")
@Id
fun insert(@Batch entities: List<Entity>): List<Long>
}
The generated key can also be returned as the view key type rather than a scalar.
When the identifier is a composite key described by an @Embedded record,
the @Id method returns that record, and a @Batch insert returns a List of keys, one per inserted row:
@Repository
public interface EntityRepository extends JdbcRepository {
@EntityJdbc
record Entity(@Id @Embedded EntityId id, @Column("name") String name) {
@EntityJdbc
record EntityId(Long a, Long b) {}
}
@Query("INSERT INTO entities_composite(name) VALUES (:entity.name)")
@Id
Entity.EntityId insertGenerated(Entity entity);
@Query("INSERT INTO entities_composite(name) VALUES (:entity.name)")
@Id
List<Entity.EntityId> insertGenerated(@Batch List<Entity> entities);
}
@Repository
interface EntityRepository : JdbcRepository {
@EntityJdbc
data class Entity(
@field:Id @field:Embedded val id: EntityId?,
@field:Column("name") val name: String
) {
@EntityJdbc
data class EntityId(val a: Long?, val b: Long?)
}
@Id
@Query("INSERT INTO entities_composite(name) VALUES (:entity.name)")
fun insertGenerated(entity: Entity): Entity.EntityId
@Id
@Query("INSERT INTO entities_composite(name) VALUES (:entity.name)")
fun insertGenerated(@Batch entities: List<Entity>): List<Entity.EntityId>
}
Without @Id a @Batch method cannot map arbitrary rows, so its return type is limited
to void / Unit, UpdateCount, int[] / IntArray, or long[] / LongArray.
Manual Query With Telemetry¶
If a query is hard to express as a single static @Query, declare a regular method with an implementation
and build the SQL yourself.
JdbcRepository#executor() returns the JdbcExecutor that the generated @Query methods use,
so a manual query runs on the same connection, joins an active transaction, and is reported to telemetry.
The recommended way to assemble a dynamic query is JdbcQuery.
JdbcQuery.named() builds SQL with :name placeholders and binds values by name,
JdbcQuery.template() builds SQL with positional ? placeholders.
Both builders keep SQL fragments and values apart: sql / sqlIf append SQL,
bind / bindIf supply values, and bindIn / bindInIf expand an IN (:name) clause
into one placeholder per element.
Every named parameter used in SQL must be bound, and every bound parameter must be used in SQL.
@Repository
public interface EntityRepository extends JdbcRepository {
@EntityJdbc
record Entity(long id, String name) {}
default List<Entity> findByFilter(@Nullable String name, List<Long> ids) {
var query = JdbcQuery.named()
.sql("SELECT id, name FROM entities WHERE 1 = 1") //(1)!
.sqlIf(" AND name = :name", name != null) //(2)!
.bindIf("name", name, name != null) //(3)!
.sqlIf(" AND id IN (:ids)", !ids.isEmpty())
.bindInIf("ids", ids, !ids.isEmpty()) //(4)!
.sql(" ORDER BY id")
.build();
return executor().queryList(query, rs -> new Entity(rs.getLong("id"), rs.getString("name"))); //(5)!
}
}
- Static part of the query.
SQLidentifiers and fragments are formatted before they reach the builder, values never are. - Appends the
SQLfragment only when the condition holds. - Binds the value only when the same condition holds.
- Expands into one
?placeholder per element, soid IN (:ids)becomesid IN (?, ?, ?). queryListruns the statement through telemetry and maps every row with aJdbcRowMapper.queryOne,queryOptional,executeUpdate, andexecuteUpdateBatchare available as well.
@Repository
interface EntityRepository : JdbcRepository {
@EntityJdbc
data class Entity(val id: Long, val name: String)
fun findByFilter(name: String?, ids: List<Long>): List<Entity> {
val query = JdbcQuery.named()
.sql("SELECT id, name FROM entities WHERE 1 = 1") //(1)!
.sqlIf(" AND name = :name", name != null) //(2)!
.bindIf("name", name, name != null) //(3)!
.sqlIf(" AND id IN (:ids)", ids.isNotEmpty())
.bindInIf("ids", ids, ids.isNotEmpty()) //(4)!
.sql(" ORDER BY id")
.build()
return executor().queryList(query, JdbcRowMapper<Entity> { rs -> //(5)!
Entity(rs.getLong("id"), rs.getString("name"))
})
}
}
- Static part of the query.
SQLidentifiers and fragments are formatted before they reach the builder, values never are. - Appends the
SQLfragment only when the condition holds. - Binds the value only when the same condition holds.
- Expands into one
?placeholder per element, soid IN (:ids)becomesid IN (?, ?, ?). queryListruns the statement through telemetry and maps every row with aJdbcRowMapper.queryOne,queryOptional,executeUpdate, andexecuteUpdateBatchare available as well.
When you already have the final SQL and want full control over the PreparedStatement,
use JdbcExecutor#query with a QueryContext.
QueryContext carries the query identifier reported to telemetry — a stable name such as Repository.method is convenient —
and the final SQL.
Values must be passed through PreparedStatement parameters, not concatenated into the query string:
@Repository
public interface EntityRepository extends JdbcRepository {
default int countByPrefix(String prefix) {
var queryContext = new QueryContext(
"EntityRepository.countByPrefix", //(1)!
"SELECT count(*) FROM entities WHERE name LIKE ?");
return executor().query(queryContext, statement -> { //(2)!
statement.setString(1, prefix + "%");
try (var rs = statement.executeQuery()) {
return rs.next() ? rs.getInt(1) : 0;
}
});
}
}
- Query identifier reported to telemetry
- Creates the
PreparedStatement, wraps execution in telemetry, and reuses the current connection
@Repository
interface EntityRepository : JdbcRepository {
fun countByPrefix(prefix: String): Int {
val queryContext = QueryContext(
"EntityRepository.countByPrefix", //(1)!
"SELECT count(*) FROM entities WHERE name LIKE ?"
)
return executor().query(queryContext) { statement -> //(2)!
statement.setString(1, "$prefix%")
statement.executeQuery().use { rs -> if (rs.next()) rs.getInt(1) else 0 }
}
}
}
- Query identifier reported to telemetry
- Creates the
PreparedStatement, wraps execution in telemetry, and reuses the current connection
Statement options¶
JdbcQuery can also configure the PreparedStatement it creates through opts:
fetchSize, maxRows, queryTimeoutSeconds, resultSetType, resultSetConcurrency, resultSetHoldability,
generatedKeys, and returnGeneratedKeys(columns...).
var query = JdbcQuery.template()
.sql("SELECT id, name FROM entities WHERE name LIKE ?")
.bind("prefix%")
.opts(o -> o.fetchSize(500).queryTimeoutSeconds(5)) //(1)!
.build();
var entities = executor().queryList(query, rs -> new Entity(rs.getLong("id"), rs.getString("name")));
fetchSizecontrols how many rows the driver reads at once,queryTimeoutSecondslimits statement execution time
val query = JdbcQuery.template()
.sql("SELECT id, name FROM entities WHERE name LIKE ?")
.bind("prefix%")
.opts { o -> o.fetchSize(500).queryTimeoutSeconds(5) } //(1)!
.build()
val entities = executor().queryList(query, JdbcRowMapper<Entity> { rs ->
Entity(rs.getLong("id"), rs.getString("name"))
})
fetchSizecontrols how many rows the driver reads at once,queryTimeoutSecondslimits statement execution time
Manual batch¶
The same builders create a batch from a collection: batch() switches the builder into batch mode,
and executeUpdateBatch sends it as a single PreparedStatement#executeBatch.
The result is the total number of affected rows, or UpdateCount(-1) when the driver
reports Statement.SUCCESS_NO_INFO.
default UpdateCount insertAll(List<Entity> entities) {
var batch = JdbcQuery.named()
.sql("INSERT INTO entities(id, name) VALUES (:id, :name)")
.batch()
.bindAll(entities, (row, entity) -> row
.bind("id", entity.id())
.bind("name", entity.name()))
.build();
return executor().executeUpdateBatch(batch);
}
Transactions¶
JdbcRepository exposes the JdbcExecutor contract through executor().
All repository methods called inside the transaction callback are executed in that same transaction,
because they reuse the connection bound to the current scope.
Use inTx to execute queries transactionally.
If there is already an active transaction in the current scope, a nested inTx call uses the same connection and does not open
a new transaction.
A transactional sequence of operations can stay inside the repository itself as a regular method with an implementation.
This is useful when several @Query methods or a complex manual SQL query should stay next to the rest of the repository queries,
without moving technical database work to a service layer.
Inside such a method, you can use both repository @Query methods and manual queries.
@Repository
public interface EntityRepository extends JdbcRepository {
@Query("INSERT INTO entities(id, name) VALUES (:entity.id, :entity.name)")
UpdateCount insert(Entity entity);
@Query("UPDATE entities SET name = :name WHERE id = :id")
UpdateCount updateName(long id, String name);
default List<Entity> saveAll(Entity one, Entity two) {
return executor().inTx(() -> {
insert(one); //(1)!
updateName(two.id(), two.name()); //(2)!
return List.of(one, two);
});
}
}
- Executed within the transaction, or rolled back if the whole lambda throws an exception
- Executed within the transaction, or rolled back if the whole lambda throws an exception
@Repository
interface EntityRepository : JdbcRepository {
@Query("INSERT INTO entities(id, name) VALUES (:entity.id, :entity.name)")
fun insert(entity: Entity): UpdateCount
@Query("UPDATE entities SET name = :name WHERE id = :id")
fun updateName(id: Long, name: String): UpdateCount
fun saveAll(one: Entity, two: Entity): List<Entity> {
return executor().inTx(JdbcExecutor.SqlSupplier { //(1)!
insert(one) //(2)!
updateName(two.id, two.name) //(3)!
listOf(one, two)
})
}
}
- Explicit SAM constructor, see the warning below
- Executed within the transaction, or rolled back if the whole lambda throws an exception
- Executed within the transaction, or rolled back if the whole lambda throws an exception
Kotlin
inTx has several overloads that all accept a single functional argument, so a bare Kotlin lambda cannot be resolved.
Pass an explicit SAM constructor instead: JdbcExecutor.SqlSupplier { … } when the block returns a value and
JdbcExecutor.SqlRunnable { … } when it does not.
Without it the compiler reports Overload resolution ambiguity or Cannot infer type for type parameter T,
neither of which points at the transaction.
The transaction is considered successfully committed after the method completes if it did not throw an exception. If the method throws an exception, all database changes made within the transaction are rolled back and the exception is rethrown.
The connection is bound to the executing scope, so work handed off to an unrelated thread does not join the current transaction — it acquires its own connection instead.
Isolation level¶
By default a transaction runs with the isolation level configured for the driver, the database,
or the connection pool — for most databases that is READ_COMMITTED.
To request another level for one transaction, pass a JdbcExecutor.TxIsolation value as the first argument of inTx:
READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, or SERIALIZABLE.
The previous level of the connection is restored after the transaction completes.
The isolation level is applied only when inTx actually opens a transaction.
A nested inTx inside an already open transaction reuses it and ignores the argument.
Multi-repository Transactions¶
When your application uses multiple repositories, you can combine their operations in a single transaction.
All repositories that extend JdbcRepository share the same JdbcExecutor (unless a separate @Tag for a different database is specified).
JdbcExecutor stores the connection in the Context of the current thread.
When entering inTx, the connection is saved to the context.
Any @Query method of any repository called inside inTx checks the context and uses the existing connection instead of creating a new one.
Thus, all operations in the lambda execute on the same connection and in the same transaction.
If any of the calls throws an exception — all changes are rolled back.
@Repository
public interface OrderRepository extends JdbcRepository {
@Query("INSERT INTO orders(customer_id, total) VALUES (:customerId, :total)")
UpdateCount create(long customerId, long total);
}
@Repository
public interface StockRepository extends JdbcRepository {
@Query("UPDATE stock SET quantity = quantity - :quantity WHERE product_id = :productId")
UpdateCount reserve(long productId, long quantity);
}
@Component
public class OrderService {
private final OrderRepository orderRepo;
private final StockRepository stockRepo;
public OrderService(OrderRepository orderRepo, StockRepository stockRepo) {
this.orderRepo = orderRepo;
this.stockRepo = stockRepo;
}
public void placeOrder(long customerId, long productId, long total, long quantity) {
orderRepo.getJdbcExecutor().inTx(() -> {
stockRepo.reserve(productId, quantity);
orderRepo.create(customerId, total);
});
}
}
@Repository
interface OrderRepository : JdbcRepository {
@Query("INSERT INTO orders(customer_id, total) VALUES (:customerId, :total)")
fun create(customerId: Long, total: Long): UpdateCount
}
@Repository
interface StockRepository : JdbcRepository {
@Query("UPDATE stock SET quantity = quantity - :quantity WHERE product_id = :productId")
fun reserve(productId: Long, quantity: Long): UpdateCount
}
@Component
class OrderService(
private val orderRepo: OrderRepository,
private val stockRepo: StockRepository
) {
fun placeOrder(customerId: Long, productId: Long, total: Long, quantity: Long) {
orderRepo.JdbcExecutor.inTx {
stockRepo.reserve(productId, quantity)
orderRepo.create(customerId, total)
}
}
}
Limitation: If repositories are connected to different databases (via @Tag(OtherDatabase.class)), they use different JdbcExecutor instances — the transaction does NOT propagate between them.
Manual Connection Management¶
If a query needs more complex logic or queries outside a repository, you can use java.sql.Connection.
The withConnection method executes code with a connection, but does not open a transaction by itself.
withConnection works as follows:
- if the current scope already contains a
ConnectionContext, the method passes the current connection to the lambda; - if the current scope does not contain a connection, the method takes a new connection from the pool, binds it to a
ConnectionContextfor the duration of the lambda, and closes it after completion; - nested calls to
withConnection, manual queries, and repository methods inside this lambda use the same current connection; - a
java.sql.SQLExceptionis wrapped intoUncheckedSqlException.
withContext is the same thing but hands over the ConnectionContext instead of the raw Connection,
which is what you need to register post-commit and post-rollback actions.
currentConnection and currentContext return the connection bound to the current scope or null when there is none,
and acquireConnection takes a brand new connection from the pool that the caller must close.
@Component
public final class SomeService {
private final EntityRepository repository;
public SomeService(EntityRepository repository) {
this.repository = repository;
}
public List<Entity> saveAll(Entity one, Entity two) {
return repository.executor().withConnection(connection -> {
// do some work with java.sql.Connection
return List.of(one, two);
});
}
}
@Component
class SomeService(private val repository: EntityRepository) {
fun saveAll(one: Entity, two: Entity): List<Entity> {
return repository.executor().withConnection(JdbcExecutor.SqlFunction<Connection, List<Entity>> { connection ->
// do some work with java.sql.Connection
listOf(one, two)
})
}
}
The inTx method opens a transaction and is built on top of withContext.
If the current connection is already in an active transaction, meaning autoCommit = false, nested inTx uses the same transaction.
If there is no active transaction, inTx disables autoCommit, executes the lambda, and then calls commit on success or rollback on exception.
A @Query method can also accept a java.sql.Connection argument.
The generated code prepares the statement on exactly that connection instead of the current one,
which is useful when the connection comes from outside the repository:
Post-Commit Actions¶
If you need to perform actions after a transaction is successfully committed, register them on the ConnectionContext
with afterCommit.
The action receives the connection, is executed after commit, and only if the transaction completed successfully.
Such actions can be added only inside an active transaction — otherwise afterCommit throws IllegalStateException.
@Component
public final class SomeService {
private final EntityRepository repository;
public SomeService(EntityRepository repository) {
this.repository = repository;
}
public List<Entity> saveAll(Entity one, Entity two) {
return repository.executor().inTx(context -> { //(1)!
context.afterCommit(connection -> {
// do some work after commit
});
repository.insert(one);
repository.insert(two);
return List.of(one, two);
});
}
}
- The single-argument
inTxoverload hands over theConnectionContextof the current transaction
@Component
class SomeService(private val repository: EntityRepository) {
fun saveAll(one: Entity, two: Entity): List<Entity> {
return repository.executor().inTx(JdbcExecutor.SqlFunction<ConnectionContext, List<Entity>> { context -> //(1)!
context.afterCommit { connection ->
// do some work after commit
}
repository.insert(one)
repository.insert(two)
listOf(one, two)
})
}
}
- The single-argument
inTxoverload hands over theConnectionContextof the current transaction
An exception thrown by a post-commit action is propagated to the caller, but the transaction stays committed.
Post-Rollback Actions¶
If you need to perform actions after a transaction is rolled back, register them with afterRollback.
The action receives the connection and the exception that caused the transaction to roll back.
Such actions can be added only inside an active transaction — otherwise afterRollback throws IllegalStateException.
@Component
public final class SomeService {
private final EntityRepository repository;
public SomeService(EntityRepository repository) {
this.repository = repository;
}
public List<Entity> saveAll(Entity one, Entity two) {
return repository.executor().inTx(context -> {
context.afterRollback((connection, e) -> {
// do some work after rollback
});
repository.insert(one);
repository.insert(two);
return List.of(one, two);
});
}
}
@Component
class SomeService(private val repository: EntityRepository) {
fun saveAll(one: Entity, two: Entity): List<Entity> {
return repository.executor().inTx(JdbcExecutor.SqlFunction<ConnectionContext, List<Entity>> { context ->
context.afterRollback { connection, e ->
// do some work after rollback
}
repository.insert(one)
repository.insert(two)
listOf(one, two)
})
}
}
An exception thrown by a post-rollback action does not replace the original failure: it is attached to it as a suppressed exception.
Signatures¶
JDBC repository contracts are synchronous — a method blocks the calling thread until the database answers.
Kora server transports dispatch request handling onto virtual threads, so a blocking JDBC call does not hold a platform thread.
There are no asynchronous or reactive repository signatures.
Available repository method signatures out of the box:
T means the return value type.
T myMethod()— the result must not benull@Nullable T myMethod()Optional<T> myMethod()List<T> myMethod()void myMethod()UpdateCount myMethod()— number of affected rowsint[] myMethod(@Batch List<T> values)/long[] myMethod(@Batch List<T> values)— per-row results of a batch query
T means the return value type.
fun myMethod(): T— the result must not benullfun myMethod(): T?fun myMethod(): List<T>fun myMethod()— returnsUnitfun myMethod(): UpdateCount— number of affected rowsfun myMethod(@Batch values: List<T>): IntArray/fun myMethod(@Batch values: List<T>): LongArray— per-row results of a batch query
To bind a repository to a data source other than the default one, use the executorTag attribute
of @Repository, see Additional data sources.
Telemetry¶
Logging, metrics, and tracing are configured via the telemetry block in the configuration and described in the Metrics Reference section.
The Hikari pool reports its own metrics as long as telemetry.metrics.driverMetrics is enabled.
To completely override telemetry, you can provide custom SPI factories; see the Common Database Documentation for details.