Tutorials/Sealed entities

Sealed entity hierarchies

Kotlin already has the perfect tool for closed type hierarchies: sealed interfaces with exhaustive when. Storm maps them straight onto a table, so the compiler's exhaustiveness checking extends to your database rows.

Series · The Storm way4 min readKotlin

01The task

A pet table holds cats and dogs with some shared columns and some subtype-specific ones. The code should work with real Cat and Dog types, not a Pet with nullable everything, and adding a new subtype should be impossible to half-do.

02The model

Declare the hierarchy exactly as you would in domain code. The sealed interface is the entity; the data classes are the subtypes; @Discriminator marks the column that tells rows apart:

Pet.kt Kotlin · Storm
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// The sealed interface is the entity; subtypes are plain data classes
@Discriminator
sealed interface Pet : Entity<Int>

data class Cat(
    @PK val id: Int = 0,
    val name: String,
    val indoor: Boolean,
) : Pet

data class Dog(
    @PK val id: Int = 0,
    val name: String,
    val weight: Int,
) : Pet

All subtypes share the pet table. Shared fields like name live alongside subtype-specific ones like indoor and weight, which are simply NULL for rows of other subtypes.

03Reads and writes

The repository works on the sealed interface, and results come back as concrete subtypes. That means when over a result is exhaustive: introduce a Bird and every unhandled branch in the codebase becomes a compile error:

PetService.kt Kotlin · Storm Show SQL
1
2
3
4
5
6
7
8
9
10
11
12
13
val pets = orm.entity<Pet>()

// Reads return the concrete subtypes; when is exhaustive by construction
for (pet in pets.findAll()) {
    when (pet) {
        is Cat -> render("${pet.name}, indoor=${pet.indoor}")
        is Dog -> render("${pet.name}, ${pet.weight}kg")
    }   // add a Bird subtype and this stops compiling until handled
}

// Writes go through the same repository
pets.insert(Cat(name = "Bella", indoor = true))
pets.update(Cat(id = 1, name = "Sir Whiskers", indoor = true))
generated sql
-- one table; the discriminator column picks the subtype on read
SELECT p.id, p.dtype, p.name, p.indoor, p.weight FROM pet p

-- inserts write only the columns of the concrete subtype
INSERT INTO pet (dtype, name, indoor) VALUES ('Cat', ?, ?)
INSERT INTO pet (dtype, name, weight) VALUES ('Dog', ?, ?)

On insert and update Storm inspects the runtime class, writes the discriminator, and includes only that subtype's columns. On select it reads the discriminator and constructs the right type. No casting, no manual type column handling.

04Querying the hierarchy

Shared fields query through the metamodel as usual; subtype refinement is ordinary Kotlin on the results:

PetService.kt Kotlin · Storm
1
2
3
4
5
6
// Query the hierarchy like any other entity
val indoorCats = orm.entity<Pet>().select()
    .where(Pet_.name like "B%")
    .resultList
    .filterIsInstance<Cat>()
    .filter { it.indoor }

This page covers the single-table strategy, the default. When subtypes have many disjoint fields, the joined-table strategy (@Polymorphic(JOINED)) puts each subtype's columns in its own table; see Polymorphism for the full decision guide, discriminator customization, and polymorphic foreign keys.

05Keep going

The reference documentation covers the mechanics in depth: