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.
val pets = owner.pets
val pets = orm.findAll(Pet_.owner eq owner)
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.
JPA maps both associations onto the entity, and afterwards navigation is just Kotlin:
@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.
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:
// 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)
-- 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:
// 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
For many-to-many, Storm models the join table as what it is in the database, an entity with a composite key:
// 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
-- 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.
| JPA mapped collections | Storm queried associations | |
|---|---|---|
| Navigation | owner.pets, one property | orm.findAll(Pet_.owner eq owner), one line |
| When it loads | On first access, the whole collection, per owner | When you ask, exactly what you asked |
| Filter, sort, page | A separate repository query in practice | The same query, composed |
| Join table | Hidden until it needs columns, then refactored | An entity from day one |
| Failure modes | Lazy proxies, session scope, cascade surprises | None 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.
The reference documentation covers the mechanics in depth: