Tutorials/Projections

Projections: read-only views on your data

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.

Series · JPA to Storm6 min readKotlin

01The task

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.

02The JPA way

JPA has entities, and entities are read-write. The usual approximation of a read model is an entity mapped over a view, marked immutable:

OwnerAddressView.kt Kotlin · JPA
1
2
3
4
5
6
7
8
9
10
// 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.

03The Storm way

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:

OwnerSummary.kt Kotlin · Storm
1
2
3
4
5
6
7
// 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:

OwnerAddressView.kt Kotlin · Storm
1
2
3
4
5
6
7
8
// 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:

OwnerService.kt Kotlin · Storm Show SQL
1
2
3
4
5
6
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
generated sql
-- 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.

04Read models are built for reuse

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:

PetView.kt Kotlin · Storm Show SQL
1
2
3
4
5
6
7
8
9
10
11
@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
generated sql
-- 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:

OwnerWithPetCount.kt Kotlin · Storm Show SQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 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)
generated sql
-- 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 >= ?
A projection is not a DTO trick; it is a modeled, reusable view on your data. When all you need is a custom result shape for one query, Storm has lighter tools: see Typed query results for case-by-case result mapping and SQL templates for one-off shapes inside full SQL.

05Side by side

JPA with HibernateStorm
Read-only conceptNone in JPA; Hibernate's @Immutable by conventionProjection: the write API does not exist
Subset of a tableNo good option; second entities conflict@DbTable points the projection at the table
Database viewsEntity over the view, still managed stateA projection, plain immutable records
ReuseEntity semantics wherever it goesNests in other types, filters with typed predicates, own repository

06Keep going

The reference documentation covers the mechanics in depth: