Install Storm, define two linked entities as plain data classes, and query across the relation in one type-safe line. No persistence context, no proxies, no XML. Here is the whole path.
Apply the Storm Gradle plugin. It imports the BOM and wires the core dependencies, the metamodel processor, and the Kotlin compiler plugin, so all that is left for a runnable project is an in-memory H2 database.
// build.gradle.kts plugins { kotlin("jvm") version "2.0.21" id("com.google.devtools.ksp") version "2.0.21-1.0.28" id("st.orm") version "1.13.1" } dependencies { runtimeOnly("st.orm:storm-h2") // zero-setup in-memory database runtimeOnly("com.h2database:h2:2.3.232") }
// build.gradle.kts plugins { kotlin("jvm") version "2.1.21" id("com.google.devtools.ksp") version "2.1.21-2.0.2" id("st.orm") version "1.13.1" } dependencies { runtimeOnly("st.orm:storm-h2") // zero-setup in-memory database runtimeOnly("com.h2database:h2:2.3.232") }
// build.gradle.kts plugins { kotlin("jvm") version "2.2.21" id("com.google.devtools.ksp") version "2.2.21-2.0.5" id("st.orm") version "1.13.1" } dependencies { runtimeOnly("st.orm:storm-h2") // zero-setup in-memory database runtimeOnly("com.h2database:h2:2.3.232") }
// build.gradle.kts plugins { kotlin("jvm") version "2.3.21" id("com.google.devtools.ksp") version "2.3.10" id("st.orm") version "1.13.1" } dependencies { runtimeOnly("st.orm:storm-h2") // zero-setup in-memory database runtimeOnly("com.h2database:h2:2.3.232") }
// build.gradle.kts plugins { kotlin("jvm") version "2.4.0" id("com.google.devtools.ksp") version "2.3.10" id("st.orm") version "1.13.1" } dependencies { runtimeOnly("st.orm:storm-h2") // zero-setup in-memory database runtimeOnly("com.h2database:h2:2.3.232") }
The plugin generates the type-safe metamodel (Movie_) through KSP and makes SQL templates injection-safe by default through the compiler plugin, with no extra dependencies to declare. On a real database, swap storm-h2 for your dialect and add its JDBC driver. See the installation guide for all options.
Working with an AI coding assistant? One command, run from the root of your project's workspace, installs Storm-aware rules and skills for it (Claude, Cursor, Copilot, Windsurf, Codex) and sets up a schema-aware MCP server, so the entities and queries it generates match your real schema.
# from the root of your project's workspace npx @storm-orm/cli init
Entities are plain immutable data classes that implement Entity<ID>. Field names map to columns automatically (camelCase to snake_case), and a relation is just a @FK field typed as the entity it points to. There is no base class to extend and no generated draft type to route through: the classes you write are the objects you get back.
// Entities.kt: plain data classes. This is the whole data layer. data class Director( @PK val id: Int = 0, val name: String, ) : Entity<Int> // A relation is a @FK field typed as the entity it points to. data class Movie( @PK val id: Int = 0, val title: String, val year: Int, val rating: Double, @FK val director: Director, ) : Entity<Int>
Storm maps to an existing schema rather than creating one, so define the tables with your migration tool (Flyway, Liquibase) or a one-off DDL script; pick your database in the block below. Storm can verify at startup that your entities match it with validateSchema().
-- create the tables with your migration tool, or run this once CREATE TABLE director ( id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name VARCHAR(100) ); CREATE TABLE movie ( id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, title VARCHAR(200), year INT, rating DOUBLE PRECISION, director_id INT REFERENCES director(id) );
-- create the tables with your migration tool, or run this once CREATE TABLE director ( id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name VARCHAR(100) ); CREATE TABLE movie ( id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, title VARCHAR(200), year INT, rating DOUBLE PRECISION, director_id INT REFERENCES director(id) );
-- create the tables with your migration tool, or run this once CREATE TABLE director ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) ); CREATE TABLE movie ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200), year INT, rating DOUBLE, director_id INT, FOREIGN KEY (director_id) REFERENCES director(id) );
-- create the tables with your migration tool, or run this once CREATE TABLE director ( id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name VARCHAR2(100) ); CREATE TABLE movie ( id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, title VARCHAR2(200), year NUMBER, rating BINARY_DOUBLE, director_id NUMBER REFERENCES director(id) );
-- create the tables with your migration tool, or run this once CREATE TABLE director ( id INT IDENTITY PRIMARY KEY, name VARCHAR(100) ); CREATE TABLE movie ( id INT IDENTITY PRIMARY KEY, title VARCHAR(200), year INT, rating FLOAT, director_id INT REFERENCES director(id) );
-- create the tables with your migration tool, or run this once CREATE TABLE director ( id INTEGER PRIMARY KEY, name TEXT ); CREATE TABLE movie ( id INTEGER PRIMARY KEY, title TEXT, year INTEGER, rating REAL, director_id INTEGER REFERENCES director(id) );
Open an ORMTemplate on your DataSource, insert a few records, and filter movies by their director's name in one type-safe line. Toggle Show SQL to see exactly what Storm runs: a single statement with the join included, fully parameterized, no N+1.
// Open an ORM on any JDBC DataSource. Thread-safe; create it once. val orm = dataSource.orm // Insert returns a copy with the generated id. val nolan = orm insert Director(name = "Christopher Nolan") val villeneuve = orm insert Director(name = "Denis Villeneuve") orm insert Movie(title = "Interstellar", year = 2014, rating = 8.7, director = nolan) orm insert Movie(title = "Oppenheimer", year = 2023, rating = 8.3, director = nolan) orm insert Movie(title = "Dune: Part Two", year = 2024, rating = 8.5, director = villeneuve) // A repository for Movie: every CRUD method included, nothing to implement. val movies = orm.entity<Movie>() // One type-safe line across the relation. Movie_ is generated at compile time, // so a typo in the path fails to compile. val byChris = movies.findAll(Movie_.director.name like "Chris%") // The director is loaded in the same query: no proxies, no N+1. val director = byChris.first().director.name
-- orm insert Movie(...) INSERT INTO movie (title, year, rating, director_id) VALUES (?, ?, ?, ?) -- movies.findAll(Movie_.director.name like "Chris%") -- one statement, join included: the whole graph, no N+1 SELECT m.id, m.title, m.year, m.rating, d.id, d.name FROM movie m INNER JOIN director d ON m.director_id = d.id WHERE d.name LIKE ?
That is the core loop. Insert, findById, update, and remove all come for free on the repository; add your own one-line queries whenever you need them.
You have the whole shape of Storm in three steps. From here:
Something not work, or not make sense? Tell us on Discord.