Tutorials/Exposed vs Storm: Ktor

Ktor services, side by side

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.

Series · Exposed to Storm5 min readKotlin

01The task

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.

02The Exposed way

Exposed connects a Database at startup and wraps route operations in transactions, with suspendTransaction keeping things coroutine-friendly:

Application.kt Kotlin · Exposed + Ktor
1
2
3
4
5
6
7
8
9
10
11
12
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.

03The Storm way

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:

Application.kt Kotlin · Storm + Ktor
1
2
3
4
5
6
7
8
9
10
11
12
13
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:

Routes.kt Kotlin · Storm + Ktor
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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.

04The translation table

Exposed + KtorStorm + Ktor
Database.connect(dataSource) at startupinstall(Storm), pool from application.conf
suspendTransaction { } around every operationReads bare; transaction { } where atomicity matters
Query logic in table objects and DAO companionsRepository interfaces, auto-registered at install, fetched via repository<T>()
Results used inside the transaction (DAO) or mapped outPlain values, returned and serialized directly
Side effects after commit by handonCommit { } in the block

05Keep going

The reference documentation covers the mechanics in depth: