MySQL #
MySQL is the most widely used relational database management system in the world — powering millions of web applications, from simple blogs to large-scale e-commerce platforms. In Kotlin, there are several ways to interact with MySQL: JDBC (Java Database Connectivity, a low-level API giving full control), Exposed (an ORM library from JetBrains that’s Kotlin-native), and other ORM frameworks like Hibernate or Spring Data JPA. This article covers JDBC to understand the fundamentals, then Exposed as the idiomatic choice for Kotlin projects, complete with connection pooling, transactions, and repository patterns.
Setting Up a MySQL Connection #
Dependencies #
// build.gradle.kts
dependencies {
// MySQL JDBC Driver
implementation("com.mysql:mysql-connector-j:8.3.0")
// HikariCP — the best connection pool for the JVM
implementation("com.zaxxer:HikariCP:5.1.0")
// Exposed ORM (optional, an alternative to direct JDBC)
implementation("org.jetbrains.exposed:exposed-core:0.49.0")
implementation("org.jetbrains.exposed:exposed-dao:0.49.0")
implementation("org.jetbrains.exposed:exposed-jdbc:0.49.0")
implementation("org.jetbrains.exposed:exposed-java-time:0.49.0")
// Flyway for database migrations
implementation("org.flywaydb:flyway-core:10.10.0")
implementation("org.flywaydb:flyway-mysql:10.10.0")
}
Basic JDBC Connection #
import java.sql.DriverManager
fun main() {
val url = "jdbc:mysql://localhost:3306/myapp?useSSL=false&serverTimezone=Asia/Jakarta&characterEncoding=UTF-8"
val user = "root"
val password = "password"
// Simple connection — open and close manually
DriverManager.getConnection(url, user, password).use { connection ->
println("Connected to MySQL: ${connection.metaData.databaseProductVersion}")
// Execute a simple query
connection.createStatement().use { stmt ->
val result = stmt.executeQuery("SELECT VERSION()")
if (result.next()) {
println("MySQL version: ${result.getString(1)}")
}
}
}
// use{} guarantees the connection is always closed
}
HikariCP — Connection Pooling #
Creating a new database connection for every incoming request is expensive — it needs a TCP handshake, authentication, and resource allocation on the server side. A connection pool manages a set of already-open, ready-to-use connections, greatly increasing application throughput.
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
object DatabasePool {
private val dataSource: HikariDataSource by lazy {
val config = HikariConfig().apply {
jdbcUrl = "jdbc:mysql://localhost:3306/myapp?useSSL=false&serverTimezone=Asia/Jakarta&characterEncoding=UTF-8"
username = System.getenv("DB_USER") ?: "root"
password = System.getenv("DB_PASSWORD") ?: "password"
driverClassName = "com.mysql.cj.jdbc.Driver"
// Pool configuration
minimumIdle = 2 // minimum idle connections
maximumPoolSize = 10 // maximum connections in the pool
idleTimeout = 300_000 // 5 minutes — close connections idle too long
connectionTimeout = 20_000 // 20 seconds — timeout when getting a connection from the pool
maxLifetime = 1_800_000 // 30 minutes — force replacement of old connections
validationTimeout = 5_000
// Pool name for monitoring
poolName = "MyApp-MySQL-Pool"
// Query to validate the connection is still alive
connectionTestQuery = "SELECT 1"
}
HikariDataSource(config)
}
fun <T> use(block: (java.sql.Connection) -> T): T {
return dataSource.connection.use(block)
}
fun close() {
if (!dataSource.isClosed) dataSource.close()
}
}
// Usage
fun main() {
DatabasePool.use { connection ->
println("Got a connection from the pool: ${connection.metaData.databaseProductVersion}")
}
// Add a shutdown hook
Runtime.getRuntime().addShutdownHook(Thread {
DatabasePool.close()
println("Connection pool closed")
})
}
CRUD with JDBC and PreparedStatement #
PreparedStatement is the right way to run queries with parameters — it prevents SQL injection and improves performance because the query is precompiled.
Table Schema #
CREATE TABLE IF NOT EXISTS produk (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(255) NOT NULL,
deskripsi TEXT,
harga DECIMAL(15,2) NOT NULL,
stok INT NOT NULL DEFAULT 0,
kategori VARCHAR(100),
aktif BOOLEAN NOT NULL DEFAULT TRUE,
dibuat_pada TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
diperbarui_pada TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
Model and Repository #
import java.math.BigDecimal
import java.sql.Connection
import java.sql.ResultSet
import java.sql.Timestamp
data class Product(
val id: Int = 0,
val name: String,
val description: String? = null,
val price: BigDecimal,
val stock: Int = 0,
val category: String? = null,
val active: Boolean = true
)
class ProductRepository {
private fun ResultSet.toProduct() = Product(
id = getInt("id"),
name = getString("nama"),
description = getString("deskripsi"),
price = getBigDecimal("harga"),
stock = getInt("stok"),
category = getString("kategori"),
active = getBoolean("aktif")
)
// CREATE
fun save(product: Product): Product {
val sql = """
INSERT INTO produk (nama, deskripsi, harga, stok, kategori, aktif)
VALUES (?, ?, ?, ?, ?, ?)
""".trimIndent()
return DatabasePool.use { connection ->
connection.prepareStatement(sql, java.sql.Statement.RETURN_GENERATED_KEYS).use { stmt ->
stmt.setString(1, product.name)
stmt.setString(2, product.description)
stmt.setBigDecimal(3, product.price)
stmt.setInt(4, product.stock)
stmt.setString(5, product.category)
stmt.setBoolean(6, product.active)
stmt.executeUpdate()
// Get the generated ID
stmt.generatedKeys.use { keys ->
if (keys.next()) {
product.copy(id = keys.getInt(1))
} else {
throw RuntimeException("Failed to get the generated ID")
}
}
}
}
}
// READ — find one
fun findById(id: Int): Product? {
val sql = "SELECT * FROM produk WHERE id = ? AND aktif = TRUE"
return DatabasePool.use { connection ->
connection.prepareStatement(sql).use { stmt ->
stmt.setInt(1, id)
stmt.executeQuery().use { rs ->
if (rs.next()) rs.toProduct() else null
}
}
}
}
// READ — find all with filters
fun findAll(
category: String? = null,
maxPrice: BigDecimal? = null,
activeOnly: Boolean = true,
limit: Int = 20,
offset: Int = 0
): List<Product> {
val conditions = mutableListOf("1=1")
val params = mutableListOf<Any?>()
if (activeOnly) { conditions.add("aktif = TRUE") }
if (category != null) {
conditions.add("kategori = ?")
params.add(category)
}
if (maxPrice != null) {
conditions.add("harga <= ?")
params.add(maxPrice)
}
val sql = """
SELECT * FROM produk
WHERE ${conditions.joinToString(" AND ")}
ORDER BY nama
LIMIT ? OFFSET ?
""".trimIndent()
return DatabasePool.use { connection ->
connection.prepareStatement(sql).use { stmt ->
var index = 1
params.forEach { param ->
when (param) {
is String -> stmt.setString(index++, param)
is BigDecimal -> stmt.setBigDecimal(index++, param)
else -> stmt.setObject(index++, param)
}
}
stmt.setInt(index++, limit)
stmt.setInt(index, offset)
stmt.executeQuery().use { rs ->
buildList {
while (rs.next()) add(rs.toProduct())
}
}
}
}
}
// UPDATE
fun update(product: Product): Boolean {
val sql = """
UPDATE produk
SET nama = ?, deskripsi = ?, harga = ?, stok = ?, kategori = ?
WHERE id = ?
""".trimIndent()
return DatabasePool.use { connection ->
connection.prepareStatement(sql).use { stmt ->
stmt.setString(1, product.name)
stmt.setString(2, product.description)
stmt.setBigDecimal(3, product.price)
stmt.setInt(4, product.stock)
stmt.setString(5, product.category)
stmt.setInt(6, product.id)
stmt.executeUpdate() > 0
}
}
}
// DELETE — soft delete (mark inactive)
fun delete(id: Int): Boolean {
val sql = "UPDATE produk SET aktif = FALSE WHERE id = ?"
return DatabasePool.use { connection ->
connection.prepareStatement(sql).use { stmt ->
stmt.setInt(1, id)
stmt.executeUpdate() > 0
}
}
}
// Batch insert — efficient for lots of data at once
fun saveMany(list: List<Product>): Int {
val sql = "INSERT INTO produk (nama, harga, stok, kategori) VALUES (?, ?, ?, ?)"
return DatabasePool.use { connection ->
connection.prepareStatement(sql).use { stmt ->
list.forEach { product ->
stmt.setString(1, product.name)
stmt.setBigDecimal(2, product.price)
stmt.setInt(3, product.stock)
stmt.setString(4, product.category)
stmt.addBatch()
}
stmt.executeBatch().sum()
}
}
}
}
Transactions #
Transactions ensure a series of database operations all succeed or all fail — no half-done state:
fun transferStock(fromId: Int, toId: Int, quantity: Int) {
DatabasePool.use { connection ->
// Disable auto-commit to start a transaction
connection.autoCommit = false
try {
// Decrease the source product's stock
val sqlDecrease = "UPDATE produk SET stok = stok - ? WHERE id = ? AND stok >= ?"
connection.prepareStatement(sqlDecrease).use { stmt ->
stmt.setInt(1, quantity)
stmt.setInt(2, fromId)
stmt.setInt(3, quantity)
val affected = stmt.executeUpdate()
if (affected == 0) throw IllegalStateException("Insufficient stock or product not found")
}
// Increase the destination product's stock
val sqlIncrease = "UPDATE produk SET stok = stok + ? WHERE id = ?"
connection.prepareStatement(sqlIncrease).use { stmt ->
stmt.setInt(1, quantity)
stmt.setInt(2, toId)
val affected = stmt.executeUpdate()
if (affected == 0) throw IllegalStateException("Destination product not found")
}
// Commit if everything succeeded
connection.commit()
println("Transferred $quantity units from product $fromId to $toId successfully")
} catch (e: Exception) {
// Rollback if anything failed
connection.rollback()
println("Transfer failed, rolled back: ${e.message}")
throw e
} finally {
// Restore auto-commit
connection.autoCommit = true
}
}
}
Exposed ORM — The Idiomatic Kotlin Approach #
Exposed from JetBrains is a Kotlin-native ORM providing two APIs: DSL (type-safe query builder) and DAO (active record pattern). Both can be used together.
Table Definitions #
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.javatime.*
import org.jetbrains.exposed.dao.*
import org.jetbrains.exposed.dao.id.*
import java.time.LocalDateTime
// DSL API: table definitions as objects
object ProductTable : IntIdTable("produk") {
val name = varchar("nama", 255)
val description = text("deskripsi").nullable()
val price = decimal("harga", 15, 2)
val stock = integer("stok").default(0)
val category = varchar("kategori", 100).nullable()
val active = bool("aktif").default(true)
val createdAt = datetime("dibuat_pada").defaultExpression(CurrentDateTime)
}
object UserTable : IntIdTable("pengguna") {
val name = varchar("nama", 255)
val email = varchar("email", 255).uniqueIndex()
val passwordHash = varchar("password_hash", 255)
val active = bool("aktif").default(true)
}
// DAO API: entity classes
class ProductEntity(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<ProductEntity>(ProductTable)
var name by ProductTable.name
var description by ProductTable.description
var price by ProductTable.price
var stock by ProductTable.stock
var category by ProductTable.category
var active by ProductTable.active
var createdAt by ProductTable.createdAt
}
Database Setup and Transactions #
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
fun initializeDatabase() {
Database.connect(
url = "jdbc:mysql://localhost:3306/myapp?useSSL=false&serverTimezone=Asia/Jakarta",
driver = "com.mysql.cj.jdbc.Driver",
user = System.getenv("DB_USER") ?: "root",
password = System.getenv("DB_PASSWORD") ?: "password"
)
// Or with the HikariDataSource already created
// Database.connect(DatabasePool.dataSource)
// Create tables if they don't exist (for development)
transaction {
SchemaUtils.create(ProductTable, UserTable)
}
}
CRUD with the Exposed DSL #
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction
import java.math.BigDecimal
// INSERT
fun addProductDsl(name: String, price: BigDecimal, stock: Int): Int {
return transaction {
ProductTable.insertAndGetId {
it[ProductTable.name] = name
it[ProductTable.price] = price
it[ProductTable.stock] = stock
}.value
}
}
// SELECT
fun getAllProductsDsl(category: String? = null): List<ResultRow> {
return transaction {
ProductTable
.select { ProductTable.active eq true }
.apply { if (category != null) andWhere { ProductTable.category eq category } }
.orderBy(ProductTable.name)
.toList()
}
}
// SELECT with a join
fun getProductsWithSellers() {
transaction {
(ProductTable innerJoin UserTable)
.select { ProductTable.active eq true }
.forEach { row ->
println("${row[ProductTable.name]} — ${row[UserTable.name]}")
}
}
}
// UPDATE
fun updatePrice(id: Int, newPrice: BigDecimal): Int {
return transaction {
ProductTable.update({ ProductTable.id eq id }) {
it[price] = newPrice
}
}
}
// DELETE
fun deleteProductDsl(id: Int): Int {
return transaction {
ProductTable.update({ ProductTable.id eq id }) {
it[active] = false // soft delete
}
}
}
CRUD with the Exposed DAO #
// INSERT
fun addProductDao(name: String, price: BigDecimal, stock: Int): ProductEntity {
return transaction {
ProductEntity.new {
this.name = name
this.price = price
this.stock = stock
this.active = true
}
}
}
// SELECT
fun findProductDao(id: Int): ProductEntity? {
return transaction {
ProductEntity.findById(id)
}
}
fun allActiveProductsDao(): List<ProductEntity> {
return transaction {
ProductEntity.find { ProductTable.active eq true }
.orderBy(ProductTable.name to SortOrder.ASC)
.toList()
}
}
// UPDATE
fun updateProductDao(id: Int, newName: String, newPrice: BigDecimal): Boolean {
return transaction {
val product = ProductEntity.findById(id) ?: return@transaction false
product.name = newName
product.price = newPrice
true
}
}
// DELETE
fun deleteProductDao(id: Int): Boolean {
return transaction {
val product = ProductEntity.findById(id) ?: return@transaction false
product.active = false
true
}
}
Database Migrations with Flyway #
Flyway manages database schema changes in a controlled way — every change is written as a numbered SQL file, and Flyway tracks which versions have been run:
// build.gradle.kts — already added above
// Migration file structure:
// src/main/resources/db/migration/
// ├── V1__create_pengguna.sql
// ├── V2__create_produk.sql
// ├── V3__add_kategori_index.sql
// └── V4__add_pesanan.sql
-- V1__create_pengguna.sql
CREATE TABLE pengguna (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
aktif BOOLEAN NOT NULL DEFAULT TRUE,
dibuat_pada TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- V2__create_produk.sql
CREATE TABLE produk (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(255) NOT NULL,
deskripsi TEXT,
harga DECIMAL(15,2) NOT NULL,
stok INT NOT NULL DEFAULT 0,
kategori VARCHAR(100),
aktif BOOLEAN NOT NULL DEFAULT TRUE,
dibuat_pada TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
diperbarui_pada TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- V3__add_kategori_index.sql
CREATE INDEX idx_produk_kategori ON produk(kategori);
CREATE INDEX idx_produk_aktif ON produk(aktif);
import org.flywaydb.core.Flyway
fun runMigrations() {
val flyway = Flyway.configure()
.dataSource(
"jdbc:mysql://localhost:3306/myapp?useSSL=false",
System.getenv("DB_USER") ?: "root",
System.getenv("DB_PASSWORD") ?: "password"
)
.locations("classpath:db/migration") // location of the SQL files
.baselineOnMigrate(true) // for existing databases
.validateOnMigrate(true) // validate file checksums
.load()
val result = flyway.migrate()
println("Migration successful: ${result.migrationsExecuted} migrations executed")
}
fun main() {
runMigrations() // run migrations first
initializeDatabase() // then set up Exposed
// ... run the application
}
Preventing SQL Injection #
SQL injection is the most common attack on database applications. Always use PreparedStatement or an ORM — never concatenate user input into SQL strings:
// ANTI-PATTERN: SQL injection — NEVER DO THIS!
fun badProductSearch(name: String): List<Product> {
DatabasePool.use { connection ->
// If name = "' OR '1'='1" → the query returns ALL data!
// If name = "'; DROP TABLE produk; --" → THE TABLE IS DROPPED!
val sql = "SELECT * FROM produk WHERE nama = '$name'"
// ...
}
return emptyList()
}
// CORRECT: PreparedStatement with parameter placeholders
fun safeProductSearch(name: String): List<Product> {
return DatabasePool.use { connection ->
connection.prepareStatement("SELECT * FROM produk WHERE nama = ?").use { stmt ->
stmt.setString(1, name) // MySQL handles escaping automatically
stmt.executeQuery().use { rs ->
buildList { while (rs.next()) add(rs.toProduct()) }
}
}
}
}
// CORRECT: the Exposed DSL is also safe by default
fun exposedProductSearch(name: String) = transaction {
ProductTable.select { ProductTable.name eq name }.toList()
// Exposed uses PreparedStatement behind the scenes
}
Summary #
- Always use a connection pool — don’t create a new connection per request. HikariCP is the best choice for the JVM — configure
maximumPoolSizeaccording to your CPU core count and query characteristics.- PreparedStatement is mandatory — there’s no excuse for using
Statement.execute(sqlString)with user input.PreparedStatementprevents SQL injection and is more efficient because the query is precompiled.- Exposed for cleaner code — the Exposed DSL provides type-safety, IDE auto-complete, and more readable queries. The DAO API suits object-oriented code.
- Transactions for related operations — any operation involving multiple tables or multiple steps must run in a single transaction. If anything fails, everything is rolled back.
- Soft delete over hard delete — add an
aktif BOOLEAN DEFAULT TRUEcolumn and markaktif = FALSEinstead of deleting rows. This makes recovery and audit trails easier.- Flyway for schema migrations — don’t modify production databases manually. All schema changes must go through sequential, reviewed, version-controlled migration files.
- Environment variables for credentials — don’t hardcode database URLs, usernames, and passwords in code or config files that go into git. Use environment variables.
- Batch inserts for mass data —
addBatch()andexecuteBatch()are far more efficient than inserting one at a time in a loop. For thousands of rows, the performance difference is significant.