Ktor is home ground for both libraries: coroutine-native, annotation-free, Kotlin all the way down. The integration styles differ in where the ceremony sits.
A Ktor service with a read endpoint and a write endpoint: fetch a user by id, and create an order atomically with an inventory update, publishing an event only after the commit.
Exposed connects a Database at startup and wraps route operations in transactions, with suspendTransaction keeping things coroutine-friendly:
fun Application.module() { Database.connect(hikariDataSource()) routing { get("/users/{id}") { val user = suspendTransaction { // every operation needs a transaction User.findById(call.parameters.getOrFail("id").toInt()) } call.respond(user ?: HttpStatusCode.NotFound) } } }
This works well, and it is how a large share of Ktor services are built today. The ceremony is the transaction wrapper: Exposed requires one around every operation, including single reads, so it appears in every route.
Storm ships a Ktor plugin. install(Storm) builds the connection pool from application.conf and automatically registers every repository interface from the compile-time index, created eagerly so a broken definition fails at startup. Route handlers fetch them with a bare repository<T>(), no registration block, no lookup ceremony:
interface UserRepository : EntityRepository<User, Int> fun Application.module() { install(Storm) // pool from application.conf; repositories register automatically routing { get("/users/{id}") { val users = repository<UserRepository>() val user = users.findById(call.parameters.getOrFail("id").toInt()) call.respond(user ?: HttpStatusCode.NotFound) } } }
Repositories are stateless, so each read borrows a pooled connection just for the query; there is no transaction wrapper because none is needed. For quick endpoints, bare entity<User>() and projection<T>() extensions skip even the interface. Transactions appear where atomicity matters, and because they are suspend functions propagated through the coroutine context, they compose with Ktor naturally:
post("/orders") { val request = call.receive<CreateOrderRequest>() val orders = repository<OrderRepository>() val inventory = repository<InventoryRepository>() val order = transaction { // suspend-friendly, rides the coroutine context val order = orders.insertAndFetch(request.toOrder()) inventory.decrease(order.product, order.quantity) onCommit { events.publish(OrderCreated(order)) } order } call.respond(HttpStatusCode.Created, order) }
The onCommit callback keeps the event publication out of the transaction, so a rollback never announces an order that does not exist. See the transactions comparison for the full mapping.
| Exposed + Ktor | Storm + Ktor |
|---|---|
Database.connect(dataSource) at startup | install(Storm), pool from application.conf |
suspendTransaction { } around every operation | Reads bare; transaction { } where atomicity matters |
| Query logic in table objects and DAO companions | Repository interfaces, auto-registered at install, fetched via repository<T>() |
| Results used inside the transaction (DAO) or mapped out | Plain values, returned and serialized directly |
| Side effects after commit by hand | onCommit { } in the block |
The reference documentation covers the mechanics in depth: