Most of an application only reads. A Storm projection models that read-only view of the data as a first-class type, sometimes over a table, sometimes over a database view, with the write API gone by construction.
An owners list screen needs three columns of the owner table. A reporting screen reads from a database view the DBA maintains. Neither should ever write, and both deserve to be modeled as real types with real queries, not as ad-hoc result shapes scattered across repository methods.
JPA has entities, and entities are read-write. The usual approximation of a read model is an entity mapped over a view, marked immutable:
// The closest JPA gets: an entity over a view, made read-only by convention @Entity @Immutable // Hibernate-specific, not JPA @Table(name = "owner_address_view") class OwnerAddressView( @Id var id: Int? = null, var fullName: String = "", var address: String = "", var city: String = "", )
It works, but read-onliness is a runtime convention, not a property of the type. @Immutable is Hibernate-specific, the instances still live in the persistence context, and updates are silently skipped rather than rejected by the compiler. For the other half of the task, a narrow read-only slice of a table, there is no good answer at all: mapping a second entity onto the same table leaves the persistence context managing two disconnected representations of the same row, both of them writable.
Storm separates the concept. An Entity is read-write; a Projection is a read-only view on the data. It can map a subset of a table's columns:
// A read-only view on the owner table: just these columns, no writes. @DbTable("owner") data class OwnerSummary( @PK val id: Int, val firstName: String, val lastName: String, ) : Projection<Int>
Or a database view:
// Database views map the same way; the class name matches the view here, // so no @DbTable is needed (OwnerAddressView -> owner_address_view) data class OwnerAddressView( @PK val id: Int, val fullName: String, val address: String, val city: String, ) : Projection<Int>
Projections get their own repository with the full read API: findAll, findById, counting, paging, and the type-safe query builder. There is no insert, update, or delete to misuse; read-onliness is enforced by the type system, not by an annotation the runtime promises to respect:
val owners = orm.projection<OwnerSummary>().findAll() // Filters are type-checked against the generated metamodel. val smiths = orm.projection<OwnerSummary>().select() .where(OwnerSummary_.lastName, EQUALS, "Smith") .resultList
-- findAll(): only the projected columns leave the database SELECT o.id, o.first_name, o.last_name FROM owner o -- where(OwnerSummary_.lastName, EQUALS, "Smith") SELECT o.id, o.first_name, o.last_name FROM owner o WHERE o.last_name = ?
The record selects exactly its own fields, serializes directly to JSON with no proxy surprises, and OwnerSummary_.lastName is checked by the compiler against the generated metamodel.
Because a projection is a modeled type rather than a query result, the rest of the codebase can lean on it. Projections compose: other projections and entities can reference them with @FK, Storm joins them in the same query, and where predicates follow the nested path with compile-time checking:
@DbTable("pet") data class PetView( @PK val id: Int, val name: String, @FK val owner: OwnerSummary, // a projection nested in a projection ) : Projection<Int> // Predicates follow the nested path, checked at compile time val smithsPets = orm.projection<PetView>().select() .where(PetView_.owner.lastName, EQUALS, "Smith") .resultList
-- the nested projection joins in the same query, no N+1 SELECT p.id, p.name, o.id, o.first_name, o.last_name FROM pet p INNER JOIN owner o ON p.owner_id = o.id WHERE o.last_name = ?
The nested OwnerSummary instances are shared per id across the result list, so a thousand pets owned by fifty owners hydrate into fifty owner objects. See the N+1 tutorial for how that sharing works.
A projection can also be backed by SQL of its own via @ProjectionQuery and still behave like any other queryable type. Storm runs the annotated SQL as a derived table, so type-safe predicates work on top of it, including on the aggregate, with no HAVING gymnastics:
// A read model backed by SQL you control. @ProjectionQuery(""" SELECT o.id, o.first_name, o.last_name, COUNT(p.id) AS pet_count FROM owner o LEFT JOIN pet p ON p.owner_id = o.id GROUP BY o.id, o.first_name, o.last_name """) data class OwnerWithPetCount( @PK val id: Int, val firstName: String, val lastName: String, val petCount: Int, ) : Projection<Int> // Type-safe predicates apply on top, even on the aggregate val prolificOwners = orm.projection<OwnerWithPetCount>() .findAll(OwnerWithPetCount_.petCount greaterEq 3)
-- the projection query runs as a derived table; predicates apply on top SELECT x.id, x.first_name, x.last_name, x.pet_count FROM ( SELECT o.id, o.first_name, o.last_name, COUNT(p.id) AS pet_count FROM owner o LEFT JOIN pet p ON p.owner_id = o.id GROUP BY o.id, o.first_name, o.last_name ) x WHERE x.pet_count >= ?
| JPA with Hibernate | Storm | |
|---|---|---|
| Read-only concept | None in JPA; Hibernate's @Immutable by convention | Projection: the write API does not exist |
| Subset of a table | No good option; second entities conflict | @DbTable points the projection at the table |
| Database views | Entity over the view, still managed state | A projection, plain immutable records |
| Reuse | Entity semantics wherever it goes | Nests in other types, filters with typed predicates, own repository |
The reference documentation covers the mechanics in depth: