Every table wants createdAt and updatedAt, and nobody wants to set them by hand. EntityCallback hooks into the write path, and because entities are immutable, the hook returns a transformed copy instead of mutating state behind your back.
Stamp audit timestamps on every insert and update, enforce a business invariant before data reaches the database, and keep both concerns out of the service code that writes the entities.
EntityCallback<E> is typed to the entity it applies to, with hooks for before and after each mutation. The before-hooks return the entity that will actually be persisted, which is how transformation works in an immutable world:
data class Article( @PK val id: Int = 0, val title: String, val createdAt: Instant? = null, val updatedAt: Instant? = null, ) : Entity<Int> // Before-hooks return the entity to persist; copy() is the transformation class AuditCallback : 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 { return entity.copy(updatedAt = Instant.now()) } }
Callbacks attach to the template, following Storm's immutable configuration pattern: the original template is unchanged, and the returned one applies the callback to every write:
// Registration is explicit and immutable: a new template, callback applied val orm = dataSource.orm.withEntityCallback(AuditCallback()) // From here, every insert and update flows through the hooks val article = orm insert Article(title = "Storm 1.11") // article.createdAt and updatedAt are set
One caveat worth knowing: the entity passed to the after-hooks is the pre-persist value, without database-generated fields. For the generated id, use the return value of insertAndFetch.
Because before-hooks run ahead of the SQL, they double as a validation point with domain-specific error messages, and multiple callbacks chain in registration order:
// The same hook enforces invariants before the SQL round trip class ArticleValidation : EntityCallback<Article> { override fun beforeInsert(entity: Article): Article { require(entity.title.isNotBlank()) { "Title must not be blank" } return entity } } // Callbacks chain and fire in registration order val orm = dataSource.orm .withEntityCallback(ArticleValidation()) .withEntityCallback(AuditCallback())
For auditing across many entity types, a single global callback with a shared Auditable interface covers them all; the lifecycle docs show that pattern, the Spring Boot auto-registration, and how callbacks route for upserts.
The reference documentation covers the mechanics in depth: