Skip to main content
Version: 1.10.0

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 delete. 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. Keyset pagination. @UK fields can serve as cursor columns for slice, sliceAfter, and sliceBefore. Because the values are unique, the cursor position is always unambiguous. See Keyset Pagination.
  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>

For compound unique constraints spanning multiple columns, 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>

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 keyset pagination 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)

@DbTable("product")
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>

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>

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,
@Nonnull String email,
@Nonnull 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 identifiers that are SQL reserved words or contain special characters. Force escaping with the escape parameter:

@DbTable("order", escape = true)  // "order" is a reserved word
data class Order(
@PK val id: Int = 0,
@DbColumn("select", escape = true) val select: String // "select" is reserved
) : 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)

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.