Stormify Stormify
Stormify Stormify

A Kotlin Multiplatform ORM, without the ceremony.

Plain Kotlin classes, convention over configuration. CRUD, transactions, lazy refs, paged queries — same API across JVM, Android, iOS, and native Linux/Windows/macOS.

8 platforms 5 databases natively 0 required annotations
// Plain class. Fields match columns by convention.
data class User(
    @DbField(primaryKey = true)
    var id: Int = 0,
    var email: String = "",
    var name: String = ""
)

val stormify = Stormify(dataSource)
// CRUD with no boilerplate
val user = stormify.create(User(email = "ada@example.com"))

val adults = stormify.read<User>(
    "SELECT * FROM user WHERE age > ?", 18)

user.name = "Ada Lovelace"
stormify.update(user)

stormify.delete(user)
// Nested transactions via savepoints
stormify.transaction {
    val user = create(User(email = "ada@example.com"))
    create(Profile(userId = user.id))

    transaction {
        create(AuditLog("signup", user.id))
        // rollback-safe savepoint
    }
}
Runs everywhere Kotlin runs
JVM · Java 11+ Android · API 21+ Linux · x64 & ARM64 Windows · x64 macOS · Apple Silicon & Intel iOS · device & simulator
Features

Everything an ORM should be. Nothing more.

No annotations required

Field-to-column by convention. Annotate only the exceptions.

CRUD out of the box

Create, read, update, delete — plus batched bulk variants.

JPA-compatible

@Id, @Table, @Column — drop in without rewriting.

Tunable conventions

Naming policies + custom primary-key resolvers for legacy schemas.

Raw SQL, first-class

Parameters auto-expand; map rows to objects, primitives, or maps.

Paged queries

PagedList for UI, PagedQuery for stateless REST.

Lazy references

by db() delegates load related entities on access.

Stored procedures

Call procedures with in/out/inout parameters natively.

Nested transactions

Savepoint-backed rollback/commit at any depth.

Bulk operations

Batched create/update/delete for high-throughput loads.

Composite keys

Multi-column primary keys without extra configuration.

Enums, your way

Stored as int or string, with custom mapping hooks.

Kotlin & Java

First-class idiomatic APIs on both: Stormify and StormifyJ.

Coroutines + pool

Suspend-based transactions, built-in pool, native cancel wiring.

Native DB access

PostgreSQL, MariaDB, Oracle, MSSQL, SQLite — no JVM, no JDBC.

Compile-time metadata

KSP-generated entity info on native, Android, iOS — no runtime reflection.

Quickstart

From zero to a working query.

Language
Connection
Query style
// Any JDBC DataSource — Hikari, DBCP, plain driver, anything
val stormify = Stormify(dataSource).asDefault()
// Native — no JVM, no JDBC, pure C-library driver
val stormify = Stormify(
    KdbcDataSource("jdbc:sqlite:/tmp/app.db")
).asDefault()
// Any JDBC DataSource — Hikari, DBCP, plain driver, anything
StormifyJ stormify = new StormifyJ(dataSource).asDefault();
val byId   = stormify.findById<User>(1)

val active = stormify.findAll<User>(
    "WHERE status = ?", "active")

val one    = stormify.readOne<User>(
    "SELECT * FROM user WHERE email = ?",
    "ada@example.com")
val byId   = findById<User>(1)

val active = findAll<User>(
    "WHERE status = ?", "active")

transaction {
    create(User(email = "ada@example.com"))
}
// String extension — the query string itself is the receiver
val one = "SELECT * FROM user WHERE id = ?"
    .readOne<User>(1)

val adults = "SELECT * FROM user WHERE age > ?"
    .read<User>(18)
// Explicit receiver — Java uses Class<T> parameters
User byId         = stormify.findById(User.class, 1);

List<User> active = stormify.findAll(User.class,
    "WHERE status = ?", "active");

User one          = stormify.readOne(User.class,
    "SELECT * FROM user WHERE email = ?",
    "ada@example.com");

Installation lives in the Getting Started guide — one dependency for your target plus annproc where reflection is unavailable.

Paged queries

Filters, sorts, facets — one page at a time.

Think product listings with thousands of rows, search-as-you-type, sortable columns, live totals. Describe the fields users can search and sort by — Stormify handles pagination, counts, and caching for you, whether you are feeding a table in a desktop app or answering JSON requests from a React frontend.

Language

PagedQuery · stateless REST

val customers = PagedQuery<Customer>().apply {
    addFacet("search", Customer_.name, Customer_.email).isSortable = false
    addFacet("name", Customer_.name)
    setConstraints("tenant_id = ?", tenantId)
}

// Per request — thread-safe, shares engine
val page = customers.execute(PageSpec(
    filters = mapOf("search" to req.q),
    sorts   = mapOf("name" to SortDir.ASC),
    page = req.page, pageSize = 25,
))
Json("items" to page.rows, "total" to page.total)
PagedQuery<Customer> customers = new PagedQuery<>(Customer.class);
customers.addFacet("search", Customer_.name, Customer_.email).setSortable(false);
customers.addFacet("name", Customer_.name);
customers.setConstraints("tenant_id = ?", tenantId);

// Per request — thread-safe, shares engine
Page<Customer> page = customers.execute(new PageSpec(
    req.page(), 25,
    Map.of("search", req.q()),
    Map.of("name", SortDir.ASC)
));
Json.of("items", page.getRows(), "total", page.getTotal());

PagedList · UI grid model

val list = PagedList<Company>()

list.addFacet(Company_.name)
list.addFacet(Company_.contactPerson.firstName,
              Company_.contactPerson.lastName)
list.addSqlFacet("SUM(order_total)", Facet.NUMERIC)

list.getFacet(0).filter = "Acme"
list.getFacet(1).sort = Facet.ASCENDING

// Consume as a normal List — loads pages lazily
val first = list[0]
val count = list.size
for (c in list) println(c.name)
PagedList<Company> list = new PagedList<>(Company.class);

list.addFacet(Company_.name);
list.addFacet(Company_.contactPerson().firstName,
              Company_.contactPerson().lastName);
list.addSqlFacet("SUM(order_total)", Facet.NUMERIC);

list.getFacet(0).setFilter("Acme");
list.getFacet(1).setSort(Facet.ASCENDING);

// Consume as a normal List — loads pages lazily
Company first = list.get(0);
int count = list.size();
for (Company c : list) System.out.println(c.getName());
Coroutines

Suspend transactions, cancellation that reaches the driver.

Built-in connection pool. Coroutine cancel → native Connection.cancel().

Suspend + pool

val async = stormify.suspending(PoolConfig(
    minConnections = 2,
    maxConnections = 10,
))

async.transaction {
    val user = create(User(email = "ada@example.com"))
    create(Profile(userId = user.id))

    transaction { // nested → savepoint
        create(AuditLog("signup", user.id))
    }
}

Cancellation → driver-native abort

val job = launch {
    async.transaction {
        // Long-running query — cancelling the coroutine
        // triggers libpq PQcancel / OCIBreak / sqlite3_interrupt
        read<Order>("SELECT ... FROM huge_table")
    }
}

delay(500)
job.cancel() // connection aborts at the DB level
Lazy delegates

Entities that load themselves on touch.

Load an order and you get an order — not its customer, not its line items, not every related record in a giant join. The moment your code actually reads one of those relationships, Stormify fetches just that. No upfront cost, no N+1 surprises, no boilerplate for loading what your view actually needs.

by db() — single entity, loaded once

class Order {
    @DbField(primaryKey = true) var id: Int = 0
    var total: Double by db(0.0)
    var customer: Customer by db()     // FK → parent
}

val order = stormify.findById<Order>(1)
println(order.customer.name)  // loads on access

by lazyDetails() — child collection

class Order {
    @DbField(primaryKey = true) var id: Int = 0
    var items: List<OrderItem> by lazyDetails()
}

val order = stormify.findById<Order>(1)
order.items.forEach { println(it.sku) }
// SELECT * FROM order_item WHERE order_id = ?
Examples

Pick a runtime. Clone. Run.

github.com/teras/stormify-examples →

ExampleWhat it showsRun
javaPOJOs with JPA + Stormify annotationsmvn compile exec:java
kotlin-jvmKotlin JVM with by db() delegatesgradle run
kotlin-linuxNative Linux binary, no JVMgradle runDebugExecutableLinuxX64
kotlin-windowsNative Windows (mingwX64)gradle runDebugExecutableMingwX64
kotlin-macosNative macOS, arm64 + x64gradle runDebugExecutableMacosArm64
kotlin-multiplatformShared code, JVM + nativegradle jvmRun
androidCompose, ViewModel, CRUD, enumsgradle :app:installDebug
iosiOS app on SQLiteOpen in Xcode
kotlin-restKtor REST API with paged queries (ships with a React frontend)gradle run
Comparison

How Stormify stacks up.

A side-by-side against the most common Kotlin and Java ORMs. See the full comparison for narrative context and when to pick each.

Stormify Exposed Ktorm Komapper SQLDelight Hibernate
Multiplatform · JVM + Android + native + iOS JVM + Android JVM JVM JVM
Native DB drivers · no JDBC required SQLite only
Facet-aware paged queries, built-in
Any class as entity
Accepts JPA annotations
Suspend / coroutines API
Lazy reference delegates DAO only eager only
Stored procedures (in/out/inout) manual manual