Ktor Integration
Storm integrates with Ktor through a dedicated plugin that handles DataSource lifecycle, configuration, and ORM access. Because Ktor is coroutine-first and Storm's Kotlin API already provides a suspend-friendly transaction { } function, the integration is lightweight: no custom transaction infrastructure or SPI providers are needed.
The integration follows Ktor's plugin-based architecture. You install(Storm) like any other Ktor plugin, configure it through HOCON or programmatically, and access the ORM through extension properties on Application, ApplicationCall, and RoutingContext. Storm handles connection pooling, transaction propagation, and DataSource lifecycle automatically.
Installation
Add the Storm Ktor module alongside your core Storm dependencies:
dependencies {
implementation(platform("st.orm:storm-bom:1.13.0"))
implementation("st.orm:storm-kotlin")
implementation("st.orm:storm-ktor")
runtimeOnly("st.orm:storm-core")
ksp("st.orm:storm-metamodel-ksp")
kotlinCompilerPluginClasspath("st.orm:storm-compiler-plugin-2.0")
// Connection pooling (recommended)
implementation("com.zaxxer:HikariCP:6.2.1")
// Database dialect (pick yours)
runtimeOnly("st.orm:storm-postgresql")
// Testing
testImplementation("st.orm:storm-ktor-test")
testImplementation("com.h2database:h2")
}
Storm integrates with Ktor's built-in dependency injection out of the box (see Dependency Injection); if your project uses Koin instead, see Using with Koin.
Plugin Setup
Install the Storm plugin in your Ktor application module. The plugin creates an ORMTemplate and manages the DataSource lifecycle. There are two ways to provide a DataSource: let the plugin create one from your HOCON configuration, or pass one explicitly.
Zero-Configuration Setup
When you call install(Storm) without providing a DataSource, the plugin reads the storm.datasource section from application.conf and creates a HikariCP connection pool automatically. This is the recommended approach for most applications, as it keeps database configuration external to your code and environment-specific.
fun Application.module() {
install(Storm)
routing {
get("/users/{id}") {
val user = entity<User, _>().findById(call.parameters.getOrFail("id").toInt())
call.respond(user ?: HttpStatusCode.NotFound)
}
}
}
# application.conf
storm {
datasource {
jdbcUrl = "jdbc:postgresql://localhost:5432/mydb"
driverClassName = "org.postgresql.Driver"
username = "dbuser"
password = "dbpass"
maximumPoolSize = 10
}
}
The plugin reads Storm ORM properties from the same storm section (see Configuration below).
Explicit DataSource
If you need full control over connection pool configuration, or want to reuse a DataSource from another library, pass it directly. This is useful when integrating with existing infrastructure or when HikariCP configuration goes beyond what HOCON properties cover.
fun Application.module() {
val hikariConfig = HikariConfig().apply {
jdbcUrl = "jdbc:postgresql://localhost:5432/mydb"
username = "dbuser"
password = "dbpass"
maximumPoolSize = 10
}
install(Storm) {
dataSource = HikariDataSource(hikariConfig)
}
}
Plugin Configuration Options
The Storm plugin accepts several configuration options through its DSL:
install(Storm) {
// DataSource: auto-created from HOCON if not provided
dataSource = myDataSource
// Storm config: auto-read from HOCON if not provided
config = StormConfig.of(mapOf(UPDATE_DEFAULT_MODE to "FIELD"))
// Schema migrations: runs after the DataSource is available, before validation
migration { dataSource ->
Flyway.configure().dataSource(dataSource).load().migrate()
}
// Schema validation: "none", "warn", or "fail".
// When omitted, read from storm.validation.schemaMode in the application config; defaults to "fail".
schemaValidation = "warn"
// Entity callbacks for lifecycle hooks
entityCallback(AuditCallback())
// Repository auto-registration (enabled by default; see Repository Registration below)
repositories("com.myapp.repository") // optional: narrow to specific packages
// autoRegisterRepositories = false // optional: disable auto-registration
}
| Option | Default | Description |
|---|---|---|
dataSource | Created from HOCON | The JDBC DataSource to use. When omitted, a HikariCP pool is created from storm.datasource.* in application.conf. |
config | Read from HOCON | A StormConfig with ORM properties. When omitted, properties are read from storm.* in application.conf. |
migration(...) | None | A hook that runs after the DataSource is available and before schema validation. The place for Flyway or Liquibase migrations, guaranteeing validation sees the migrated schema. |
schemaValidation | From config, else "fail" | Validates entity definitions against the database schema during installation. "fail" (the default) blocks startup on mismatches; "warn" logs them; "none" opts out. When not set, the mode is read from storm.validation.schemaMode in the application configuration. |
entityCallback(...) | None | Registers entity lifecycle callbacks for insert, update, and delete operations. |
autoRegisterRepositories | true | Registers all repository interfaces from the compile-time type index during installation, so repository<T>() works without further setup. |
repositories(...) | All indexed | Narrows repository auto-registration to the given packages (including sub-packages). |
When the application stops, the plugin automatically closes the DataSource if it is a HikariDataSource that the plugin created. If you provide your own DataSource, manage its lifecycle yourself.
Accessing the ORM
The ORMTemplate is stored in the application's attributes and accessible through extension properties. Storm provides extensions on three types, so you can pick the most convenient access pattern depending on where you are in the code:
| Extension | Available in | Example |
|---|---|---|
Application.orm | Application setup, plugins | application.orm.entity<User>() |
ApplicationCall.orm | Route handlers | call.orm.entity<User>() |
RoutingContext.orm | Route handlers (implicit this) | orm.entity<User>() |
// In route handlers via ApplicationCall (explicit)
get("/users") {
val users = call.orm.entity<User>()
call.respond(users.findAll())
}
// In route handlers via RoutingContext (implicit, most concise)
routing {
get("/users") {
val users = orm.entity<User>()
call.respond(users.findAll())
}
}
// From the Application object (during setup)
val orm = application.orm
Why a Single Instance Works
Unlike JPA's EntityManager (which is session-scoped and must be opened/closed per request), Storm's ORMTemplate is stateless. It does not hold connections, track entity state, or maintain a persistence context. A single application-wide instance is correct.
Connection scoping happens automatically:
- Outside transactions: each ORM operation acquires a connection from the pool, executes the query, and returns the connection immediately.
- Inside
transaction { }blocks: a single connection is held for the duration of the transaction and propagated through the coroutine context, ensuring all operations within the block share the same connection and transaction.
This means there is no connection-per-request interceptor and no risk of connection leaks from forgotten close calls.
Transaction Management
Storm's Kotlin transaction API works directly in Ktor route handlers because both are coroutine-based. There is no need for annotations, proxies, or additional transaction infrastructure. You call transaction { } and Storm handles the connection, commit, and rollback lifecycle.