Tutorials/Exposed vs Storm: transactions

Transactions translate almost one to one

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.

Series · Exposed to Storm5 min readKotlin

01The task

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.

02Where the two agree

Exposed made explicit transaction scope normal in Kotlin, and its block carries the settings right at the call site:

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

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

03Nesting and propagation

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:

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

04After the outcome

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:

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

05Coroutines

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:

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

06The translation table

ExposedStorm
transaction(transactionIsolation, readOnly)transaction(isolation, readOnly)
queryTimeouttimeoutSeconds
maxAttempts, built-in retryNot built in; retries are application code
Nested blocks share the transaction; useNestedTransactions for savepointsPropagation per block: REQUIRED, NESTED, REQUIRES_NEW, ...
After-commit logic by handonCommit { } and onRollback { }
suspendTransaction { }transaction { } is a suspend function
Reads also need a transactionRepositories manage connections; blocks where atomicity matters

07Keep going

The reference documentation covers the mechanics in depth: