Skip to main content
Version: Next

Entities

Storm entities are simple data classes that map to database tables. By default, Storm applies sensible naming conventions to map entity fields to database columns automatically.


Defining Entities​

Use Kotlin data classes with the Entity interface:

data class City(
@PK val id: Int = 0,
val name: String,
val population: Long
) : Entity<Int>

data class User(
@PK val id: Int = 0,
val email: String,
val birthDate: LocalDate,
val street: String,
val postalCode: String?,
@FK val city: City
) : Entity<Int>

Entity Interface​

Implementing the Entity<ID> interface is optional but required for using EntityRepository with built-in CRUD operations. The type parameter specifies the primary key type. Without this interface, you can still use Storm's SQL template features and query builder, but you lose the convenience methods like findById, insert, update, and remove. If you only need read access, consider using Projection<ID> instead (see Projections).

Storm also supports polymorphic entity hierarchies using sealed interfaces. A sealed interface extending Entity can define multiple record subtypes, enabling Single-Table or Joined Table inheritance with compile-time exhaustive pattern matching. See Polymorphism for details.


Nullability​

Kotlin's type system maps directly to Storm's null handling. A non-nullable field produces an INNER JOIN for foreign keys and a NOT NULL expectation for columns. A nullable field produces a LEFT JOIN for foreign keys and allows NULL values from the database. This means your entity definition fully describes the expected schema constraints.

Use nullable types (?) to indicate nullable fields:

data class User(
@PK val id: Int = 0,
val email: String, // Non-nullable
val birthDate: LocalDate, // Non-nullable
val postalCode: String?, // Nullable
@FK val city: City? // Nullable (results in LEFT JOIN)
) : Entity<Int>

Primary Key Generation​

The @PK annotation supports a generation parameter that controls how primary key values are generated:

StrategyDescription
IDENTITYDatabase generates the key using an identity/auto-increment column (default)
SEQUENCEDatabase generates the key using a named sequence
NONENo generation; the caller must provide the key value

IDENTITY (default):

data class User(
@PK val id: Int = 0, // Database generates via auto-increment
val name: String
) : Entity<Int>

When inserting, Storm omits the PK column and retrieves the generated value:

val user = User(name = "Alice")
val inserted = orm.insert(user) // Returns User with generated id

SEQUENCE:

data class Order(
@PK(generation = SEQUENCE, sequence = "order_seq") val id: Long = 0,
val total: BigDecimal
) : Entity<Long>

Storm fetches the next value from the sequence before inserting.

NONE:

data class Country(
@PK(generation = NONE) val code: String, // Caller provides the value
val name: String
) : Entity<String>

Use NONE when:

  • The key is a natural key (like country codes or UUIDs)
  • The key comes from an external source
  • The primary key is also a foreign key (see Primary Key as Foreign Key)

Composite Primary Keys​

For join tables or entities whose identity is defined by a combination of columns, wrap the key fields in a separate data class and annotate it with @PK. Storm treats all fields in the composite key class as part of the primary key.

data class UserRolePk(
val userId: Int,
val roleId: Int
)

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

Foreign Keys​

The @FK annotation marks a field as a foreign key reference to another table-backed type (entity, projection, or data class with a @PK). Storm uses these annotations to automatically generate JOINs when querying and to derive column names (by default, appending _id to the field name).

data class User(
@PK val id: Int = 0,
val email: String,
@FK val city: City // Always loaded via INNER JOIN
) : Entity<Int>
When to use Ref<T> vs the full entity type

Use the full entity type (e.g., @FK val city: City) when you always want the related entity loaded. Use Ref<T> (e.g., @FK val city: Ref<City>) when you only sometimes need the related entity, when the relationship is optional, or to prevent circular dependencies. See Refs for details.


Unique Keys​

Use @UK on fields that have a unique constraint in the database. The @PK annotation implies @UK, so primary key fields are automatically unique. Annotating a field with @UK tells Storm that the column contains unique values, which enables several framework features:

  1. Type-safe lookups. findBy(Key, value) and getBy(Key, value) return a single result without requiring a predicate. The metamodel processor generates Metamodel.Key instances for @UK fields. See Metamodel for details.
  2. Scrolling. @UK fields can serve as cursor columns for scroll(Scrollable). Because the values are unique, the cursor position is always unambiguous. See Scrolling.
  3. Schema validation. When schema validation is enabled, Storm checks that the database actually has a matching unique constraint for each @UK field and reports a warning if it is missing.
data class User(
@PK val id: Int = 0,
@UK val email: String,
val name: String
) : Entity<Int>

Compound Unique Keys​

For compound unique constraints that need a metamodel key (e.g., for keyset pagination or type-safe lookups), use an inline record annotated with @UK. When the compound key columns overlap with other fields on the entity, use @Persist(insertable = false, updatable = false) to prevent duplicate persistence:

data class UserEmailUk(val userId: Int, val email: String)

data class SomeEntity(
@PK val id: Int = 0,
@FK val user: User,
val email: String,
@UK @Persist(insertable = false, updatable = false) val uniqueKey: UserEmailUk
) : Entity<Int>

Compound unique constraints that do not require a metamodel key do not need to be modeled in the entity. Schema validation does not warn about unmodeled compound constraints.

Use @UK(constraint = false) when the unique constraint does not exist in the database: for example, when uniqueness is enforced at the application level.

When a column is not annotated with @UK but becomes unique in a specific query context (for example, a GROUP BY column produces unique values in the result set), wrap the metamodel with .key() (Kotlin) or Metamodel.key() (Java) to indicate it can serve as a scrolling cursor. See Manual Key Wrapping for details.


Embedded Components​

Embedded components group related fields into a reusable data class without creating a separate database table. The component's fields are stored as columns in the parent entity's table. This is useful for value objects like addresses, coordinates, or monetary amounts that appear in multiple entities.

Use data classes for embedded components:

data class Address(
val street: String? = null,
@FK val city: City? = null
)

data class Owner(
@PK val id: Int = 0,
val firstName: String,
val lastName: String,
val address: Address,
val telephone: String?
) : Entity<Int>

@Persist Propagation on Embedded Components​

When @Persist is placed on an embedded component field, it propagates to all child fields within that component. This is useful when the embedded component's columns overlap with other fields on the entity and should not be persisted separately. Child fields can override the inherited @Persist with their own annotation.

data class OwnerCityKey(val ownerId: Int, val cityId: Int)

data class Pet(
@PK val id: Int = 0,
val name: String,
@FK val owner: Owner,
@FK val city: City,
@Persist(insertable = false, updatable = false) val ownerCityKey: OwnerCityKey
) : Entity<Int>

In this example, the owner and city foreign keys define the actual persisted columns. The ownerCityKey inline record maps to the same underlying columns but is excluded from INSERT and UPDATE statements because its child fields inherit @Persist(insertable = false, updatable = false) from the parent field.


Enumerations​

Storm persists enum values as their name() string by default, which is readable and resilient to reordering. If storage efficiency is a priority or your schema uses integer columns for enums, you can switch to ordinal storage with @DbEnum(ORDINAL). Be aware that ordinal storage is sensitive to the order of enum constants: adding or reordering values will break existing data.

Enums are stored by their name by default:

enum class RoleType {
USER,
ADMIN
}

data class Role(
@PK val id: Int = 0,
val name: String,
val type: RoleType // Stored as "USER" or "ADMIN"
) : Entity<Int>

To store by ordinal:

data class Role(
@PK val id: Int = 0,
val name: String,
@DbEnum(ORDINAL) val type: RoleType // Stored as 0 or 1
) : Entity<Int>

Converters​

When an entity field uses a type that is not directly supported by the JDBC driver, use @Convert to specify a converter that transforms between your domain type and a JDBC-compatible column type. Storm also supports auto-apply converters via @DefaultConverter, which automatically apply to all matching field types without requiring explicit annotations.

data class Money(val amount: BigDecimal)

data class Product(
@PK val id: Int = 0,
val name: String,
@Convert(converter = MoneyConverter::class) val price: Money
) : Entity<Int>

See Converters for the full Converter<D, E> interface, auto-apply with @DefaultConverter, resolution order, and practical examples.


Versioning (Optimistic Locking)​

Optimistic locking prevents lost updates when multiple users or threads modify the same record concurrently. Storm checks the version value during updates: if another transaction has already changed the row, the update fails with an exception rather than silently overwriting the other change. You can use either an integer counter or a timestamp.

Use @Version for optimistic locking:

data class Owner(
@PK val id: Int = 0,
val firstName: String,
val lastName: String,
@Version val version: Int
) : Entity<Int>

Timestamps are also supported:

data class Visit(
@PK val id: Int = 0,
val visitDate: LocalDate,
val description: String? = null,
@FK val pet: Pet,
@Version val timestamp: Instant?
) : Entity<Int>

Choosing the Remedy for a Lost Update​

A lost update needs no bug in any single statement: two writers read the same row, and the one that writes last overwrites the other's change without notice. A whole-entity update overwrites the columns the writer never touched as well. Two shapes cause nearly all of them, and each has its own remedy.

A change computed from the old value, such as a stock level, a balance or a counter, read and written back in one transaction. Compute the change in the database instead, in one statement that reads nothing first: the update() builder with the computation in set. It cannot lose a concurrent change and never needs a retry. @Version would turn every conflict on a hot row into an exception to retry.

// Not: read the product, subtract one in code, update(product).
products.update {
set(Product_.stock) { "${Product_.stock} - 1" }
where(Product_.id eq productId)
}.executeUpdate()

An edited record, read in one request and written back in another, such as an edit form or an endpoint that takes the full object. No transaction spans the two, so no isolation level and no lock helps. @Version does: the version travels to the client with the record and comes back with the edit, so the later of two writes fails with an OptimisticLockException instead of overwriting the earlier one. See Error Handling for how to respond to it.

// GET: the version leaves with the record.
val owner = owners.getById(ownerId)

// PUT: the version comes back with the edit, and a stale one fails.
owners.update(owner.copy(lastName = form.lastName, version = form.version))

Dirty checking and @DynamicUpdate(FIELD) narrow what a stale write overwrites, to the columns the writer changed, but do not prevent it. An entity no one edits concurrently, such as reference data or rows a single process owns, needs no @Version.


Non-Updatable Fields​

Some fields should be set once at creation and never changed by the application, such as creation timestamps, entity types, or references that define an object's identity. Marking a field with @Persist(updatable = false) tells Storm to include it in INSERT statements but exclude it from UPDATE statements.

Use @Persist(updatable = false) for fields that should only be set on insert:

data class Pet(
@PK val id: Int = 0,
val name: String,
@Persist(updatable = false) val birthDate: LocalDate,
@FK @Persist(updatable = false) val type: PetType,
@FK val owner: Owner? = null
) : Entity<Int>

Query Columns​

A query can only name a column that a field maps, and every field is selected, inserted and updated. Some columns exist only to be queried: a generated column that stores a derived form of another column, which a search matches and an index is built on, but which no caller reads and the database computes. Mapped as a field it sits in every SELECT, needs @Persist(insertable = false, updatable = false), and makes every caller that constructs the entity supply a value the database overwrites. The same holds for a tsvector column for full-text search, a normalized or hashed copy of a column, a computed sort key, and a large column a query filters on but should not load.

A query column is a column a query names but the entity does not carry. @QueryColumn on the entity or projection declares it with the name of its metamodel field and the type of its values. The processors generate the field as they generate one for a record component, and the column follows from the field name through the column name resolver; a column attribute names it where the resolver does not.

@QueryColumn(field = "lastNameFolded", type = String::class, function = "fold_accents")
data class Owner(
@PK val id: Int = 0,
val lastName: String,
val year: Int
) : Entity<Int>

The annotation repeats for each query column. Owner_.lastNameFolded is a Metamodel<Owner, String> and goes wherever a column does:

orm.entity(Owner::class)
.select()
.where(search(input, Owner_.lastNameFolded))
.orderBy(Owner_.lastNameFolded)
.resultList

The migration creates the column, here generated from the last name by the accent-folding function of Storing the Folded Text, and the index on it:

ALTER TABLE owner ADD COLUMN last_name_folded VARCHAR GENERATED ALWAYS AS (fold_accents(last_name)) STORED NOT NULL;
CREATE INDEX idx_owner_last_name_folded ON owner USING gin (LOWER(last_name_folded) gin_trgm_ops);
  • A query column is never selected, inserted or updated. A record holds no value for it: Metamodel.getValue throws, and update().set refuses it.
  • A column is carried by a field or named by a query column, not both. A query column whose column a field maps is refused when the model is built, as is one named after a record component.
  • It is reached through paths like any other field: a query column declared on Owner is Pet_.owner.lastNameFolded from a query on pets. Projections of the same table declare their own.
  • A query column that stores a function's result names it: function = "fold_accents". A value the column is compared with passes through the function, on the value side only, last_name_folded = fold_accents(?), so the column is read as stored and the database computes the value in the same statement. It applies to the comparison operators, IN, BETWEEN, the text operators, search, suggest and orderFirst; a pattern passes through it too, so the function must leave the wildcards and the escape character as they are. A function behind a generated column or an index must be deterministic, giving the same result for the same input. A keyset position holds the stored value and is compared as it is.
  • where, orderBy, orderFirst, search and suggest, a SqlFunction, a keyset sort and a template placeholder take it. A keyset sort refuses a nullable query column, as it refuses a nullable field; nullable = true declares one.
  • Schema validation checks that the column exists with a compatible type. On an entity every insert leaves it out, so the database must fill it: a NOT NULL query column that is not generated, not an identity column and has no default is reported as an unmapped column is.

Where a caller reads the value, map the column as a field with @Persist(insertable = false, updatable = false) instead: a query column is for a column no row needs to carry.


Modifying Entities​

Since Storm entities are immutable, updating a field means creating a new instance with the changed value. Kotlin data classes have a built-in copy() method for this. Java records do not provide an equivalent, but Lombok's @Builder(toBuilder = true) annotation generates a builder that copies all fields from an existing instance:

@Builder(toBuilder = true)
record User(@PK Integer id,
String email,
String name,
@FK City city
) implements Entity<Integer> {}

This enables user.toBuilder().email("new@example.com").build() to create a modified copy. See the FAQ for alternative approaches and upcoming Java language features.


Naming Conventions​

Storm uses pluggable name resolvers to convert Kotlin/Java names to database identifiers. By default, camelCase names are converted to snake_case, and foreign key fields append _id.

Default Conversion: CamelCase to Snake_Case​

The default resolver converts camelCase to snake_case:

  1. Convert the first character to lowercase
  2. Insert an underscore before each uppercase letter and convert it to lowercase
Field/ClassResolved Name
idid
emailemail
birthDatebirth_date
postalCodepostal_code
firstNamefirst_name
UserRoleuser_role

For foreign keys, _id is appended after the conversion:

FK FieldResolved Column
citycity_id
petTypepet_type_id
homeAddresshome_address_id

For details on customizing name resolution (uppercase conversion, custom resolvers, composable wrappers), see Naming Conventions.

Per-Entity and Per-Field Overrides​

Annotation overrides (@DbTable, @DbColumn, and the string parameters on @PK and @FK) always take precedence over configured resolvers. See Custom Table and Column Names for details and examples.

Identifier Escaping​

Storm automatically escapes a name that the database reserves or that holds special characters, and leaves every other name unescaped. On H2 and Oracle an escaped name is case-sensitive, so this keeps a column created without quotes matching. To escape a name Storm leaves alone, such as one created in quotes to keep its case, use the escape parameter:

@DbTable("PetType", escape = true) // created as "PetType"
data class PetType(
@PK val id: Int = 0,
@DbColumn("typeName", escape = true) val name: String // created as "typeName"
) : Entity<Int>

Custom Table and Column Names​

When the database schema does not follow Storm's default camelCase-to-snake_case convention, use annotations to specify the exact names. @DbTable overrides the table name, @DbColumn overrides a column name, and the string parameter on @PK or @FK overrides their respective column names. These annotations take precedence over any configured name resolver.

@DbTable("app_users")
data class User(
@PK("user_id") val id: Int = 0,
@DbColumn("email_address") val email: String,
@FK("home_city_id") val city: City
) : Entity<Int>

Column Mapping​

Storm automatically maps fields to columns using these conventions:

Entity FieldDatabase Column
idid
emailemail
birthDatebirth_date
postalCodepostal_code
city (FK)city_id

CamelCase field names are converted to snake_case column names. Foreign keys automatically append _id and reference the primary key of the related entity.


Join Behavior​

Nullability affects how relationships are loaded:

  • Non-nullable FK: INNER JOIN (referenced entity must exist)
  • Nullable FK: LEFT JOIN (referenced entity may be null)

Foreign keys are joined transitively, so an entity's reads carry the columns of everything its graph reaches. Declaring a foreign key as Ref stores the key and leaves the join to the queries that ask for it. See Entity Design for when that is worth doing.


Suppressing Schema Validation​

To suppress constraint-specific warnings (missing primary key, foreign key, or unique constraint), use the constraint attribute on @PK, @FK, or @UK. This is more targeted than @DbIgnore because it only suppresses the constraint check while preserving all other validation (column existence, type compatibility, nullability). See Constraint Validation for details and examples.

Use @DbIgnore to suppress schema validation for an entity or a specific field entirely. This is useful for legacy tables, columns handled by custom converters, or known type mismatches that are safe at runtime.

// Suppress all schema validation for a legacy entity.
@DbIgnore
data class LegacyUser(
@PK val id: Int = 0,
val name: String
) : Entity<Int>

// Suppress schema validation for a specific field.
data class User(
@PK val id: Int = 0,
val name: String,
@DbIgnore("DB uses FLOAT, but column only stores whole numbers")
val age: Int
) : Entity<Int>

The optional value parameter documents why the mismatch is acceptable. When placed on an embedded component field, @DbIgnore suppresses validation for all columns within that component.