The Java flavor on Java 21: immutable record entities, metamodel-based queries, Spring-managed transactions, and schema validation. No JPA, no proxies, no persistence context.
An example movie browser built with Storm ORM on Spring Boot 4 and Java 21. It imports the public IMDB dataset into PostgreSQL and serves a server-rendered web app (Thymeleaf + a little vanilla JS) for browsing movies, people, genres, ratings, and a watchlist.
The project exists to show what idiomatic Storm looks like in a real Spring Boot application: immutable record entities, metamodel-based queries, Spring-managed transactions, and schema validation. No JPA, no proxies, no persistence context.
storm-spring-boot-starter + storm-java21) with the
annotation-processor metamodel generator and RAW SQL string templatesstorm-jackson3) for the JSON APIs and cache values, including
Storm's Ref serializationstorm-test on H2 for repository tests, Playwright for interface testsStorm's Java API builds SQL with JDK String Templates (JEP 430), a
preview feature that shipped in Java 21 and 22 and was then withdrawn. The
project therefore pins the toolchain to Java 21 specifically and enables
preview features everywhere: --enable-preview is wired into every
JavaCompile task, every Test task, and bootRun in build.gradle.kts. The
app must be built and run on a Java 21 JDK; later JDKs no longer have the
feature. The RAW."... \{expression} ..." syntax you see in the repositories is
that preview feature in action; the interpolated \{...} values are separated
from the SQL fragments at compile time, so the templates are injection-safe by
construction.
String templates were withdrawn for a redesign, not abandoned: Project Amber is reworking the feature, and a revised proposal is expected to return to the JDK. Storm deliberately ships String Template support today rather than waiting: the Java API is production-ready, and its template syntax will track the redesigned feature as it lands. Once string templates return to the JDK as a stable feature, the Java API moves front and center alongside Kotlin, without preview flags or a version pin. Everything else in this example (entities, repositories, the metamodel, transactions) is stable Java and unaffected by the preview status.
Prerequisites: JDK 21 and Docker.
# 1. Start PostgreSQL
docker compose up -d
# 2. Start the application
./gradlew bootRun
# 3. Open the app
open http://localhost:8080
On first startup the app runs the Flyway migration and imports the IMDB
dataset: movies with at least 1,000 votes (configurable via
imdb.import.minimum-vote-count), plus their genres, cast, crew, and ratings.
The dataset files (~1.2 GB) are downloaded once and cached in ./data, then
streamed through Storm's batch inserts, so expect the first startup to take a
few minutes. The import is skipped entirely on subsequent startups once movie data
is present.
To start over with an empty database:
docker compose down -v
Movie posters, person photos, and plot summaries are fetched at runtime from the IMDB suggestion API and the Wikipedia REST API, so the app looks best with internet access.
src/main/java/st/orm/demo/imdb/
├── model/ Storm entities (@PK, @FK) and projections, as records
├── repository/ EntityRepository interfaces with QueryBuilder queries
├── service/ Business logic in Storm transaction(...) blocks,
│ plus the streaming IMDB importer
├── web/ MVC controllers (pages) and REST controllers (/api/**)
└── serialization/ Jackson support: custom serializers and the
JSON-serialized Spring cache
src/main/resources/
├── db/migration/ Flyway schema (V1__create_schema.sql)
├── templates/ Thymeleaf views
└── static/ CSS, JS, images
Each part of the app demonstrates a Storm feature:
model/): immutable records with @PK, @FK, @UK, and
composite keys (MovieGenre, Principal). MovieView and PersonGallery
hold their foreign keys as Ref, so writing one never joins the referenced
graph; MovieSummary / PersonSummary are database-view-style projections
that select a subset of columns.repository/): EntityRepository interfaces with default
methods using the type-safe QueryBuilder and generated metamodel
(Movie_.startYear, Principal_.person). Aggregations return plain records;
computed expressions use RAW SQL string templates with metamodel references.service/): Storm's programmatic transaction(...) API
at the service level, with TransactionOptions for the read-only request
boundaries. MovieService.viewMovie is the one declarative @Transactional
example: both run through the same Spring transaction manager, so they
compose freely. The gallery service keeps Spring's programmatic
TransactionTemplate for its split fetch/store boundary.application.yaml): every query and transaction is
reported as a Micrometer Observation (storm.query, storm.transaction),
surfaced by Actuator at /actuator/metrics/storm.query. Query observations
follow the OpenTelemetry database semantic conventions, and the trace
context rides along as a SQL comment on every statement.service/ImdbDataImporter.java): a Stream-based
pipeline that parses TSV rows into entities and hands them to Storm's batch
insert, one pass per file, without materializing entity lists.EntitySchemaValidationTest does the same in the test suite.serialization/, web/ API models): Storm entities
serialized with Jackson for the REST endpoints (Ref fields via
storm-jackson3, BigDecimal and Instant via field serializers), and a
Spring cache that stores values as serialized JSON to prove entities survive
the round-trip (CacheConfiguration.java)../gradlew test
Repository tests run on an in-memory H2 database via @StormTest, so no
Docker is required. Tests receive an ORMTemplate and a SqlCapture as parameters, so
they can assert on the SQL Storm generates.
The Playwright interface tests run against a live application:
./gradlew installPlaywrightBrowsers # once
./gradlew bootRun # in one terminal
./gradlew e2eTest # in another
Everything lives in src/main/resources/application.yaml. The defaults match
the Compose file (database imdb, user/password storm on localhost:5432).
Import behavior is tunable under imdb.import (cache directory, minimum vote
count, dataset base URL).