Transaction Management

Overview

Transactions ensure the atomicity, consistency, isolation, and durability of a group of database operations. Juice provides two transaction styles:

  1. Manual transactions: engine.Tx() and engine.ContextTx(...)

  2. Functional transactions: juice.Transaction(...) and juice.NestedTransaction(...)

Transaction Interfaces

The core transaction-related interfaces in Juice are:

type Manager interface {
    Object(v any) SQLRowsExecutor
}

type TxManager interface {
    Manager
    Begin() error
    Commit() error
    Rollback() error
}

Manual Transactions

Use manual transactions when you need explicit control over Begin, Commit, and Rollback:

tx := engine.Tx()
if err := tx.Begin(); err != nil {
    return err
}
defer tx.Rollback()

if _, err := tx.Object(Repo{}.CreateUser).ExecContext(ctx, user); err != nil {
    return err
}

if _, err := tx.Object(Repo{}.CreateOrder).ExecContext(ctx, order); err != nil {
    return err
}

return tx.Commit()

Note

It is recommended to always use defer tx.Rollback() as a fallback, and then call tx.Commit() only on the success path.

Functional Transactions

Functional transactions automatically inject the transaction manager into the callback context, which makes them a good fit for service-layer transaction boundaries:

baseCtx := juice.ContextWithManager(context.Background(), engine)

err := juice.Transaction(baseCtx, func(ctx context.Context) error {
    repo := NewUserRepository()
    _, err := repo.CreateUser(ctx, user)
    return err
},
    tx.WithIsolationLevel(sql.LevelReadCommitted),
    tx.WithReadOnly(false),
)

if err != nil {
    return
}

Transaction commits when the callback returns nil and rolls back when the callback returns a non-nil error.

Nested Call Semantics

The semantics of NestedTransaction are: reuse the current transaction if one already exists, otherwise create a new transaction.

err := juice.Transaction(baseCtx, func(ctx context.Context) error {
    if err := serviceA(ctx); err != nil {
        return err
    }

    return juice.NestedTransaction(ctx, func(ctx context.Context) error {
        return serviceB(ctx)
    })
})

Attention

NestedTransaction is not the same as a database savepoint. It does not automatically create an independent inner commit point.

Isolation Level and Read-Only Options

You can specify the isolation level and read-only flag when opening a transaction:

err := juice.Transaction(baseCtx, handler,
    tx.WithIsolationLevel(sql.LevelSerializable),
    tx.WithReadOnly(true),
)

These options are aligned with database/sql and sql.TxOptions.

Further Reading