Start with an empty project and finish with a running, tested REST API: two entities, their relationship loaded in a single query, CRUD routes over Ktor, and a test that asserts the exact SQL. About twenty minutes end to end.
Start a plain Kotlin/Gradle project and add Ktor and Storm. The Ktor plugin manages the Ktor artifact versions, and the Storm BOM manages Storm's, so most dependencies need no version. We use H2 so there is nothing to install.
// build.gradle.kts plugins { kotlin("jvm") version "2.0.21" id("io.ktor.plugin") version "3.0.3" id("com.google.devtools.ksp") version "2.0.21-1.0.28" } dependencies { implementation(platform("st.orm:storm-bom:1.12.0")) implementation("st.orm:storm-kotlin") implementation("st.orm:storm-ktor") runtimeOnly("st.orm:storm-core") runtimeOnly("st.orm:storm-h2") runtimeOnly("com.h2database:h2:2.2.224") ksp("st.orm:storm-metamodel-ksp") kotlinCompilerPluginClasspath("st.orm:storm-compiler-plugin-2.0") implementation("io.ktor:ktor-server-netty") implementation("io.ktor:ktor-server-content-negotiation") implementation("io.ktor:ktor-serialization-jackson") implementation("st.orm:storm-jackson2") testImplementation("st.orm:storm-ktor-test") testImplementation("com.h2database:h2") }
Storm maps to an existing schema rather than generating one, which keeps migrations under your control. For this tutorial a small script is enough; H2 runs it on startup, so there is no migration tool to set up yet.
-- src/main/resources/schema.sql CREATE TABLE folder ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL ); CREATE TABLE bookmark ( id INT PRIMARY KEY AUTO_INCREMENT, url VARCHAR(2000) NOT NULL, title VARCHAR(200) NOT NULL, folder_id INT NOT NULL REFERENCES folder(id) ); -- one folder to start with INSERT INTO folder (name) VALUES ('Reading');
Two immutable data classes. A Bookmark belongs to a Folder; marking that reference @FK is the whole relationship. Field names map to columns automatically, so folder becomes the folder_id column.
// Folder.kt data class Folder( @PK val id: Int = 0, val name: String, ) : Entity<Int> // Bookmark.kt — @FK means the folder is loaded in the same query data class Bookmark( @PK val id: Int = 0, val url: String, val title: String, @FK val folder: Folder, ) : Entity<Int>
The Storm Ktor plugin reads its DataSource from application.conf. No wiring code: install(Storm) builds a connection pool and, because the metamodel processor already indexed them, registers your repositories.
# src/main/resources/application.conf storm { datasource { jdbcUrl = "jdbc:h2:mem:bookmarks;DB_CLOSE_DELAY=-1;INIT=RUNSCRIPT FROM 'classpath:schema.sql'" username = "sa" password = "" } validation { schemaMode = "warn" } # log any entity/schema mismatch }
Extend EntityRepository and every CRUD method comes for free. Add your own one-line queries on top; Bookmark_ is the compile-time metamodel, so a typo in a field name fails to compile.
// BookmarkRepository.kt — CRUD is inherited; add only your own queries interface BookmarkRepository : EntityRepository<Bookmark, Int> { fun findByFolderName(name: String): List<Bookmark> = findAll(Bookmark_.folder.name eq name) }
A standard Ktor main and module. Install Storm, register Storm's Jackson module so entities serialize cleanly, and mount the routes.
// Application.kt fun main() { embeddedServer(Netty, port = 8080) { module() }.start(wait = true) } fun Application.module() { install(Storm) // reads storm.datasource; auto-registers repositories install(ContentNegotiation) { jackson { registerModule(StormModule()) } } routing { bookmarkRoutes() } }
Read straight from the ORM, write inside transaction { }. Because Ktor and Storm are both coroutine-based, the transaction rides the request's coroutine with no proxies or annotations. call.orm and repository<T>() are extensions available right in the handler.
// Routes.kt @JsonIgnoreProperties(ignoreUnknown = true) data class NewBookmark(val url: String, val title: String, val folderId: Int) fun Route.bookmarkRoutes() { get("/bookmarks") { call.respond(repository<BookmarkRepository>().findAll()) } get("/bookmarks/{id}") { val id = call.parameters.getOrFail("id").toInt() val bookmark = call.orm.entity<Bookmark>().findById(id) call.respond(bookmark ?: HttpStatusCode.NotFound) } post("/bookmarks") { val body = call.receive<NewBookmark>() val folder = call.orm.entity<Folder>().findById(body.folderId) if (folder == null) { call.respond(HttpStatusCode.BadRequest, "unknown folder"); return@post } val created = transaction { call.orm insert Bookmark(url = body.url, title = body.title, folder = folder) } call.respond(HttpStatusCode.Created, created) } delete("/bookmarks/{id}") { val id = call.parameters.getOrFail("id").toInt() transaction { call.orm.entity<Bookmark>().removeById(id) } call.respond(HttpStatusCode.NoContent) } }
The list endpoint is where Storm earns its keep. One call, one query: the folder for every bookmark is joined in, so there is no N+1 to discover later. Toggle Show SQL to see exactly what runs.
// GET /bookmarks returns every bookmark, each with its folder repository<BookmarkRepository>().findAll()
-- one query, no N+1: the folder is joined in SELECT b.id, b.url, b.title, f.id, f.name FROM bookmark b INNER JOIN folder f ON b.folder_id = f.id
Start the server and exercise it with curl. The created bookmark comes back with its full folder object, loaded in the same query that fetched the bookmark.
# start the app ./gradlew run # create a bookmark in folder 1 curl -s -X POST localhost:8080/bookmarks \ -H 'content-type: application/json' \ -d '{"url":"https://orm.st","title":"Storm","folderId":1}' # list them back — each bookmark includes its folder object curl -s localhost:8080/bookmarks
storm-ktor-test spins up an in-memory database from your schema script and captures the SQL. Here we assert the create path is a single INSERT, so an accidental N+1 or extra round-trip fails the build rather than slipping into production.
// BookmarkRoutesTest.kt @Test fun `POST creates a bookmark with a single insert`() = testStormApplication( scripts = listOf("/schema.sql"), ) { scope -> application { install(Storm) { dataSource = scope.stormDataSource } install(ContentNegotiation) { jackson { registerModule(StormModule()) } } routing { bookmarkRoutes() } } scope.stormSqlCapture.run { client.post("/bookmarks") { contentType(ContentType.Application.Json) setBody("""{"url":"https://orm.st","title":"Storm","folderId":1}""") } } assertEquals(1, scope.stormSqlCapture.count(Operation.INSERT)) }
An empty folder to a running, tested API: two entities, a joined relationship, four routes, and a repository, with no persistence context, no proxies, and no N+1. From here: