Skip to main content
Version: 1.9.1

Common Patterns

This page collects practical patterns for recurring requirements that are not covered by a dedicated Storm API but are straightforward to implement using the framework's building blocks. Each pattern includes a complete example with the entity definition, supporting code, and usage.


Loading One-to-Many Relationships

Storm does not support collection fields on entities. This is by design: embedding collections inside entities leads to lazy loading, N+1 queries, and unpredictable fetch behavior. Instead, you load the "many" side with an explicit query and assemble the result in your application code.

Unlike JPA's @OneToMany collection, Storm loads relationships via explicit queries. This gives you full control over when and how children are loaded, preventing N+1 problems and making the data flow visible in the source code.

Entity Definitions

@DbTable("purchase_order")
data class Order(
@PK val id: Long = 0,
val customerId: Long,
val status: String,
val createdAt: Instant?
) : Entity<Long>

data class LineItem(
@PK val id: Long = 0,
@FK val order: Order,
val productName: String,
val quantity: Int,
val unitPrice: BigDecimal
) : Entity<Long>

Fetching and Assembling

Fetch the parent entity, then query its children using the foreign key. Assemble the result into a response object that your service or controller returns.

data class OrderWithItems(
val order: Order,
val lineItems: List<LineItem>
)

fun findOrderWithItems(orderId: Long): OrderWithItems? {
val order = orm.entity(Order::class).findById(orderId) ?: return null
val lineItems = orm.entity(LineItem::class).findAll { LineItem_.order eq order }
return OrderWithItems(order, lineItems)
}

This pattern generalizes to any one-to-many relationship. Both queries are explicit and visible in the source code, so you can easily add filtering, sorting, or pagination to the child query without affecting the parent fetch.


Auditing

Most applications need to track when records were created and last modified. Storm's EntityCallback interface provides the hooks for this without requiring special annotations or framework-specific column types.

Entity Definition

@DbTable("article")
data class Article(
@PK val id: Int = 0,
val title: String,
val content: String,
val createdAt: Instant?,
val updatedAt: Instant?
) : Entity<Int>

Callback Implementation

class AuditTimestampCallback : EntityCallback<Article> {

override fun beforeInsert(entity: Article): Article {
val now = Instant.now()
return entity.copy(createdAt = now, updatedAt = now)
}

override fun beforeUpdate(entity: Article): Article =
entity.copy(updatedAt = Instant.now())
}

Register it with the ORM template:

val orm = ORMTemplate.of(dataSource)
.withEntityCallback(AuditTimestampCallback())

Or declare it as a Spring bean for automatic registration:

@Bean
fun auditTimestampCallback(): EntityCallback<*> = AuditTimestampCallback()

To apply auditing to all entities (not just Article), parameterize the callback with Entity<?> and use pattern matching to handle each entity type. See the Entity Lifecycle page for details.


Soft Deletes

Soft deletes mark records as deleted without physically removing them from the database. This preserves data for audit trails, undo operations, or compliance requirements. The pattern uses a boolean or timestamp column to indicate deletion status.

Entity Definition

@DbTable("customer")
data class Customer(
@PK val id: Int,
val name: String,
val email: String,
val deletedAt: Instant? // null means not deleted
) : Entity<Int>

Repository with Soft Delete Methods

interface CustomerRepository : EntityRepository<Customer, Int> {

/** Find only non-deleted customers. */
fun findActive(): List<Customer> =
findAll { Customer_.deletedAt.isNull() }

/** Find a non-deleted customer by ID. */
fun findActiveOrNull(customerId: Int): Customer? =
find { (Customer_.id eq customerId) and Customer_.deletedAt.isNull() }

/** Soft-delete a customer by setting the deletedAt timestamp. */
fun softDelete(customer: Customer): Customer {
val softDeleted = customer.copy(deletedAt = Instant.now())
update(softDeleted)
return softDeleted
}

/** Restore a soft-deleted customer. */
fun restore(customer: Customer): Customer {
val restored = customer.copy(deletedAt = null)
update(restored)
return restored
}
}

Enforcing Soft Deletes via Callback

To prevent accidental hard deletes, use an entity callback that converts delete() calls into soft deletes:

class SoftDeleteGuard : EntityCallback<Customer> {

override fun beforeDelete(entity: Customer) {
throw PersistenceException(
"Hard deletes are not allowed for Customer. Use softDelete() instead."
)
}
}

Pagination

There are two common approaches to pagination: offset-based (using LIMIT and OFFSET) and keyset-based (using a cursor). Offset-based pagination is simpler but degrades on large offsets; keyset pagination is consistent regardless of the page depth.

Offset-Based Pagination

data class Page<T>(
val content: List<T>,
val pageNumber: Int,
val pageSize: Int,
val totalElements: Long
) {
val totalPages: Int get() = ((totalElements + pageSize - 1) / pageSize).toInt()
val hasNext: Boolean get() = pageNumber < totalPages - 1
}

fun findUsersPage(pageNumber: Int, pageSize: Int): Page<User> {
val offset = pageNumber * pageSize

val content = orm.query("""
SELECT ${User::class}
FROM ${User::class}
ORDER BY ${User_.name}
LIMIT $pageSize OFFSET $offset
""").getResultList(User::class)

val totalElements = orm.entity(User::class).select().getResultCount()

return Page(content, pageNumber, pageSize, totalElements)
}

Keyset-Based Pagination

Keyset pagination uses the last row's sort key as the starting point for the next page. This avoids the performance degradation of large offsets:

data class KeysetPage<T>(
val content: List<T>,
val nextCursor: String?, // null if no more pages
val pageSize: Int
) {
val hasNext: Boolean get() = nextCursor != null
}

fun findUsersAfter(cursor: String?, pageSize: Int): KeysetPage<User> {
val content = if (cursor != null) {
orm.query("""
SELECT ${User::class}
FROM ${User::class}
WHERE ${User_.name} > $cursor
ORDER BY ${User_.name}
LIMIT $pageSize
""").getResultList(User::class)
} else {
orm.query("""
SELECT ${User::class}
FROM ${User::class}
ORDER BY ${User_.name}
LIMIT $pageSize
""").getResultList(User::class)
}

val nextCursor = if (content.size == pageSize) content.last().name else null

return KeysetPage(content, nextCursor, pageSize)
}

Choosing Between the Two

FactorOffset-BasedKeyset-Based
Implementation complexitySimpleModerate
Jump to arbitrary pageYesNo (sequential only)
Performance at page 1GoodGood
Performance at page 1000DegradesConsistent
Handles concurrent insertsRows may shift between pagesStable cursor

Bulk Import

For large-scale data imports, use Storm's streaming batch methods. These process entities from a Stream in configurable batch sizes, keeping memory usage constant regardless of the total number of entities.

// Read from a CSV file and insert in batches of 500.
val entityStream = csvReader.lines()
.map { line -> parseUser(line) }

orm.entity(User::class).insert(entityStream, batchSize = 500)

For imports where auto-generated primary keys should be ignored (e.g., migrating data with existing IDs):

orm.entity(User::class).insert(entityStream, batchSize = 500, ignoreAutoGenerate = true)

The streaming API processes entities lazily: only one batch is held in memory at a time. This makes it suitable for importing millions of rows without running out of memory.


Row-Level Security

Row-level security restricts which rows a user can access based on their identity or role. Storm does not provide built-in row-level security, but you can implement it using entity callbacks and the SQL interceptor.

Via Entity Callbacks

Use a callback to enforce read-level security by filtering or rejecting unauthorized access:

class TenantIsolationCallback : EntityCallback<TenantEntity<*>> {

override fun beforeInsert(entity: TenantEntity<*>): TenantEntity<*> {
val currentTenant = TenantContext.current()
if (entity.tenantId != currentTenant) {
throw PersistenceException("Cannot insert entity for tenant ${entity.tenantId}")
}
return entity
}

override fun beforeUpdate(entity: TenantEntity<*>): TenantEntity<*> {
val currentTenant = TenantContext.current()
if (entity.tenantId != currentTenant) {
throw PersistenceException("Cannot update entity belonging to tenant ${entity.tenantId}")
}
return entity
}

override fun beforeDelete(entity: TenantEntity<*>) {
val currentTenant = TenantContext.current()
if (entity.tenantId != currentTenant) {
throw PersistenceException("Cannot delete entity belonging to tenant ${entity.tenantId}")
}
}
}