Skip to content

Bluetape4k Exposed Part 1: Why Exposed?

Small robotic builders assembling Exposed JDBC and R2DBC blocks on a blue blueprint workbench
The data-access layer is not flashy. Pick it badly, and every request waits there.

This is Part 1 of the bluetape4k-exposed series. Exposed was not the answer I started with. I looked at JPA, MyBatis, R2DBC, Vert.x SQL Client, and Virtual Threads, then built examples to see which path could stay useful in production code.

The choice I kept coming back to was Exposed. bluetape4k-exposed adds the operational pieces I kept needing around it: repositories, R2DBC, JSON and encryption columns, cache, and multi-tenancy boundaries.

The Part 1 question is simple:

JPA, MyBatis, R2DBC, and Vert.x SQL Client already exist. Why Exposed?

“The DSL looks nice” is not enough of an answer. The real starting point was the search for async and non-blocking data access. WebFlux does not help much if the database path blocks inside the request. R2DBC gives non-blocking I/O, but SQL DSL ergonomics and Kotlin usage become their own problem. Virtual Threads let us keep blocking JDBC with lower waiting cost, but they still need a safer way to write SQL.

Exposed sits in that middle ground. It keeps SQL visible, makes it safer through a Kotlin DSL, and lets the workload decide between JDBC and R2DBC.

Choice flow from async pressure through WebFlux JDBC, R2DBC Vert.x, Virtual Threads, Exposed DSL, and bluetape4k-exposed production boundaries
The choice is not “reactive everywhere.” It is SQL safety, Kotlin fit, and production boundaries together.

JDBC is synchronous. While a request waits for a database response, its thread is tied to that work. As traffic grows and database latency increases, many services survive by adding more application servers.

NoSQL was another common escape path for a while. MongoDB, Cassandra, and Elasticsearch are strong in their own domains. But moving every primary store to NoSQL brings different costs around schema changes, exception handling, operations, and back-office work. Many services eventually keep relational databases as the main store because they are strong and operationally familiar.

The harder question comes next. If a service uses an async/non-blocking web framework such as Spring WebFlux but keeps JDBC or JPA inside, only the entrance is asynchronous. The database path still queues. When the event loop spends time waiting for the database, the design that was supposed to improve responsiveness becomes risky.

I grouped the choices by what I had tried and where they became awkward in production.

ChoiceStrengthFriction
Hibernate ReactiveBrings ORM experience into a reactive stackVert.x based, with learning cost and ecosystem constraints
Vert.x SQL ClientClean non-blocking access for I/O-bound database workSQL strings and mapping code stay manual
Spring Data R2DBCNatural in the Spring reactive stackSQL DSL support is thinner, and Reactor types come first
JPA + Virtual ThreadsKeeps existing blocking APIs mostly intactJPA mapping weight and SQL-control issues remain
Exposed + Virtual ThreadsMature JDBC drivers plus Kotlin SQL DSLThe blocking JDBC boundary still has to be understood and measured
Exposed R2DBC + CoroutinesExposed SQL DSL with suspend and FlowDriver and workload behavior must be measured case by case

The conclusion is not “R2DBC is wrong.” It is also not “Virtual Threads win everything.” Streaming and backpressure may make R2DBC the better fit. Mature JDBC drivers, batch behavior, familiar transaction boundaries, and simple call stacks may make JDBC plus Virtual Threads easier to operate.

I was not looking for a large ORM world like JPA. For this codebase, it fit better to let developers who understand SQL write queries safely in Kotlin instead of hiding SQL behind a large domain graph and lazy-loading model.

val rows = Users
.selectAll()
.where { Users.email eq email }
.orderBy(Users.id)
.limit(1)
.toList()

Reducing string SQL is more than a style preference. When column names, types, joins, and where conditions live in code, small production mistakes become less likely. Exposed is not an abstraction that makes SQL disappear. It is closer to a Kotlin DSL that lets us write SQL more safely.

DAO is still available. Simple reads and relationships can be expressed neatly with EntityClass. In production code, though, complex queries, transactions, cache, routing, and observability move together. That is why this series puts SQL DSL and repository boundaries ahead of DAO.

bluetape4k-exposed does not replace Exposed. JetBrains Exposed stays at the center. The extra layers are the pieces I kept rebuilding in production services.

Overview diagram from the bluetape4k-exposed root README
Exposed owns SQL DSL and transactions. bluetape4k-exposed separates repositories, cache, JSON, encryption, dialects, and Spring Boot wiring.
AreaRepresentative extensionsResponsibility
Repositoryexposed-jdbc, exposed-r2dbcJDBC/R2DBC repository abstraction, audit and soft-delete patterns
Cacheexposed-cache, exposed-jdbc-caffeine, exposed-r2dbc-lettuceRead paths with local or Redis cache
Column codecsexposed-jackson2, exposed-jackson3, exposed-fastjson2, exposed-tinkJSON columns, encrypted columns, measured columns
Dialectsexposed-postgresql, exposed-mysql8, exposed-bigquery, exposed-clickhouse, exposed-trino, exposed-duckdbDatabase-specific SQL and connector helpers
Spring Bootexposed-spring-boot-jdbc, exposed-spring-boot-r2dbc, exposed-spring-boot-batchBoot 4 auto-configuration and application wiring

Adoption should stay selective.

dependencies {
implementation("io.github.bluetape4k.exposed:bluetape4k-exposed-jdbc:1.9.2")
implementation("io.github.bluetape4k.exposed:bluetape4k-exposed-r2dbc:1.9.2")
implementation("io.github.bluetape4k.exposed:bluetape4k-exposed-jdbc-lettuce:1.9.2")
implementation("io.github.bluetape4k.exposed:bluetape4k-exposed-jackson2:1.9.2")
implementation("io.github.bluetape4k.exposed:bluetape4k-exposed-spring-boot-jdbc:1.9.2")
}

The intended use is not “pull in everything.” If you need only JDBC repositories, add only JDBC. If a cache-backed read path is needed, add the cache adapter. Add the Spring Boot module only when auto-configuration is useful. Keeping that distance matters; otherwise a library starts pretending to be a framework.

The most dangerous sentence in data-access selection is “it should be faster in theory.” Theory gives direction. Production choices need measurements.

That is why the examples and benchmarks live close to the docs. The Virtual Threads/JDBC benchmark notes in exposed-workshop show the representative ratios below. The ratio matters more than the absolute number here.

Virtual Threads benchmark chart showing 2.54x indexed SELECT, 1.79x complex query, 2.27x 10ms I/O delay, and 1.03x CPU-bound improvement
Virtual Threads matter more for JDBC workloads with I/O wait than for CPU-bound work.
WorkloadVirtual Threads improvement
Simple indexed SELECT2.2-2.6x
Complex query, UPDATE + aggregation1.8x
10ms I/O delay simulation2.3x
CPU-bound work1.02-1.03x

These numbers do not mean “Virtual Threads make every code path faster.” The benefit is largest when a waiting JDBC path can release the carrier thread. CPU-bound work barely changes. JDBC plus Virtual Threads is a way to keep blocking APIs while lowering waiting cost, not a free CPU-performance button.

bluetape4k-exposed also includes ExposedJdbcBenchmark based on kotlinx-benchmark. It measures single-row CRUD, JOINs, and batch inserts with PostgreSQL Testcontainers, HikariCP max 24, and JMH throughput mode.

Exposed JDBC benchmark throughput chart showing singleFindById 15000 ops/s, singleInsert 14400 ops/s, singleUpdate 14300 ops/s, joinQuery 1510 ops/s, batchInsert 217 ops/s
Exposed JDBC throughput changes a lot by query shape. Single-row CRUD, JOINs, and batch inserts should not be read as one profile.
BenchmarkThroughput
singleFindById15,000 ops/s
singleInsert14,400 ops/s
singleUpdate14,300 ops/s
joinQuery1,510 ops/s
batchInsert217 ops/s

This is not the final scoreboard for JDBC versus R2DBC. Part 3 covers that with batch benchmarks. The Part 1 lesson is smaller: when choosing Exposed, measure query shape, driver behavior, connection pool, and transaction boundaries together. Do not choose by abstraction name alone.

I also kept a JPA comparison. In exposed-workshop/11-high-performance/04-benchmark, the benchmark compares Person CRUD operations with PostgreSQL Testcontainers and JMH average latency. Lower latency is better.

Chart comparing Exposed and JPA average latency for create, read, update, delete, and batchCreate operations
Under the same PostgreSQL setup, Exposed showed 3.9-8.3x lower latency than JPA for the sampled single-row CRUD operations.
OperationExposedJPAInterpretation
create1,196 us4,623 usExposed 3.9x faster
read1,972 us11,602 usExposed 5.9x faster
update1,980 us15,278 usExposed 7.7x faster
delete1,945 us12,059 usExposed 6.2x faster
batchCreate(100 rows)51,203 us423,816 usExposed 8.3x faster

This does not mean “never use JPA.” JPA is still strong when complex domain graphs, second-level cache, and the Spring Data ecosystem matter. It does mean Exposed is a serious candidate when SQL control and lower ORM overhead matter.

Changing a data-access layer based only on a blog post is risky. Small examples can hide the real problems. That is why this series keeps linking exposed-workshop, exposed-r2dbc-workshop, and production examples.

Repo / chapterWhat to inspect in code
exposed-workshop/02-alternatives-to-jpaWhy R2DBC, Vert.x, and Exposed were compared
exposed-workshop/03-exposed-basicBasic Exposed DSL and DAO usage
exposed-workshop/08-coroutinesCoroutine and virtual-thread boundaries
exposed-workshop/10-multi-tenantTenant isolation, routing datasource, cache isolation
exposed-workshop/11-high-performanceCache strategies and benchmark-centered examples
exposed-workshop/12-production-integrationOutbox, idempotency, observability, readiness boundaries
exposed-r2dbc-workshopThe same design revisited through R2DBC and coroutines

The production examples focus especially on cache strategy and multi-tenancy. The point is not to stop at a design diagram, but to include running results and performance charts. Cache cannot be added because it “should be fast.” If tenant ID is missing from a cache key, the optimization becomes a data-leak candidate. That is worse than being slow.

I built bluetape4k-exposed not because Exposed solves every problem, but because it does not try to. Exposed keeps SQL visible and lets Kotlin’s type system and DSL make it safer. bluetape4k-exposed adds repositories, cache, JSON and encryption columns, dialects, and Spring Boot wiring around that center.

The selection rule is:

  • Use SQL through a Kotlin DSL instead of string SQL.
  • Choose JDBC + Virtual Threads or R2DBC + Coroutines by workload.
  • Avoid rebuilding cache, JSON, encryption, dialect, and Spring Boot wiring for every service.
  • Check performance and production boundaries through benchmarks and workshop examples.

Next, Part 2 looks at JDBC repositories and SQL DSL patterns. The repository layer exists not to hide SQL DSL, but to avoid rebuilding mapping, paging, and soft-delete boundaries for every table.

Comments

Leave a note or reaction with your GitHub account.