Tutorials/Transactions

Transactions without the proxy rules

@Transactional is powerful, and it comes with rules: proxies, self-invocation, visibility, rollback defaults. Storm makes the transaction a block of code whose scope you can see.

Series · JPA to Storm6 min readKotlin

01The task

Insert an order and decrease inventory atomically. Send the confirmation email only if the transaction actually committed. This is the bread and butter of transactional code, and also where the classic mistakes live.

02The Spring way

@Transactional handles the atomicity, but it works through a proxy that wraps the bean, and the proxy has rules:

OrderService.kt Kotlin · Spring
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
class OrderService(
    private val orders: OrderRepository,
    private val mailer: Mailer,
) {
    @Transactional
    fun placeOrder(order: Order) {
        orders.save(order)
        mailer.sendConfirmation(order)   // sent before commit: the order may still roll back
    }

    fun importAll(batch: List<Order>) {
        batch.forEach { placeOrder(it) }   // self-invocation: @Transactional is silently ignored
    }
}

Both bugs in that snippet are silent. The email goes out before the commit, so a rollback leaves a customer confirmed for an order that does not exist; the fix requires registering a TransactionSynchronization by hand. And importAll calls placeOrder on this rather than through the proxy, so the annotation simply does not apply: no transaction, no rollback, and nothing in the code or the log tells you. Add the visibility rules and the rollback-only-on-unchecked default, and correct usage depends on knowing the manual.

03The Storm way

Storm's transaction is a function you call, not an aspect woven around you. The scope is the block, and it works the same in any class, any method, any visibility:

OrderService.kt Kotlin · Storm Show SQL
1
2
3
4
5
6
// The transaction is a block. Its scope is exactly what you can see.
transaction {
    val order = orders.insertAndFetch(newOrder)
    inventory.decrease(order.product, order.quantity)
}
// commits on success, rolls back on exception, no proxy involved
generated sql
BEGIN
INSERT INTO "order" (product_id, quantity) VALUES (?, ?)
UPDATE inventory SET stock = stock - ? WHERE product_id = ?
COMMIT

There is no proxy, so there is nothing to self-invoke around. Calling a function that opens a transaction block from anywhere just works, and nesting follows the propagation you specify rather than the annotation plumbing.

04Side effects, after the outcome

The email problem has a first-class answer: register callbacks inside the block, and they fire only once the outcome is final. Running the email after the block is not equivalent, and not just stylistically: with REQUIRED propagation the block may have joined an outer transaction, in which case the end of the block commits nothing, and the outer transaction may still roll back. Callbacks bind to the physical transaction, so the code stays correct however deeply callers nest it:

OrderService.kt Kotlin · Storm
1
2
3
4
5
6
7
8
9
10
11
12
13
14
transaction {
    val order = orders.insertAndFetch(newOrder)
    inventory.decrease(order.product, order.quantity)

    onCommit {
        // runs only after the commit succeeded: the data is durable
        mailer.sendConfirmation(order)
        events.publish(OrderCreated(order))
    }

    onRollback {
        metrics.increment("orders.failed")
    }
}

onCommit runs only when the data is durable; if the commit itself fails, it is skipped and onRollback runs instead. No synchronization registration, no event-listener detour.

05Full control when you need it

Propagation, isolation, and timeout are parameters of the block, so a reader sees the transaction's guarantees at the call site instead of hunting for annotation attributes:

Provisioning.kt Kotlin · Storm Show SQL
1
2
3
4
5
// Propagation, isolation and timeout, visible at the call site
transaction(propagation = REQUIRES_NEW, isolation = REPEATABLE_READ, timeoutSeconds = 5) {
    val city = orm insert City(name = "San Jose", population = 1_013_240)
    orm insert User(email = "alice@acme.io", name = "Alice", city = city)
}
generated sql
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ
BEGIN
INSERT INTO city (name, population) VALUES (?, ?)
INSERT INTO "user" (email, name, city_id) VALUES (?, ?, ?)
COMMIT

06Coroutine-aware

transaction is a suspend function, and the transaction context survives dispatcher switches, which annotation-driven transaction management famously does not handle well:

BatchJob.kt Kotlin · Storm
1
2
3
4
5
6
7
8
9
10
// Suspend transactions survive dispatcher switches
transaction {
    val pending = orders.findAllPending()

    val processed = withContext(Dispatchers.Default) {
        heavyComputation(pending)   // the transaction context travels along
    }

    orders.update(processed)          // still the same transaction
}

The transaction is part of the coroutine context, so the code inside the switched dispatcher is still transaction-aware: a repository call there joins the same transaction rather than opening a new one. For synchronous code, transactionBlocking is the drop-in equivalent.

07Mixing with Spring

If you run Spring, the two systems bridge, on one condition. With @EnableTransactionIntegration (the Kotlin Spring Boot starter enables it by default), Storm's blocks delegate to Spring's transaction manager: a transactionBlocking inside a @Transactional method joins the surrounding transaction, and propagation behaves as one system. Without that bridge, the two managers are independent, and a block inside a @Transactional method would open its own connection and its own transaction.

One trade-off to know: suspend transaction blocks are not available while Spring manages Storm's transactions; Storm rejects them with a clear error. Coroutine-aware transactions belong to setups where Storm manages transactions itself, such as Ktor or standalone services. See Spring Integration for the details.

08Side by side

Spring @TransactionalStorm
MechanismProxy woven around the beanA function with a block
Self-invocationSilently skips the transactionNot a concept; blocks work anywhere
After-commit effectsRegister a TransactionSynchronizationonCommit and onRollback in the block
CoroutinesThread-bound context, fragile across dispatchersSuspend transactions survive context switches

09Keep going

The reference documentation covers the mechanics in depth: