Tutorials/Mapped collections

Mapped collections vs queried associations

A mapped collection and a queried association are each one line of code. The difference is what the line tells you: Storm's says when it runs, what it loads, and how to filter and page it.

Series · JPA to Storm6 min readKotlin
JPA
val pets = owner.pets
Storm
val pets = orm.findAll(Pet_.owner eq owner)

01The task

An owner has pets; a user has roles through a join table. Show the pets of an owner and the roles of a user, the two classic association shapes: one-to-many and many-to-many.

02The JPA way

JPA maps both associations onto the entity, and afterwards navigation is just Kotlin:

Entities.kt Kotlin · JPA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Entity
class Owner(
    @Id @GeneratedValue var id: Int? = null,
    var name: String = "",

    @OneToMany(mappedBy = "owner")
    var pets: MutableList<Pet> = mutableListOf(),
)

@Entity
class User(
    @Id @GeneratedValue var id: Int? = null,
    var name: String = "",

    @ManyToMany
    @JoinTable(name = "user_role")
    var roles: MutableSet<Role> = mutableSetOf(),
)

// Navigation is a property access. This is genuinely convenient.
val pets = owner.pets
val roles = user.roles

No query in sight, and nobody should pretend that is not convenient. The costs arrive later, and they are all variations of one fact: owner.pets is an unbounded query hiding behind a property. It runs on first access, per owner, which is the N+1 pattern in a loop. It always loads the whole collection: showing ten of two thousand pets loads two thousand. The list it returns is a lazy proxy bound to the session, with the familiar failure mode outside it. And in practice, the moment you need the association filtered, sorted, or paged, you write petRepository.findByOwner(owner, pageable) anyway, at which point the mapped collection is no longer carrying the load. The write side adds its own decisions: cascade types and orphan removal now govern what a mutation of the collection means.

03The Storm way: one-to-many

Storm keeps entities stateless, so there is no collection on the one side. The association lives where it lives in the schema, on the many side, and reading it is a query:

PetService.kt Kotlin · Storm Show SQL
1
2
3
4
5
6
7
8
9
// The many side owns the relation, as it does in the schema
data class Pet(
    @PK val id: Int = 0,
    val name: String,
    @FK val owner: Owner,
) : Entity<Int>

// The "collection" is a query: one line, loaded when you ask
val pets = orm.findAll(Pet_.owner eq owner)
generated sql
-- runs when you ask, loads what you asked for
SELECT p.id, p.name, o.id, o.name
FROM pet p
INNER JOIN owner o ON p.owner_id = o.id
WHERE p.owner_id = ?

One line instead of one property, and the line buys three things: it runs when you decide, it loads exactly what you asked, and it composes. Filtering, sorting, and paging the association need no second mechanism, because the association already is a query:

PetService.kt Kotlin · Storm
1
2
3
4
5
6
// Because the association is a query, it composes
val firstTen = orm.entity<Pet>().select()
    .where(Pet_.owner eq owner)
    .orderBy(Pet_.name)
    .limit(10)
    .resultList

04The Storm way: many-to-many

For many-to-many, Storm models the join table as what it is in the database, an entity with a composite key:

UserRole.kt Kotlin · Storm Show SQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// The join table is a real entity with a composite key
data class UserRolePk(val userId: Int, val roleId: Int)

data class UserRole(
    @PK val userRolePk: UserRolePk,
    @FK @Persist(insertable = false, updatable = false) val user: User,
    @FK @Persist(insertable = false, updatable = false) val role: Role,
) : Entity<UserRolePk>

// Roles of a user, through the join entity ...
val roles = orm.findAll(UserRole_.user eq user).map { it.role }

// ... or joined straight onto Role
val sameRoles = orm.entity<Role>().select()
    .innerJoin<UserRole>().on<Role>()
    .whereAny(UserRole_.user eq user)
    .resultList
generated sql
-- the second form: one query, no intermediate list
SELECT r.id, r.name
FROM role r
INNER JOIN user_role ur ON ur.role_id = r.id
WHERE ur.user_id = ?

This is more code than @ManyToMany plus @JoinTable, no argument. Two things pay it back. Writes are explicit inserts and deletes on UserRole, with no cascade semantics to configure or debug. And the join table is a real type from day one, so when it inevitably grows columns, grantedAt, grantedBy, an expiry, you add fields to an entity you already have. JPA teams know that moment: it is when @ManyToMany gets refactored into exactly this shape. Storm starts where mapped collections end up.

05The trade, stated plainly

JPA mapped collectionsStorm queried associations
Navigationowner.pets, one propertyorm.findAll(Pet_.owner eq owner), one line
When it loadsOn first access, the whole collection, per ownerWhen you ask, exactly what you asked
Filter, sort, pageA separate repository query in practiceThe same query, composed
Join tableHidden until it needs columns, then refactoredAn entity from day one
Failure modesLazy proxies, session scope, cascade surprisesNone specific; plain queries, plain values

If your associations are small, always loaded whole, and never leave the session, JPA's property is genuinely convenient and stays that way. Storm's position is that associations rarely stay that way, and a query is the shape that survives growth without changing form.

06Keep going

The reference documentation covers the mechanics in depth: