Exposed and Storm agree on the important part: a transaction is an explicit block of code, not an annotation woven around a bean. If you know one API you nearly know the other. This page maps the concepts and marks the few places they differ.
Write an order and an inventory change atomically, control isolation and timeouts, nest safely, send a confirmation only after the commit, and do all of it from coroutine code. The full transactional toolbox.
Exposed made explicit transaction scope normal in Kotlin, and its block carries the settings right at the call site:
// Explicit scope, explicit settings: Exposed got this right transaction( transactionIsolation = Connection.TRANSACTION_REPEATABLE_READ, readOnly = false, ) { maxAttempts = 3 // built-in retry on SQLException queryTimeout = 5 Orders.insert { it[product] = productId; it[quantity] = 2 } Inventory.update({ Inventory.product eq productId }) { it[stock] = stock - 2 } }
Note maxAttempts: built-in retry on SQLException is a genuinely useful feature that Storm does not have; retries in Storm are application code. Storm's block will look immediately familiar:
// The same idea, the same shape transaction(isolation = REPEATABLE_READ, timeoutSeconds = 5) { val order = orders.insertAndFetch(newOrder) inventory.decrease(order.product, order.quantity) } // commits on success, rolls back on exception, like Exposed
transactionIsolation maps to isolation, queryTimeout to timeoutSeconds, readOnly to readOnly. One structural difference sits outside the block: Exposed requires a transaction for every operation including reads, while Storm's repositories manage connections per operation, and the block appears where atomicity matters.
In Exposed, nested transaction blocks share the outer transaction by default; setting useNestedTransactions = true on the database turns inner blocks into savepoints. Storm expresses the same choices per block instead of per database, using the propagation vocabulary from enterprise transaction managers:
// Nesting is configured per block, not per database transaction { orders.insertAndFetch(newOrder) transaction(propagation = NESTED) { // savepoint semantics for this block only audit.record(newOrder) } transaction(propagation = REQUIRES_NEW) { // an independent transaction, committed on its own metrics.bump("orders") } }
The default, REQUIRED, joins the surrounding transaction, which is Exposed's default behavior too. NESTED is the savepoint mode, and REQUIRES_NEW, MANDATORY, NEVER and the rest cover the cases a global flag cannot.
The place where the toolboxes differ most. With explicit blocks it is tempting to run follow-up logic right after the block, and in Exposed that is the available pattern. It works only as long as the block owns the physical transaction. The moment the same function is called inside an outer transaction, and joining the outer transaction is the default in both libraries, the end of the inner block commits nothing: the outer transaction decides later, and may still roll everything back. Code after the block then announces work that never happened. onCommit exists for exactly this case: it can be registered inside any block, however deeply joined, and fires only after the physical transaction commits:
transaction { val order = orders.insertAndFetch(newOrder) onCommit { mailer.sendConfirmation(order) // only after the commit succeeded } onRollback { metrics.increment("orders.failed") } }
If the commit itself fails, onCommit is skipped and onRollback runs, so a confirmation can never precede its order. This is also what makes the function composable: a caller can wrap it in a larger transaction without breaking its side effects.
Both libraries support suspending transactions: Exposed with suspendTransaction { }, Storm by making transaction a suspend function. Storm's transaction context also survives dispatcher switches inside the block:
// Exposed: suspendTransaction { }; Storm: transaction is a suspend function transaction { val pending = orders.findAllPending() val processed = withContext(Dispatchers.Default) { heavyComputation(pending) // the transaction context travels along } orders.update(processed) // still the same transaction }
Because the transaction is part of the coroutine context, the code inside the switched dispatcher remains transaction-aware: a repository call there joins the same transaction rather than opening a new one.
| Exposed | Storm |
|---|---|
transaction(transactionIsolation, readOnly) | transaction(isolation, readOnly) |
queryTimeout | timeoutSeconds |
maxAttempts, built-in retry | Not built in; retries are application code |
Nested blocks share the transaction; useNestedTransactions for savepoints | Propagation per block: REQUIRED, NESTED, REQUIRES_NEW, ... |
| After-commit logic by hand | onCommit { } and onRollback { } |
suspendTransaction { } | transaction { } is a suspend function |
| Reads also need a transaction | Repositories manage connections; blocks where atomicity matters |
The reference documentation covers the mechanics in depth: