Kora Database JDBC
Kora sub-skill — obey the kora-v1 meta rules on every task: R0 ensure
.kora-agent/docs+examples are cloned · R1 read this sub-skill before writing code · R2 Kora APIs only — no Spring/Micronaut/Quarkus, no invented annotations or config keys · R3 journal any incorrect Kora usage. Add comments/Javadoc only if asked.
JDBC-based relational database access (PostgreSQL, MySQL, Oracle) with HikariCP. Repositories are @Repository interfaces whose implementations are generated at compile time by the annotation processor — no reflection, no runtime proxies.
Prefer synchronous repository signatures (
Entity,@Nullable Entity,List<Entity>,UpdateCount). The blocking JDBC driver runs on the executor bound toJdbcDatabase; virtual threads or a fixed pool handle blocking efficiently. Reach forCompletionStage/Monoonly when a downstream contract requires it.
Quick Start
1. Dependencies (build.gradle)
dependencies {
koraBom platform("ru.tinkoff.kora:kora-parent:1.2.19")
annotationProcessor "ru.tinkoff.kora:annotation-processors" // mandatory: generates *RepositoryImpl
implementation "ru.tinkoff.kora:database-jdbc"
implementation "ru.tinkoff.kora:config-hocon"
implementation "ru.tinkoff.kora:logging-logback"
implementation "org.postgresql:postgresql:42.7.7" // JDBC driver is required (not bundled)
}
All ru.tinkoff.kora:* artifacts inherit their version from the kora-parent BOM — never pin them individually.
Kotlin: use ksp "ru.tinkoff.kora:symbol-processors" instead of annotationProcessor, and implementation("...") syntax.
2. Plug the module into @KoraApp
@KoraApp
public interface Application extends
HoconConfigModule,
LogbackModule,
JdbcDatabaseModule { }
JdbcDatabaseModule provides JdbcDatabase, its JdbcConnectionFactory, and the JdbcDatabaseConfig reader bound to the db config section.
3. Define an entity with @EntityJdbc
@EntityJdbc
@Table("entities")
public record Entity(
@Id @Column("id") Long id,
@Column("value1") int field1,
@Column("value2") String value2,
@Nullable @Column("value3") String value3) {}
@EntityJdbc (from ru.tinkoff.kora.database.jdbc) makes the processor generate an optimized result converter. @Table/@Column/@Id come from ru.tinkoff.kora.database.common.annotation.
4. Repository with SQL macros
@Repository
public interface EntityRepository extends JdbcRepository {
@Query("SELECT %{return#selects} FROM %{return#table} WHERE id = :id")
@Nullable
Entity findById(Long id);
@Query("SELECT %{return#selects} FROM %{return#table}")
List<Entity> findAll();
@Query("INSERT INTO %{entity#inserts}")
UpdateCount insert(Entity entity);
@Query("UPDATE %{entity#table} SET %{entity#updates} WHERE %{entity#where = @id}")
UpdateCount update(Entity entity);
@Query("DELETE FROM entities WHERE id = :id")
UpdateCount deleteById(Long id);
}
5. Generated identifier (auto-increment / sequence)
When the database assigns the key, annotate the method with @Id and return the id type, or use RETURNING:
@Query("INSERT INTO %{entity#inserts-= @id}")
@Id
Long insert(Entity entity); // returns DB-generated key (works for @Batch too)
@Query("INSERT INTO entities(name) VALUES (:entity.name) RETURNING id")
long insertReturning(Entity entity); // explicit RETURNING projection
6. Transactions in a service
@Component
public final class EntityService {
private final EntityRepository repository;
public EntityService(EntityRepository repository) {
this.repository = repository;
}
public List<Entity> saveAll(Entity one, Entity two) {
return repository.getJdbcConnectionFactory().inTx(() -> {
repository.insert(one);
repository.insert(two);
return List.of(one, two);
});
}
}
Every repository method called inside the inTx() lambda joins the same transaction; an exception rolls the whole block back. JdbcConnectionFactory is reachable via repository.getJdbcConnectionFactory() or by injecting JdbcConnectionFactory directly.
Configuration (application.conf)
db {
jdbcUrl = ${POSTGRES_JDBC_URL} // required, e.g. "jdbc:postgresql://localhost:5432/postgres"
username = ${POSTGRES_USER} // required
password = ${POSTGRES_PASS} // required
poolName = "kora" // required: Hikari pool name
maxPoolSize = 10
minIdle = 0
connectionTimeout = "10s" // durations are strings, not millisecond numbers
idleTimeout = "10m"
maxLifetime = "15m"
telemetry.logging.enabled = false
telemetry.metrics.enabled = true
telemetry.tracing.enabled = true
}
Externalize every credential with ${VAR} / ${?VAR} / ${?VAR:default}. Full key list and YAML form: database-jdbc-config-reference.md.
SQL macros
Macros expand at compile time into SQL the developer could have written by hand. Target a method argument by name or the result via return; separate target and command with #.
| Macro | Expands to | Example result |
|---|---|---|
%{return#selects} |
column list of the return entity | id, value1, value2, value3 |
%{return#table} / %{entity#table} |
@Table name (or snake_case class name) |
entities |
%{entity#inserts} |
full INSERT INTO table(cols) VALUES(:entity...) |
see below |
%{entity#updates} |
col = :entity.field, ... for SET |
value1 = :entity.field1, ... |
%{entity#where = @id} |
WHERE by the @Id field(s) |
id = :entity.id |
%{id#where} |
WHERE for a composite-key argument named id |
a = :id.a AND b = :id.b |
Field enumeration after a command: = keeps only the listed fields, -= excludes them; the @id keyword refers to the @Id field(s).
@Query("INSERT INTO %{entity#inserts-= @id}") // every column except the @Id
@Id Long insert(Entity entity);
@Query("INSERT INTO %{entity#inserts = value1,value2}") // only these columns
UpdateCount insertPartial(Entity entity);
The only macro commands are table, selects, inserts, updates, where. There is no deletes command — write DELETE FROM ... WHERE ... explicitly.
Repository method signatures
T is the return type, List<T>, Void, or UpdateCount.
| Signature | Use |
|---|---|
T find(...) |
row must exist (throws otherwise) |
@Nullable T find(...) |
optional single row — preferred over Optional (no allocation) |
Optional<T> find(...) |
optional single row, Optional flavor |
List<T> find(...) |
zero-or-many (empty list, never null) |
UpdateCount write(...) |
number of affected rows for INSERT/UPDATE/DELETE |
void write(...) |
result not needed |
@Id Long insert(...) |
database-generated identifier |
CompletionStage<T> |
async — requires an Executor bound to JdbcDatabase |
Mono<T> |
reactive — add io.projectreactor:reactor-core and an Executor |
Kotlin adds suspend fun ...(): T and T? / Unit returns.
References
| Topic | File |
|---|---|
@Repository, @Query, macros, batch, inheritance, multiple databases |
repository-pattern-reference.md |
@Table/@Column/@Id/@Embedded, naming strategy, type mapping, generated ids |
entity-mapping-reference.md |
inTx(), post-commit/rollback actions, isolation, locking |
transactions-reference.md |
JdbcResultSetMapper/JdbcRowMapper/JdbcResultColumnMapper/JdbcParameterColumnMapper, enum/array/JSONB |
custom-mappers-reference.md |
| HikariCP config, drivers, telemetry, YAML | database-jdbc-config-reference.md |
| HikariCP pool tuning by workload, leak detection | connection-pool-reference.md |
| Flyway / Liquibase schema migrations | migrations-reference.md |
Assets
| Template | Purpose |
|---|---|
jdbc-entity-single-id.{java,kt}.template |
entity with a single-field id |
jdbc-entity-composite-id.{java,kt}.template |
entity with an @Embedded composite key |
jdbc-crud-single-id-repository.{java,kt}.template |
full CRUD repository (single id) |
jdbc-crud-composite-id-repository.{java,kt}.template |
full CRUD repository (composite id) |
jdbc-crud-abstract-macros-repository.java.template / jdbc-crud-abstract-single-id-macros-repository.kt.template |
reusable generic CRUD base interface |
jdbc-repository-with-enum-mapper.{java,kt}.template |
entity + enum column/parameter mappers |
jdbc-repository-with-array-mapper.java.template |
PostgreSQL array parameter mapper |
jdbc-service-with-transactions.java.template |
@Component service using inTx() |
Generate a starter entity + repository:
python scripts/generate_repository.py --entity User --table users --id-type Long --lang java --package com.example.model
Common pitfalls
| Symptom | Fix |
|---|---|
| Graph build: "required dependency JdbcRepository / Entity not found" | @KoraApp must extend JdbcDatabaseModule; entity needs @EntityJdbc |
*RepositoryImpl not generated |
annotation processor missing (annotation-processors / KSP symbol-processors) |
| Generated id is always null | use @Id on the method (and exclude it via inserts-= @id) or add RETURNING id |
| Macro renders literally / fails | use # not . (%{entity#inserts}); only table/selects/inserts/updates/where exist |
List<T> parameter treated as one value |
annotate with @Batch |
Driver ClassNotFoundException |
add the JDBC driver dependency (it is not bundled) |
Operations outside inTx() not rolled back |
wrap all related calls in one inTx() block |
connectionTimeout = 30000 ignored |
durations are strings: "10s", "10m" |
| @Column on every field looks mandatory | Column names default to snake_lower_case — @Column only for non-standard names (see Custom Mappers Advanced) |
| @Mapping required for custom types | @Component mappers are auto-discovered by type (see Custom Mappers Advanced) |
| @Batch with RETURNING doesn't return rows | Use default method with inTx() for multi-row INSERT…RETURNING (see Custom Mappers Advanced) |
Column Mappers
For advanced mapper patterns (auto-discovery, generic enum mappers, @Batch limitations), see Custom Mappers Advanced.
Version compatibility
| Component | Version |
|---|---|
Kora BOM (kora-parent) |
1.2.19 |
| Java | 21+ |
| Gradle | 9+ |
| PostgreSQL driver | 42.7.x |