1. Why JDBC blocks in coroutines
When people first hear “coroutines”, the brain often fills in extra details: as if a coroutine were an invisible helmet that makes any code “asynchronous automatically”. It would be nice, but no. If a JDBC driver executes a query synchronously, then the thread really does wait for the DB response, and the coroutine at that moment just occupies the thread — like a cat occupying your keyboard.
The core idea of coroutines in Kotlin is that they let you write asynchronous code in a sequential style and suspend execution without blocking system resources when the operation truly supports suspension. But most “regular” JDBC operations are blocking: until the database responds, the thread is busy and goes nowhere. So coroutines by themselves don’t solve the problem — it’s solved by choosing the right dispatcher and defining the boundaries where we perform blocking work.
Coroutine dispatchers and Dispatchers.IO
To avoid confusion, it helps to think like this: a coroutine is a “task”, and a dispatcher is “where and on which threads this task runs”. In kotlinx.coroutines a coroutine has a context, and one of the key elements of that context is CoroutineDispatcher, which defines which threads the coroutine will run on.
If you run a blocking JDBC query on Dispatchers.Default, you risk “clogging” a limited thread pool that is primarily meant for CPU work (calculations, collection processing, formatting, etc.). But JDBC is waiting for I/O, and it makes more sense for it to live on Dispatchers.IO, which is designed for blocking input/output operations (files, network, database).
Let’s imagine a picture that’s useful to keep in mind:
flowchart LR A[Coroutine on Default] -->|JDBC query| B[Thread blocks] B --> C[Default pool is busy] C --> D[Other coroutines wait] E[Coroutine on IO] -->|JDBC query| F[Thread blocks] F --> G[IO pool handles blocking] G --> H[Default pool stays free]
The idea is simple: you don’t make JDBC “non-blocking”, you just move the blocking to where it harms the rest of the program less.
2. withContext(Dispatchers.IO) and the storage-layer boundary
When we say “move blocking work to IO”, in practice we most often mean one specific pattern: wrap blocking code in withContext(Dispatchers.IO) { ... }. This looks like “switching the execution mode” for a fragment: outside you can have any context (for example, Default), but inside the block the code is guaranteed to run on the IO dispatcher.
It’s important to understand the philosophy: withContext does not turn JDBC into a magical non-blocking driver. It simply says: “run this part of the work on a different thread pool”. This fits well with the fact that Kotlin coroutines are designed around an execution context and a dispatcher that controls where exactly the task runs.
A mini-wrapper that people really like to write in projects so they don’t smear Dispatchers.IO all over the code:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend fun <T> dbIo(block: () -> T): T =
withContext(Dispatchers.IO) { block() }
Note: the block here is an ordinary lambda () -> T, because JDBC calls typically are not suspend. We are not “awaiting in a coroutine way”; we are actually executing synchronous code, just on a different dispatcher.
Where Dispatchers.IO should live
It’s very tempting to do this: “in main, before calling the repository, I’ll wrap everything in withContext(IO)”. On a small project that will even work. But architecturally it quickly turns into a mess: you forgot to wrap one call — and suddenly some queries went to Default. Then you add another scenario — and you’re no longer sure where you have IO and where you don’t.
So we introduce a fairly boring, but very useful rule: switching to Dispatchers.IO lives in the storage layer (repository/DAO), not in the CLI and not in business logic. That’s the “storage-layer boundary”: all external code treats the repository as a “black box” that knows how to work with the DB correctly.
A minimal repository contract for our console expense-tracking app:
import kotlinx.datetime.Instant
data class Expense(
val id: Long,
val title: String,
val amountCents: Long,
val createdAt: Instant,
)
interface ExpenseRepository {
suspend fun add(title: String, amountCents: Long): Long
suspend fun listLatest(limit: Int): List<Expense>
}
From the outside, it looks simple: suspend methods, no JDBC types, no Connection. And that’s great: external code should not know which key you use to open SQLite or how much pain there is in ResultSet.
3. Transactions and contexts
When you just start using withContext(IO), a strange thinking error appears: “I’ll wrap each SQL query separately; it’ll be even safer”. And here we hit a subtle point: a transaction is a frame of atomicity, and it should execute as one cohesive chunk.
If you start doing this: “the first query in withContext(IO), then return outside, then the second query again in withContext(IO)”, you logically begin to split the transactional scenario and increase the risk of making the code non-obvious. Technically, a JDBC transaction lives on a Connection, and until you close the Connection and call commit/rollback, you can still keep it in one place — and it’s worth doing.
So the rule is very practical: if an operation requires a transaction, then the transaction and all queries inside it must be entirely inside a single withContext(Dispatchers.IO).
Here’s the “transaction as a helper” template — short and readable:
import java.sql.Connection
inline fun <T> inTx(conn: Connection, block: () -> T): T {
conn.autoCommit = false
try {
val result = block()
conn.commit()
return result
} catch (e: Exception) {
conn.rollback()
throw e
} finally {
conn.autoCommit = true
}
}
Note: this is not a suspend function, because the JDBC calls inside it are synchronous. The suspend will be outside — at the repository level, where we wrap everything in withContext(IO).
4. Practical example: Expense Tracker
So that the examples aren’t just a set of disconnected snippets, let’s agree on a minimal project structure. We’re not building an “ideal enterprise”, but we’ll do it neatly enough that the code won’t be embarrassing to open a week later.
Dependency diagram (remember the idea of architectural layers: the CLI should not depend on JDBC directly):
flowchart TD CLI["CLI (main, commands)"] --> S["Service (use cases)"] S --> R["ExpenseRepository (interface)"] R --> J["JdbcExpenseRepository (impl)"] J --> DB[(SQLite / DB)]
A service without JDBC and without Dispatchers.IO
Now let’s sketch a service that “speaks human”, not SQL terms:
class ExpenseService(
private val repo: ExpenseRepository,
) {
suspend fun addExpense(title: String, amountCents: Long): Long {
require(title.isNotBlank()) { "title must not be blank" }
require(amountCents > 0) { "amount must be positive" }
return repo.add(title.trim(), amountCents)
}
}
The service knows nothing about Dispatchers.IO. It simply calls the suspend repository. And it is the repository that will be responsible for “where this runs”.
A JDBC repository with withContext(IO) inside
Now we’ll do the most important part: a repository implementation that (1) works with JDBC, (2) closes resources, (3) guarantees the IO dispatcher.
Let’s start with a small “connection provider”. In real projects, connection pools appear later, but today we deliberately don’t touch that: discipline of layers and the execution context matter more for us.
import java.sql.Connection
import java.sql.DriverManager
class DbConnectionFactory(
private val url: String,
) {
fun open(): Connection = DriverManager.getConnection(url)
}
Now the repository implementation. First — add(...). Note the three nesting levels: withContext(IO) → open().use → prepareStatement(...).use.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.sql.Statement
class JdbcExpenseRepository(
private val connections: DbConnectionFactory,
) : ExpenseRepository {
override suspend fun add(title: String, amountCents: Long): Long =
withContext(Dispatchers.IO) {
connections.open().use { conn ->
val sql = "INSERT INTO expenses(title, amount_cents) VALUES (?, ?)"
conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS).use { st ->
st.setString(1, title)
st.setLong(2, amountCents)
st.executeUpdate()
st.generatedKeys.use { rs ->
rs.next()
rs.getLong(1)
}
}
}
}
}
There’s one “grown-up” detail here: generatedKeys and rs.next() — don’t forget that a ResultSet starts “before the first row”. This is not a coroutine problem; it’s a pure JDBC ritual.
Now — listLatest(limit). We fetch rows, map them to Expense, and return a list:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.datetime.Instant
override suspend fun listLatest(limit: Int): List<Expense> =
withContext(Dispatchers.IO) {
connections.open().use { conn ->
val sql = """
SELECT id, title, amount_cents, created_at
FROM expenses
ORDER BY id DESC
LIMIT ?
""".trimIndent()
conn.prepareStatement(sql).use { st ->
st.setInt(1, limit)
st.executeQuery().use { rs ->
val items = mutableListOf<Expense>()
while (rs.next()) {
items += Expense(
id = rs.getLong("id"),
title = rs.getString("title"),
amountCents = rs.getLong("amount_cents"),
createdAt = Instant.parse(rs.getString("created_at")),
)
}
items
}
}
}
}
Yes, Instant.parse(...) can throw an exception if the DB contains a “dirty” string. That’s fine: the storage layer either guarantees the format correctness (via schema/validation), or it fails honestly. Today we’re not turning this into a separate error-design discussion — we just don’t sweep the problem under the rug.
Calling from main
In console apps, the most common construct is runBlocking in main, because the JVM expects a regular entry point, while you want to call suspend functions. It’s important that runBlocking is a bridge between the “regular world” and the “suspend world”.
At the same time, it’s very important not to stick withContext(IO) around every repository call in main, otherwise you break the idea of the storage-layer boundary.
A mini example: “wired dependencies and called the service”:
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val factory = DbConnectionFactory("jdbc:sqlite:app.db")
val repo: ExpenseRepository = JdbcExpenseRepository(factory)
val service = ExpenseService(repo)
val id = service.addExpense("Coffee", 350)
println("Added expense id=$id") // Added expense id=1
}
main stays clean: it doesn’t know about JDBC and it doesn’t know which dispatchers are used inside. It only knows “there is a service, there is a repository, there is a result”.
5. Exposed in coroutines
Exposed sometimes creates the illusion that “since it’s a DSL, it must be optimizing threads in some clever way”. But Exposed still goes to the database via JDBC under the hood, which means from a threading perspective it’s the same blocking operation. So the rule about Dispatchers.IO doesn’t go anywhere.
The pattern becomes even simpler: we don’t work with Connection by hand, but we wrap the entire transaction { ... } in an IO context.
Suppose you already have the Users/Expenses table from the previous lecture. Then coroutine access looks like this:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.transactions.transaction
suspend fun addExpenseExposed(title: String, amountCents: Long): Long =
withContext(Dispatchers.IO) {
transaction {
Expenses.insert {
it[Expenses.title] = title
it[Expenses.amountCents] = amountCents
} get Expenses.id
}.value
}
Note the meaning: in withContext(IO) we keep the entire transaction. This is the same “don’t tear the transaction apart” principle, only in Exposed it looks even more natural: a transaction is one block.
And one more important thought: if inside transaction { } you start doing a lot of unrelated logic (parsing, reading files, network calls), you extend the time the transaction is held. So inside a transaction, keep only what relates to data integrity, and everything else — outside.
6. Errors and exceptions
At this point, beginners want to make it “not crash”: wrap everything in try/catch inside the repository and return, for example, an empty list. It looks like caring about the user, but in practice it’s masking problems. When the storage layer silently swallows an exception, the external code continues operating under wrong assumptions: “there are just no expenses in the DB”, while in reality it’s “the DB file is unavailable” or “the table hasn’t been created”.
It’s much more honest to stick to one of two styles. Either the repository propagates exceptions outward (and the use case/CLI decides how to report to the user), or the repository returns an explicit “success/error” result as a value type. Today, within this lecture, we stick to the first style: we don’t hide the exception, we close resources via use { } (they will close even on exception), and we roll back transactions via rollback() in the template.
A mini example: a top-level scenario catches an exception and prints a normal message, without turning “disk error” into “you have 0 expenses”:
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
try {
// ... service calls
} catch (e: Exception) {
println("Database error: ${e.message}") // Database error: ...
}
}
Yes, this is not perfect error handling (we don’t type errors and we don’t log stack traces nicely), but it’s already an honest model: if the DB is broken, we don’t pretend everything is fine.
7. Common mistakes when accessing the DB in coroutines
Mistake No. 1: running JDBC/Exposed on Dispatchers.Default “because it works anyway”.
This is one of the most insidious problems: in a small app you won’t see trouble, but as soon as a couple of parallel operations appear (or you simply have a slow disk/network), Default will start idling in blocks. And Default is for computations, not for waiting on I/O — that’s exactly why the dispatcher exists as part of the coroutine context.
Mistake No. 2: smearing withContext(Dispatchers.IO) all over the code instead of the storage layer.
At first it looks “fast and convenient”: wrapped one call in the CLI — done. A week later you’ll forget to wrap the second; two weeks later you’ll add a third scenario, and you won’t be able to answer “are all queries definitely on IO?”. The storage-layer boundary exists precisely to make that guarantee centralized.
Mistake No. 3: wrapping each individual query in withContext(IO), tearing apart a transactional scenario.
If the operation logically must be atomic, keep it as one block: one withContext(IO) around the whole transaction. When a transaction is split into pieces, the code becomes not only less efficient, but also harder to read: you lose the feeling of “atomic operation starts here; it ends here”.
Mistake No. 4: catching Exception inside the repository and replacing everything with println so it “won’t crash”.
Such a repository becomes a hole that eats errors. The external code stops understanding what’s happening: “no data” and “failed to connect to the DB” become indistinguishable. It’s better either to propagate the exception or to return an explicit error result — but not to pretend everything is fine.
Mistake No. 5: holding a Connection or transaction { } for too long, mixing it with unrelated logic.
Sometimes a transaction accidentally starts to include user input, formatting, file reads, computations — and this stretches the time DB resources are held. Even if you’re not yet studying isolation levels and transaction concurrency, one habit is useful: a transaction should be compact and contain only what actually relates to data consistency.
Mistake No. 6: forgetting that coroutines do not “make code non-blocking automatically”.
You can launch a coroutine, but if inside you call a blocking JDBC method, the thread will still be busy waiting. The coroutine model promises a convenient coding style and control over the execution context, but it doesn’t cancel the physics of I/O. So Dispatchers.IO and careful boundaries remain mandatory.