Misk Hibernate and the Transacter: Persistence Without the Footguns
Series: Building Production Services with Misk — Part 13 of 24
Every service eventually has to write a row somewhere. And the moment it does, the question stops being “how do I talk to MySQL” and becomes “how do I talk to MySQL without leaking a connection, forgetting to commit, swallowing a deadlock, or accidentally reading from a replica that’s three seconds behind.” Raw Hibernate hands you a Session, a Transaction, and a loaded gun: you getCurrentSession(), you beginTransaction(), you try/catch/finally, you remember to rollback(), and you pray nobody on the team forgets the finally. Misk hibernate refuses to play that game. Instead of exposing a session you have to babysit, it gives you the misk transacter — a single object whose entire job is to lend you a Session for the duration of a block, commit it if you return normally, roll it back if you throw, and retry it if the failure was the recoverable kind. You never open a transaction. You never close one. You just describe the work.
What the misk transacter actually is
The whole API is one interface — small enough to read in a sitting. Here’s the load-bearing method from Transacter.kt:
interface Transacter {
val inTransaction: Boolean
/**
* Starts a transaction on the current thread, executes [block], and commits
* the transaction. If the block raises an exception the transaction will be
* rolled back instead of committed.
*/
fun <T> transaction(block: (session: Session) -> T): T
/** Runs a non-transactional session against a read replica. */
fun <T> replicaRead(block: (session: Session) -> T): T
fun retries(maxAttempts: Int = 2): Transacter
fun noRetries(): Transacter
fun readOnly(): Transacter
fun <T> withLock(lockKey: String, block: () -> T): T
// ...
}
That’s the stance in one screen. The Session only exists inside the lambda. There is no transacter.begin() you could call and forget. The transaction is the block, and its lifecycle is the block’s lifecycle: return a value and it commits, throw and it rolls back. Retries aren’t a try-loop you write — they’re the default behavior (up to two attempts), tunable per-call with retries(n) or switched off with noRetries(). This is the same “the framework owns the dangerous lifecycle, you own the logic” philosophy we saw with interceptors and access control earlier in the series, applied to the most footgun-rich corner of a service.
Defining entities: DbEntity, Id, and timestamps
Before you can transact, you need something to persist. Misk entities are ordinary JPA-annotated classes that also implement a Misk marker interface — DbEntity<T>, which is self-referential and exists to guarantee that only real persistent entities reach Session methods:
interface DbEntity<T : DbEntity<T>> {
val id: Id<T>
}
The interesting part is Id<T>. Misk doesn’t let you pass naked Longs around as primary keys, because a Long for a movie and a Long for an actor are the same type to the compiler and a swapped argument is a silent disaster. Instead:
/** Type-safe persistent identifier, mapped to a long column. */
data class Id<T : DbEntity<T>>(val id: Long) : Serializable, Comparable<Id<T>>
An Id<DbMovie> is not assignable to an Id<DbActor> — the wrong key won’t compile. It’s still a bigint in the database — the type parameter is pure compile-time safety with zero runtime cost — but it turns an entire class of bug into a red squiggle.
A real entity, straight from Misk’s own test suite, looks like this:
@Entity
@Table(name = "movies")
class DbMovie() : DbEntity<DbMovie>, DbTimestampedEntity {
@javax.persistence.Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override lateinit var id: Id<DbMovie>
@Column override lateinit var updated_at: Instant
@Column override lateinit var created_at: Instant
@Column(nullable = false) lateinit var name: String
@Column(nullable = true) var release_date: LocalDate? = null
constructor(name: String, releaseDate: LocalDate? = null) : this() {
this.name = name
this.release_date = releaseDate
}
}
Note DbTimestampedEntity. Implement it, declare created_at and updated_at as @Columns, and Misk’s TimestampListener populates them on save and update — crucially using the application’s Clock, not the database’s, so they’re fakeable in tests. (The columns aren’t marked nullable = false, because the listener runs after Hibernate’s nullability check — a subtle ordering detail Misk documents right in the interface’s KDoc.) The column names are snake_case on purpose: these map directly to SQL columns, and Misk leans into that rather than papering over it with camelCase translation.
Entities are registered, not auto-discovered. You bind them in a HibernateEntityModule tied to a database qualifier:
object : HibernateEntityModule(Movies::class) {
override fun configureHibernate() {
addEntities(DbMovie::class, DbActor::class, DbCharacter::class)
}
}
and you install the connectivity itself via HibernateModule(qualifier = Movies::class, config = ...), which binds the qualified SessionFactory, the qualified Transacter, and an unqualified Query.Factory. The qualifier (@Movies) is how a service with multiple databases keeps their transacters straight — you inject @Movies lateinit var transacter: Transacter and you get exactly that database’s transacter.
Using the Transacter
With an entity and a bound transacter, persistence reads almost like pseudocode. Misk’s own KDoc gives the canonical example, and it’s worth quoting because it shows both halves of how mutation works:
transacter.transaction { session ->
// Saving a NEW entity needs an explicit call.
val starWars = DbMovie(name = "Star Wars", releaseDate = LocalDate.of(1977, 5, 25))
session.save(starWars)
// Updating an existing entity is implicit — just mutate the loaded object.
val movie: DbMovie = queryFactory.newQuery<MovieQuery>().id(id).uniqueResult(session)!!
movie.release_date = LocalDate.of(1978, 1, 1)
}
session.save(entity) returns the freshly minted Id<T>. Updates are pure JPA dirty-checking: the object you got back from a query is attached to the session, so mutating its fields is the update — no save, no merge, no ceremony. The Session interface adds typed loads on top:
inline fun <reified T : DbEntity<T>> Session.load(id: Id<T>): T
inline fun <reified T : DbEntity<T>> Session.loadOrNull(id: Id<T>): T?
fun <T : DbEntity<T>> Session.delete(entity: T)
load throws if the row is gone; loadOrNull hands you a nullable — Misk makes the “might not exist” case a type, not a caught exception. Save and delete both throw IllegalStateException on a read-only session — which brings us to the chainable modifiers. transacter.readOnly().transaction { ... } produces sessions that refuse writes — a guardrail for read paths so a stray save() fails loudly instead of silently mutating. And for reads that don’t need your own uncommitted writes, replicaRead { session -> ... } runs a non-transactional read against a replica. The KDoc is refreshingly blunt about the tradeoff: consistency is eventual, you may not see a write you just made, and because successive queries can land on different replicas, you can even observe time jumping backward between two reads in the same block. Use it for genuinely read-only, latency-tolerant paths — and never to read something you wrote a millisecond ago.
Querying: the typed DSL
You can drop to JPA criteria — query.constraint { root -> like(root.get("name"), "Jurassic%") } is right there. But the headline feature of misk hibernate is the annotation-driven query DSL, where you declare a query as an interface and Misk generates the implementation. You write no query body at all:
interface MovieQuery : Query<DbMovie> {
@Constraint("name")
fun name(name: String): MovieQuery
@Constraint(path = "release_date", operator = Operator.LT)
fun releaseDateBefore(date: LocalDate): MovieQuery
@Constraint(path = "id", operator = Operator.EQ)
fun id(id: Id<DbMovie>): MovieQuery
}
Each @Constraint method appends a WHERE clause and returns this, so constraints chain. @Order(path = "name", asc = false) adds an ORDER BY. @Select turns a method into the terminal SELECT — and it does aggregations too:
@Select(path = "i64", aggregation = AggregationType.AVG)
fun averageI64(session: Session): Double?
The Operator enum covers the SQL vocabulary you’d expect — LT, LE, EQ, GE, GT, NE, IN, NOT_IN, LIKE, plus IS_NULL/IS_NOT_NULL and a handy EQ_OR_IS_NULL that does the right thing when the argument is null. You instantiate via the injected factory:
val recent = queryFactory.newQuery<MovieQuery>()
.releaseDateBefore(christmas23)
.name("Ferrari")
.list(session) // or .uniqueResult(session), .count(session), .delete(session)
list returns List<T>, uniqueResult returns T? and asserts there’s at most one, count returns a Long (with a KDoc warning that it’s as expensive as a SELECT because MySQL scans every counted row), and delete returns the number of rows removed. For result shaping there are Projection data classes whose constructor params carry @Property("path"), and an or { option { ... }; option { ... } } builder for OR’d predicate groups. The reason to prefer this over raw criteria: a query interface is testable and mockable as a unit, the column paths are checked against your mapped entity, and the whole WHERE clause reads as named, intention-revealing methods rather than a wall of builder calls. There’s a guardrail baked in too — maxRows is clamped to the 1..10,000 range, and full table scans are blocked unless you explicitly opt in with .allowTableScan().
Why Misk wraps Hibernate instead of handing it to you raw
This is the part worth slowing down on, because it’s the design thesis of the whole module. Misk could have just bound a SessionFactory and let you openSession(). It deliberately doesn’t. The Transacter is the only sanctioned way to get a Session, and that constraint buys you several things you’d otherwise have to enforce by code review and prayer:
- The transaction lifecycle is uncloseable-by-omission. You cannot leak a transaction, because you never open one. Commit-on-return and rollback-on-throw are structural, not conventional.
- Retries are first-class. Deadlocks and optimistic-lock conflicts are normal under concurrency. With raw Hibernate you’d write the retry loop yourself (and get the backoff wrong). Misk retries recoverable failures by default; you opt out with
noRetries()when you must. - Replica reads and read-only mode are types, not discipline.
replicaRead {}andreadOnly()make the consistency/safety posture of a block explicit and enforced, instead of a comment hoping nobody writes to the wrong connection.
The natural comparison is Spring’s @Transactional, and the contrast is instructive. @Transactional is an annotation on a method, woven in by a proxy. It’s genuinely convenient until it isn’t — and the failure modes are notorious: a @Transactional method calling another @Transactional method on the same bean silently skips the proxy, so the inner transaction settings just don’t apply; the propagation/isolation knobs are a small configuration language you have to learn; and “is there a transaction open right now, and which one” is invisible at the call site. Misk’s block-based approach makes the boundary lexical and obvious — the transaction is the thing between the braces. transacter.inTransaction is a readable property, not proxy archaeology. Nesting is an explicit error (“it is an error to start a transaction if another transaction is already in progress”), so you can’t accidentally get the surprising self-invocation behavior. The cost is honest: it’s more verbose than a bare annotation, and you do have to thread session through your code rather than relying on ambient thread-local magic. Misk’s stance — and I agree with it — is that for the operation most likely to corrupt your data, explicit and slightly verbose beats implicit and occasionally invisible every time.
Production notes and gotchas
replicaReadcan read stale, and even read backward in time. Eventual consistency means a write you just committed may not be visible; and because consecutive queries can hit different replicas, two reads in one block can disagree about “now.” Never use it for read-your-writes.- Retries demand idempotent blocks. Since the framework re-runs your
transaction { }lambda on a recoverable failure, anything non-transactional inside it — an HTTP call, a metric increment, a log line — runs again on each attempt. Keep side effects out of the block, or make them idempotent, or callnoRetries(). count(session)is not free. Its KDoc spells it out: MySQL scans every counted row, so a count of 5,000 is roughly ten times slower than a count of 500. Don’t reach for it on hot paths.maxRowsis clamped to 10,000 and scans are blocked by default. A query that would table-scan throws unless you’ve called.allowTableScan(), and you can’t ask for more than 10k rows. These are deliberate scalability checks, not bugs — the fix is usually a better index, not disabling the check.HibernateTestingModuleis deprecated. It still works, but its own KDoc now points you atJdbcTestingModuleinstead. New code installsJdbcTestingModule(qualifier)(often alongside aSHARED_TEST_DATABASE_POOL) to clear the datasource between tests; entities and the transacter come from the sameHibernateModuleyou run in production, so tests exercise real SQL against a real local MySQL.- Timestamps come from the JVM clock, not MySQL.
DbTimestampedEntitypopulatescreated_at/updated_atfrom the application’s injectedClock. That’s why they’re fakeable in tests — but it also means clock skew across instances is your problem, not the database’s. withLockis a real MySQL advisory lock. It’s a genuine cross-process mutex (named,<= 64chars), released only when the method returns — convenient for leader-ish coordination, but it’s database-backed locking with all the contention caveats that implies. Don’t hold it across slow work.
What’s next
You now have entities, type-safe IDs, transactions, and a query DSL — but every one of those @Columns and @Tables implies a schema, and that schema has to exist and evolve in lockstep with your code. Misk validates your mapped entities against the live database on startup and refuses to come up if they disagree, which means migrations aren’t optional bookkeeping — they’re part of the boot contract. In Part 14: Misk Schema Migrations we’ll look at how Misk discovers, orders, and applies versioned migrations, and how the SchemaMigratorService ties it all to service startup.
Target keywords: misk hibernate, misk transacter.
Comments