Tutorials/Auditing

Auditing with entity callbacks

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.

Series · The Storm way4 min readKotlin

01The task

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.

02The audit callback

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:

AuditCallback.kt Kotlin · Storm
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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())
    }
}

03Registration

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:

Setup.kt Kotlin · Storm
1
2
3
4
5
6
// 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.

04Validation, same mechanism

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:

Callbacks.kt Kotlin · Storm
1
2
3
4
5
6
7
8
9
10
11
12
// 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.

05Keep going

The reference documentation covers the mechanics in depth: