|
| 1 | +--- |
| 2 | +layout: tutorial |
| 3 | +official: false |
| 4 | +title: "Javalin with Bpdbi: A Pipelining-first Postgres Client in Kotlin" |
| 5 | +permalink: /tutorials/javalin-bpdbi-kotlin |
| 6 | +summarytitle: Using Javalin with Bpdbi (Kotlin) |
| 7 | +summary: Build a REST API with Javalin and Bpdbi — a lightweight, pipelining-first Postgres driver that bypasses JDBC for better performance and a simpler stack. |
| 8 | +date: 2026-03-26 |
| 9 | +author: <a href="https://github.com/cies">Cies Breijs</a> |
| 10 | +language: ["kotlin", "gradle", "postgres", "sql"] |
| 11 | +rightmenu: true |
| 12 | +github: https://github.com/bpdbi/bpdbi |
| 13 | +--- |
| 14 | + |
| 15 | +## Why Javalin + Bpdbi? |
| 16 | + |
| 17 | +Javalin and Bpdbi share the same philosophy: **do one thing well, stay lightweight, and get out of your way.** |
| 18 | + |
| 19 | +Javalin gives you a simple HTTP layer without the ceremony of a full framework. |
| 20 | +Bpdbi gives you a simple database layer without the ceremony of JDBC + a connection pool library + a query abstraction library (like Jdbi, Spring JDBC Template, etc.). |
| 21 | + |
| 22 | +Together they make for an exceptionally lightweight stack: |
| 23 | + |
| 24 | +- **No JDBC** — Bpdbi speaks the Postgres wire protocol directly, which unlocks pipelining and binary-for-all encoding |
| 25 | +- **No Netty** — plain `java.net.Socket`, no event loop, no reactive machinery |
| 26 | +- **No reflection** — the `bpdbi-kotlin` module uses `kotlinx.serialization` for row mapping, which does not use the reflection API |
| 27 | +- **Simplicity of code** — since Bpdbi used the good old blocking paradigm the code is very readable |
| 28 | + |
| 29 | +The total dependency footprint for the database side is under 200KB — compare that to JDBC driver + HikariCP + Jdbi (several MB) or Hibernate (~15MB). |
| 30 | + |
| 31 | +## What is pipelining? |
| 32 | + |
| 33 | +Pipelining sends multiple SQL statements to the database in a single network write and reads all responses back at once. This reduces the number of round-trips, which is especially valuable when: |
| 34 | + |
| 35 | +- You need to run setup statements before your actual query (e.g. `BEGIN`, `SET`, RLS configuration) |
| 36 | +- You need results from multiple independent queries in a single request |
| 37 | +- You're inserting or updating multiple rows |
| 38 | + |
| 39 | +For example, a typical "start transaction + query" that takes 2 round-trips with JDBC can be done in 1 round-trip with Bpdbi: |
| 40 | + |
| 41 | +```kotlin |
| 42 | +conn.enqueue("BEGIN") |
| 43 | +conn.enqueue("SET LOCAL statement_timeout TO '5s'") |
| 44 | +val result = conn.sql("SELECT * FROM users WHERE id = :id") |
| 45 | + .bind("id", userId) |
| 46 | + .query() |
| 47 | +// All three statements sent in a single network write |
| 48 | +``` |
| 49 | + |
| 50 | +In benchmarks with 1ms simulated network latency, pipelining gives a **2-17x speedup** depending on the scenario. |
| 51 | + |
| 52 | +## Project setup |
| 53 | + |
| 54 | +### Gradle (build.gradle.kts) |
| 55 | + |
| 56 | +```kotlin |
| 57 | +plugins { |
| 58 | + kotlin("jvm") version "2.1.20" |
| 59 | + kotlin("plugin.serialization") version "2.1.20" |
| 60 | + application |
| 61 | +} |
| 62 | + |
| 63 | +group = "com.example" |
| 64 | + |
| 65 | +repositories { |
| 66 | + mavenCentral() |
| 67 | +} |
| 68 | + |
| 69 | +application { |
| 70 | + mainClass.set("com.example.AppKt") |
| 71 | +} |
| 72 | + |
| 73 | +val javalinVersion = "6.6.0" |
| 74 | +val bpdbiVersion = "0.1.0" |
| 75 | + |
| 76 | +dependencies { |
| 77 | + implementation("io.javalin:javalin-bundle:$javalinVersion") |
| 78 | + implementation(platform("io.github.bpdbi:bpdbi-bom:$bpdbiVersion")) |
| 79 | + implementation("io.github.bpdbi:bpdbi-pg-client") |
| 80 | + implementation("io.github.bpdbi:bpdbi-pool") |
| 81 | + implementation("io.github.bpdbi:bpdbi-kotlin") |
| 82 | +} |
| 83 | +``` |
| 84 | + |
| 85 | +The `bpdbi-bom` aligns all module versions. The three modules we use: |
| 86 | + |
| 87 | +- **bpdbi-pg-client** — the Postgres driver (speaks wire protocol directly) |
| 88 | +- **bpdbi-pool** — a lightweight connection pool |
| 89 | +- **bpdbi-kotlin** — Kotlin extensions and `kotlinx.serialization`-based row mapping |
| 90 | + |
| 91 | +### Docker Compose with Postgres |
| 92 | + |
| 93 | +```yaml |
| 94 | +services: |
| 95 | + postgres: |
| 96 | + image: postgres:16-alpine |
| 97 | + environment: |
| 98 | + POSTGRES_USER: demo |
| 99 | + POSTGRES_PASSWORD: demo |
| 100 | + POSTGRES_DB: demo |
| 101 | + ports: |
| 102 | + - "5432:5432" |
| 103 | +``` |
| 104 | +
|
| 105 | +Start it with `docker compose up -d`. |
| 106 | + |
| 107 | +## Data model |
| 108 | + |
| 109 | +Define a simple `tasks` table. We'll create it on app startup: |
| 110 | + |
| 111 | +```kotlin |
| 112 | +private fun initSchema(pool: ConnectionPool) { |
| 113 | + pool.withConnection { conn -> |
| 114 | + conn.query(""" |
| 115 | + CREATE TABLE IF NOT EXISTS tasks ( |
| 116 | + id SERIAL PRIMARY KEY, |
| 117 | + title TEXT NOT NULL, |
| 118 | + done BOOLEAN NOT NULL DEFAULT false |
| 119 | + ) |
| 120 | + """) |
| 121 | + } |
| 122 | +} |
| 123 | +``` |
| 124 | + |
| 125 | +And a Kotlin data class to map rows into, using `kotlinx.serialization`: |
| 126 | + |
| 127 | +```kotlin |
| 128 | +import kotlinx.serialization.Serializable |
| 129 | +
|
| 130 | +@Serializable |
| 131 | +data class Task(val id: Int, val title: String, val done: Boolean) |
| 132 | +``` |
| 133 | + |
| 134 | +That's it — no reflection configuration, no code generation, no runtime dependencies. The `kotlinx.serialization` compiler plugin handles everything at compile time. |
| 135 | + |
| 136 | +## Connection pool |
| 137 | + |
| 138 | +Set up the pool once at application startup: |
| 139 | + |
| 140 | +```kotlin |
| 141 | +import io.github.bpdbi.pg.PgConnection |
| 142 | +import io.github.bpdbi.pool.ConnectionPool |
| 143 | +import io.github.bpdbi.pool.PoolConfig |
| 144 | +
|
| 145 | +fun createPool(): ConnectionPool { |
| 146 | + return ConnectionPool( |
| 147 | + { PgConnection.connect("localhost", 5432, "demo", "demo", "demo") }, |
| 148 | + PoolConfig() |
| 149 | + .maxSize(10) |
| 150 | + .connectionTimeoutMillis(5000) |
| 151 | + ) |
| 152 | +} |
| 153 | +``` |
| 154 | + |
| 155 | +The pool is virtual-thread friendly: blocking on `acquire()` is cheap when Javalin dispatches requests to virtual threads. |
| 156 | + |
| 157 | +## Handlers |
| 158 | + |
| 159 | +Now we wire up the HTTP handlers. Bpdbi's `sql()` builder with named parameters (`:name`) and `.bind()` makes the code very readable: |
| 160 | + |
| 161 | +```kotlin |
| 162 | +import io.github.bpdbi.kotlin.deserializeFirst |
| 163 | +import io.github.bpdbi.kotlin.deserializeFirstOrNull |
| 164 | +import io.github.bpdbi.kotlin.deserializeToList |
| 165 | +import io.javalin.http.Context |
| 166 | +import io.javalin.http.HttpStatus |
| 167 | +
|
| 168 | +class TaskController(private val pool: ConnectionPool) { |
| 169 | +
|
| 170 | + fun getAll(ctx: Context) { |
| 171 | + val tasks = pool.withConnection { conn -> |
| 172 | + conn.sql("SELECT id, title, done FROM tasks ORDER BY id") |
| 173 | + .query() |
| 174 | + .deserializeToList<Task>() |
| 175 | + } |
| 176 | + ctx.json(tasks) |
| 177 | + } |
| 178 | +
|
| 179 | + fun getOne(ctx: Context) { |
| 180 | + val id = ctx.pathParam("id").toInt() |
| 181 | + val task = pool.withConnection { conn -> |
| 182 | + conn.sql("SELECT id, title, done FROM tasks WHERE id = :id") |
| 183 | + .bind("id", id) |
| 184 | + .query() |
| 185 | + .deserializeFirstOrNull<Task>() |
| 186 | + } |
| 187 | + if (task != null) ctx.json(task) |
| 188 | + else ctx.status(HttpStatus.NOT_FOUND) |
| 189 | + } |
| 190 | +
|
| 191 | + fun create(ctx: Context) { |
| 192 | + val body = ctx.bodyAsClass<CreateTask>() |
| 193 | + val task = pool.withConnection { conn -> |
| 194 | + conn.sql("INSERT INTO tasks (title) VALUES (:title) RETURNING id, title, done") |
| 195 | + .bind("title", body.title) |
| 196 | + .query() |
| 197 | + .deserializeFirst<Task>() |
| 198 | + } |
| 199 | + ctx.json(task).status(HttpStatus.CREATED) |
| 200 | + } |
| 201 | +
|
| 202 | + fun update(ctx: Context) { |
| 203 | + val id = ctx.pathParam("id").toInt() |
| 204 | + val body = ctx.bodyAsClass<UpdateTask>() |
| 205 | + val task = pool.withConnection { conn -> |
| 206 | + conn.sql("UPDATE tasks SET title = :title, done = :done WHERE id = :id RETURNING id, title, done") |
| 207 | + .bind("title", body.title) |
| 208 | + .bind("done", body.done) |
| 209 | + .bind("id", id) |
| 210 | + .query() |
| 211 | + .deserializeFirstOrNull<Task>() |
| 212 | + } |
| 213 | + if (task != null) ctx.json(task) |
| 214 | + else ctx.status(HttpStatus.NOT_FOUND) |
| 215 | + } |
| 216 | +
|
| 217 | + fun delete(ctx: Context) { |
| 218 | + val id = ctx.pathParam("id").toInt() |
| 219 | + pool.withConnection { conn -> |
| 220 | + conn.sql("DELETE FROM tasks WHERE id = :id") |
| 221 | + .bind("id", id) |
| 222 | + .query() |
| 223 | + } |
| 224 | + ctx.status(HttpStatus.NO_CONTENT) |
| 225 | + } |
| 226 | +} |
| 227 | +
|
| 228 | +@Serializable |
| 229 | +data class CreateTask(val title: String) |
| 230 | +
|
| 231 | +@Serializable |
| 232 | +data class UpdateTask(val title: String, val done: Boolean) |
| 233 | +``` |
| 234 | + |
| 235 | +Notice how there's no `ResultSet` iteration, no `try/catch (SQLException)`, no `RowMapper` boilerplate. |
| 236 | +Named parameters (`:title`, `:id`) are more readable than positional `$1, $2` placeholders, and the `deserializeToList<Task>()` / `deserializeFirst<Task>()` extensions handle mapping using the `@Serializable` annotation — all at compile time. |
| 237 | + |
| 238 | +Of course you can move the db queries to a separate namespace, in a Model View Controller kind of fashion. |
| 239 | +But that's beyond the scope of this tutorial. |
| 240 | + |
| 241 | +## Pipelining in action |
| 242 | + |
| 243 | +Here's where Bpdbi really shines. Suppose you need to fetch a task and its related comments in a single request: |
| 244 | + |
| 245 | +```kotlin |
| 246 | +fun getTaskWithComments(ctx: Context) { |
| 247 | + val id = ctx.pathParam("id").toInt() |
| 248 | + pool.withConnection { conn -> |
| 249 | + val taskQx = conn.sql("SELECT id, title, done FROM tasks WHERE id = :id") |
| 250 | + .bind("id", id).enqueue() |
| 251 | + val commentsQx = conn.sql("SELECT id, body, created_at FROM comments WHERE task_id = :taskId") |
| 252 | + .bind("taskId", id).enqueue() |
| 253 | + val results = conn.flush() |
| 254 | +
|
| 255 | + val task = results[taskQx].deserializeFirstOrNull<Task>() |
| 256 | + val comments = results[commentsQx].deserializeToList<Comment>() |
| 257 | +
|
| 258 | + if (task != null) { |
| 259 | + ctx.json(mapOf("task" to task, "comments" to comments)) |
| 260 | + } else { |
| 261 | + ctx.status(HttpStatus.NOT_FOUND) |
| 262 | + } |
| 263 | + } |
| 264 | +} |
| 265 | +``` |
| 266 | + |
| 267 | +Two queries, **one network round-trip**. With JDBC, this would always be two round-trips — there's no way around it. |
| 268 | + |
| 269 | +## Putting it all together |
| 270 | + |
| 271 | +```kotlin |
| 272 | +import io.javalin.Javalin |
| 273 | +import io.javalin.apibuilder.ApiBuilder.* |
| 274 | +
|
| 275 | +fun main() { |
| 276 | + val pool = createPool() |
| 277 | + initSchema(pool) |
| 278 | +
|
| 279 | + val tasks = TaskController(pool) |
| 280 | +
|
| 281 | + val app = Javalin.create { config -> |
| 282 | + config.router.apiBuilder { |
| 283 | + path("/tasks") { |
| 284 | + get(tasks::getAll) |
| 285 | + post(tasks::create) |
| 286 | + path("/{id}") { |
| 287 | + get(tasks::getOne) |
| 288 | + put(tasks::update) |
| 289 | + delete(tasks::delete) |
| 290 | + } |
| 291 | + } |
| 292 | + } |
| 293 | + }.start(7070) |
| 294 | +
|
| 295 | + Runtime.getRuntime().addShutdownHook(Thread { |
| 296 | + app.stop() |
| 297 | + pool.close() |
| 298 | + }) |
| 299 | +} |
| 300 | +``` |
| 301 | + |
| 302 | +Run it with `./gradlew run`, then test with curl: |
| 303 | + |
| 304 | +```bash |
| 305 | +# Create a task |
| 306 | +curl -X POST http://localhost:7070/tasks \ |
| 307 | + -H "Content-Type: application/json" \ |
| 308 | + -d '{"title": "Write tutorial"}' |
| 309 | +
|
| 310 | +# List all tasks |
| 311 | +curl http://localhost:7070/tasks |
| 312 | +
|
| 313 | +# Mark as done |
| 314 | +curl -X PUT http://localhost:7070/tasks/1 \ |
| 315 | + -H "Content-Type: application/json" \ |
| 316 | + -d '{"title": "Write tutorial", "done": true}' |
| 317 | +``` |
| 318 | + |
| 319 | +## Conclusion |
| 320 | + |
| 321 | +Javalin and Bpdbi make for a remarkably lean stack: a simple HTTP server talking directly to Postgres over the binary wire protocol, with compile-time row mapping and first-class pipelining. No JDBC, no Netty, no reflection, no heavyweight frameworks. |
| 322 | + |
| 323 | +The full Bpdbi documentation and source code can be found on [GitHub](https://github.com/bpdbi/bpdbi). |
0 commit comments