Skip to content

Transactions

Stormify groups multiple operations into a single transaction — either all commit together, or all roll back if anything fails. Nested transactions use database savepoints.

Two flavours:

  • Blockingstormify.transaction { ... } (top half of this page). The default.
  • Suspendasync.transaction { ... } for Kotlin coroutines, with a built-in connection pool (see below).

Both share the same CRUD operations and nested-transaction semantics.

Managing Transactions

Transactions are plain lambdas — the block is the unit of work, commit happens on normal return, rollback on any thrown exception. Every CRUD or query call issued through the same Stormify instance inside the block (including top-level extensions and CRUDTable-implementing entities) automatically joins the transaction. The same call works identically inside or outside a transaction.

Basic Transaction Example

stormify.transaction {
    val user = stormify.create(User(email = "test@example.com"))
    stormify.create(Profile(userId = user.id, name = "Test User"))
    stormify.update(account)
}
stormify.transaction(() -> {
    User user = stormify.create(new User("test@example.com"));
    stormify.create(new Profile(user.getId(), "Test User"));
    stormify.update(account);
});

With a default instance registered, call the top-level transaction { } and the top-level extensions (user.create(), "SELECT …".read<T>(), findById<T>(id), …) without any prefix. Every call still participates in the active transaction.

stormify.asDefault()

transaction {
    val user = User(email = "test@example.com").create()
    Profile(userId = user.id).create()
}
StormifyJ stormify = StormifyJ.getDefault();
stormify.transaction(() -> {
    User user = stormify.create(new User("test@example.com"));
    stormify.create(new Profile(user.getId(), "Test User"));
});

Returning a Value

The transaction block returns whatever its body returns, so you can lift a computed value out of the transaction directly.

val userId: Int = stormify.transaction {
    val user = stormify.create(User(email = "test@example.com"))
    user.id
}
Integer userId = stormify.transaction(() -> {
    User user = stormify.create(new User("test@example.com"));
    return user.getId();
});

In Java the value-returning overload takes a Supplier<R>; the Runnable overload (no return value) is available for fire-and-forget transactions.

Nested Transactions

Nested calls to transaction on the same Stormify instance become savepoints automatically — a failure inside the inner block rolls back only its work, not the outer transaction.

stormify.transaction {
    stormify.create(record1)

    stormify.transaction {          // Becomes a savepoint
        stormify.create(record2)
        // If this fails, only record2 is rolled back
    }

    stormify.create(record3)        // Still executes
}
stormify.transaction(() -> {
    stormify.create(record1);

    stormify.transaction(() -> {    // Becomes a savepoint
        stormify.create(record2);
        // If this fails, only record2 is rolled back
    });

    stormify.create(record3);       // Still executes
});

Extracting Transaction Logic

For complex business logic, extract operations into reusable helpers.

Kotlin — plain functions

fun registerUser(stormify: Stormify, email: String, name: String) {
    val user = stormify.create(User(email = email))
    stormify.create(Profile(userId = user.id, name = name))
    stormify.create(AuditLog(action = "User registered", userId = user.id))
}

fun transferFunds(stormify: Stormify, from: Account, to: Account, amount: Double) {
    require(from.balance >= amount) { "Insufficient funds" }
    stormify.update(from.copy(balance = from.balance - amount))
    stormify.update(to.copy(balance = to.balance + amount))
    stormify.create(Transaction(fromId = from.id, toId = to.id, amount = amount))
}

// Usage
stormify.transaction {
    registerUser(stormify, "alice@example.com", "Alice")
    transferFunds(stormify, accountA, accountB, 100.0)
}

If you've called stormify.asDefault(), the helpers can just use the top-level extensions (user.create() etc.) and skip the stormify parameter entirely.

Java — service classes

class UserService {
    private final StormifyJ stormify;

    UserService(StormifyJ stormify) { this.stormify = stormify; }

    void registerUser(String email, String name) {
        User user = stormify.create(new User(email));
        stormify.create(new Profile(user.getId(), name));
    }
}

stormify.transaction(() -> {
    UserService userService = new UserService(stormify);
    userService.registerUser("alice@example.com", "Alice");
});

All helpers naturally participate in whichever enclosing transaction is active — every Stormify operation picks up the current transaction automatically.

Coroutines (Suspend API)

Stormify provides an optional suspend-based transaction API for Kotlin coroutine projects. All database operations run on the IO dispatcher, and coroutine cancellation is wired to the underlying database cancel primitive.

Extra Dependency

The coroutines API requires kotlinx-coroutines-core as a runtime dependency. It is not pulled transitively — you must add it yourself:

implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-coroutines-core</artifactId>
    <version>1.10.2</version>
</dependency>

Setup

Create a SuspendStormify from an existing Stormify instance:

import onl.ycode.stormify.Stormify
import onl.ycode.stormify.coroutines.*

val stormify = Stormify(dataSource)
val async = stormify.suspending(PoolConfig(
    minConnections = 2,
    maxConnections = 10,
))

The suspending() extension creates an internal connection pool configured by PoolConfig. The blocking Stormify instance continues to work independently — SuspendStormify is purely additive. You can use both APIs side-by-side.

Suspend Transactions

async.transaction {
    val user = stormify.create(User(email = "test@example.com"))
    stormify.create(Profile(userId = user.id, name = "Test User"))
}

All operations inside the block run on the IO dispatcher. The transaction commits on success and rolls back on any exception. Convenience calls on the underlying Stormify instance transparently join the transaction even after a dispatcher hop (delay, withContext, etc.) on JVM / Android.

Nested Suspend Transactions

Calling transaction from within another transaction on the same coroutine reuses the outer connection via a savepoint:

async.transaction {
    stormify.create(record1)
    async.transaction {
        // Uses savepoint — rollback only affects this inner block
        stormify.create(record2)
    }
}

Cancellation

When a coroutine running a transaction is cancelled, the pool dispatches Connection.cancel() which maps to the driver's native async-cancel primitive:

Platform Cancel mechanism
Native (PostgreSQL) PQcancel
Native (SQLite) sqlite3_interrupt
Native (MariaDB) mariadb_cancel
Native (Oracle) dpiConn_breakExecution
Native (MSSQL) dbcancel
JVM Statement.cancel() (JDBC)

The blocked query returns with an error, the transaction rolls back, and the connection is evicted from the pool.

Pool Configuration

PoolConfig controls pool behavior:

PoolConfig(
    minConnections = 2,          // Pre-warmed connections
    maxConnections = 10,         // Hard upper bound
    acquireTimeout = 30.seconds, // Wait time when pool is saturated
    idleTimeout = 5.minutes,     // Evict idle connections
    maxLifetime = 30.minutes,    // Retire long-lived connections
    validationQuery = "SELECT 1" // Optional health check
)

When the pool is saturated, callers suspend (not block) until a connection is released.

Pool Statistics

Monitor pool health via async.stats:

val stats = async.stats
println("total=${stats.total} inUse=${stats.inUse} idle=${stats.idle}")

Shutdown

async.close()  // Waits up to shutdownTimeout (default 30s), then force-closes remaining