Aplikasi backend nyata hampir selalu memerlukan persistensi data. Ktor tidak memasok akses database bawaan — kita bebas memilih JDBC, Exposed, atau R2DBC. Pola yang paling umum adalah connection pool HikariCP + JDBC + repository pattern. Pada chapter ini kita akan memahami bagaimana pool mengelola koneksi, bagaimana repository membungkus SQL, dan menjalankan program yang memakai SQLite in-memory sebagai contoh nyata.
Connection Pool HikariCP #
Setiap koneksi database relatif mahal dibuka — melibatkan TCP handshake, autentikasi, dan negosiasi parameter. Connection pool mengelola sejumlah koneksi yang tetap terbuka dan dipakai ulang antar permintaan. HikariCP adalah pool default yang cepat dan stabil di ekosistem JVM.
val ds = HikariDataSource().apply {
jdbcUrl = "jdbc:postgresql://localhost:5432/myapp"
username = "budi"
password = "rahasia123"
maximumPoolSize = 10
connectionTimeout = 30_000
}Pool mengatur lifecycle: saat handler membutuhkan koneksi, ia meminjam dari pool. Setelah selesai, koneksi dikembalikan ke pool (tidak ditutup). Hal ini mengurangi latensi dan membatasi jumlah koneksi maksimum ke database.
Repository Pattern #
Repository adalah class yang membungkus akses data untuk satu entitas. Repository menyembunyikan SQL dari service layer, sehingga service memanggil method seperti findById(id) atau save(user) tanpa tahu tabel atau query yang dipakai. Pemisahan ini membuat service lebih mudah diuji (mock repository) dan query terkonsolidasi di satu tempat.
class UserRepository(private val ds: DataSource) {
fun findById(id: Int): User? {
ds.connection.use { conn ->
conn.prepareStatement("SELECT id,nama,email FROM users WHERE id=?").use { ps ->
ps.setInt(1, id)
ps.executeQuery().use { rs ->
return if (rs.next()) User(rs.getInt(1), rs.getString(2), rs.getString(3)) else null
}
}
}
}
}Method use { } memastikan Connection, PreparedStatement, dan ResultSet ditutup otomatis — bahkan bila exception terjadi.
Demo Database Integration #
Karena PostgreSQL membutuhkan server eksternal, kita memakai SQLite in-memory yang berjalan di proses yang sama. SQLite mendukung syntax SQL standar, sehingga contoh query tetap realistis. File demo memerlukan file sqlite-jdbc.jar yang tersedia di sandbox — script kotlin_insert_output.py akan otomatis menyertakannya via classpath.
// ktor_database_integration.kt
// Mendemonstrasikan integrasi Ktor dengan database: HikariCP untuk pooling
// dan Exposed DSL untuk query. Pada kode produksi, kita memakai
// org.jetbrains.exposed dan com.zaxxer.hikari.HikariConfig.
// Di sini kita memakai JDBC asli + SQLite agar bisa dijalankan tanpa deps.
import java.sql.Connection
import java.sql.DriverManager
// Mock HikariCP: connection pool sederhana
class ConnectionPool(private val url: String, private val maxPool: Int = 10) {
private val pool = ArrayDeque<Connection>()
private var created = 0
// Koneksi keeper: menjaga database in-memory tetap hidup
private val keeper: Connection = DriverManager.getConnection(url)
@Synchronized
fun borrow(): Connection {
if (pool.isNotEmpty()) return pool.removeFirst()
if (created < maxPool) {
created++
return DriverManager.getConnection(url)
}
throw IllegalStateException("Pool habis: $maxPool koneksi aktif")
}
@Synchronized
fun release(conn: Connection) {
pool.addLast(conn)
}
}
// Repository pattern: satu kelas per entitas, bungkus SQL
data class User(val id: Int, val nama: String, val email: String)
class UserRepository(private val pool: ConnectionPool) {
fun createTable() {
pool.borrow().use { conn ->
conn.createStatement().use { stmt ->
stmt.execute(
"""CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
)"""
)
}
}
}
fun insert(nama: String, email: String): Int {
pool.borrow().use { conn ->
conn.prepareStatement("INSERT INTO users(nama,email) VALUES(?,?)").use { ps ->
ps.setString(1, nama)
ps.setString(2, email)
ps.executeUpdate()
}
return conn.createStatement().executeQuery("SELECT last_insert_rowid()").use { rs ->
if (rs.next()) rs.getInt(1) else -1
}
}
}
fun findById(id: Int): User? {
pool.borrow().use { conn ->
conn.prepareStatement("SELECT id,nama,email FROM users WHERE id=?").use { ps ->
ps.setInt(1, id)
ps.executeQuery().use { rs ->
if (rs.next()) {
return User(rs.getInt("id"), rs.getString("nama"), rs.getString("email"))
}
}
}
}
return null
}
fun findAll(): List<User> {
val result = mutableListOf<User>()
pool.borrow().use { conn ->
conn.createStatement().use { stmt ->
stmt.executeQuery("SELECT id,nama,email FROM users ORDER BY id").use { rs ->
while (rs.next()) {
result.add(User(rs.getInt("id"), rs.getString("nama"), rs.getString("email")))
}
}
}
}
return result
}
}
fun main() {
Class.forName("org.sqlite.JDBC")
// cache=shared agar database in-memory berbagi antar koneksi dalam satu proses
val pool = ConnectionPool("jdbc:sqlite:file::memory:?cache=shared")
val repo = UserRepository(pool)
println("=== Create table ===")
repo.createTable()
println("Tabel users dibuat")
println("\n=== Insert 3 user ===")
val budi = repo.insert("Budi", "[email protected]")
val siti = repo.insert("Siti", "[email protected]")
val andi = repo.insert("Andi", "[email protected]")
println("Budi id=$budi, Siti id=$siti, Andi id=$andi")
println("\n=== FindAll ===")
repo.findAll().forEach { println(" $it") }
println("\n=== findById(2) ===")
val found = repo.findById(2)
println(" $found")
println("\n=== findById(999) ===")
val missing = repo.findById(999)
println(" $missing")
println("\n=== Pool stats ===")
println("Pool aktif: ${pool.borrow().let { pool.release(it); "OK" }}")
}Output yang Dihasilkan:
=== Create table ===
Tabel users dibuat
=== Insert 3 user ===
Budi id=1, Siti id=2, Andi id=3
=== FindAll ===
User(id=1, nama=Budi, [email protected])
User(id=2, nama=Siti, [email protected])
User(id=3, nama=Andi, [email protected])
=== findById(2) ===
User(id=2, nama=Siti, [email protected])
=== findById(999) ===
null
=== Pool stats ===
Pool aktif: OKMari kita bedah bagian penting kode di atas:
class ConnectionPool(url, maxPool): pool sederhana denganArrayDeque<Connection>sebagai antrian koneksi idle. Methodborrowmengambil dari pool atau membuat baru bila belum mencapaimaxPool. Methodreleasemengembalikan koneksi ke pool.@Synchronized fun borrow(): annotation@Synchronizedmemastikan hanya satu thread yang mengakses pool pada satu waktu. Tanpa ini, dua thread dapat mengambil koneksi yang sama dariArrayDeque.private val keeper: Connection = DriverManager.getConnection(url): koneksi yang tetap terbuka selama proses hidup. SQLite in-memory menghapus data ketika semua koneksi ditutup — keeper mencegah hal tersebut.pool.borrow().use { conn -> ... }: blokusememastikan koneksi dikembalikan ke pool viarelease. Pada produksi, HikariCP mengelola ini otomatis — kita hanya memanggilclose()yang sebenarnya mengembalikan ke pool.conn.prepareStatement("... WHERE id=?"): parameter binding dengan?mencegah SQL injection. Nilaiiddi-set viaps.setInt(1, id)— driver memastikan tipe cocok dan escape karakter dilakukan dengan benar.
Database Migrations #
Saat skema berubah (tabel baru, kolom ditambah), diperlukan migration. Library seperti Flyway atau Liquibase memutar skema dari versi lama ke versi target secara otomatis. Migration disimpan sebagai file SQL berurutan: V1__create_users.sql, V2__add_orders.sql, dst.
Referensi Lanjutan #
- Ktor welcome — halaman pengenalan Ktor untuk merancang integrasi pustaka JDBC dan HikariCP
- Spring Data JPA reference — rujukan alternatif akses database melalui repository abstraction
Selanjutnya #
Setelah database terintegrasi, langkah berikutnya adalah menguji kode. Chapter Ktor Testing akan membahas testApplication { } yang menghidupkan Ktor in-memory untuk pengujian tanpa server nyata.