Skip to content

Commit 039f570

Browse files
ciescies
authored andcommitted
Add Bpdbi tutorial
1 parent bc28487 commit 039f570

1 file changed

Lines changed: 323 additions & 0 deletions

File tree

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
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

Comments
 (0)