The examples use GORM, but the approach does not depend on an ORM. The same manager can be built on database/sql, pgx, or another way of working with PostgreSQL. Only the infrastructure implementation changes; the use case still depends on a small interface.
Contents
- Where the problem appears
- The complete implementation
- Who should manage the transaction
- How to store a transaction in context
- Why ExtractDB must come first
- The order of operations in InTransaction
- How repositories use the transaction
- The transaction boundary in a use case
- Operations that require a transaction
- How context controls the lifecycle
- Pessimistic locking
- Limitations
- Unit of Work as an explicit alternative
- When to use an existing transaction manager
- How to test the implementation
- Practical rules
- References
Where the problem appears
Consider an order checkout. The operation must:
- Create the order.
- Add its line items.
- Change the payment state.
If the third step fails, the first two must roll back as well. Every repository therefore has to use the same transaction.
Without a shared boundary, the code easily turns into three independent writes:
order, err := uc.orders.Create(ctx, input.Order)
if err != nil {
return err
}
if err := uc.items.Create(ctx, order.ID, input.Items); err != nil {
return err
}
return uc.payments.MarkAuthorized(ctx, input.PaymentID)
Each call can succeed on its own. A failure in the final repository cannot undo changes made by the previous two.
Passing *gorm.DB to every method is another option:
orders.Create(ctx, tx, input.Order)
items.Create(ctx, tx, order.ID, input.Items)
payments.MarkAuthorized(ctx, tx, input.PaymentID)
The use case now knows about GORM, and every repository signature mixes business arguments with an infrastructure object. Moving to database/sql or pgx would require changes above the storage layer.
The complete implementation
The entire mechanism fits in one package. Here is the complete implementation; the following sections explain each part.
// internal/pkg/transaction/transaction.go
package transaction
import (
"context"
"errors"
"gorm.io/gorm"
)
type Manager interface {
InTransaction(
ctx context.Context,
fn func(context.Context) error,
) error
}
type txContextKey struct{}
var txKey txContextKey
var ErrTransactionNotFound = errors.New("transaction not found")
type GormManager struct {
db *gorm.DB
}
func NewGormManager(db *gorm.DB) *GormManager {
return &GormManager{db: db}
}
func (m *GormManager) InTransaction(
ctx context.Context,
fn func(context.Context) error,
) error {
if err := ctx.Err(); err != nil {
return err
}
db := ExtractDB(ctx, m.db).WithContext(ctx)
return db.Transaction(func(tx *gorm.DB) error {
txCtx := context.WithValue(ctx, txKey, tx)
return fn(txCtx)
})
}
func ExtractDB(ctx context.Context, defaultDB *gorm.DB) *gorm.DB {
if tx, ok := ctx.Value(txKey).(*gorm.DB); ok {
return tx
}
return defaultDB
}
func ExtractTx(ctx context.Context) (*gorm.DB, error) {
if tx, ok := ctx.Value(txKey).(*gorm.DB); ok {
return tx, nil
}
return nil, ErrTransactionNotFound
}
Each repository selects its connection again for every method:
type OrderRepository struct {
db *gorm.DB
}
func (r *OrderRepository) dbFromContext(ctx context.Context) *gorm.DB {
return transaction.ExtractDB(ctx, r.db).WithContext(ctx)
}
func (r *OrderRepository) Create(
ctx context.Context,
order Order,
) (Order, error) {
if err := r.dbFromContext(ctx).Create(&order).Error; err != nil {
return Order{}, fmt.Errorf("create order: %w", err)
}
return order, nil
}
The use case opens the boundary and passes txCtx down the call stack:
func (uc *CheckoutUseCase) Execute(
ctx context.Context,
input CheckoutInput,
) error {
return uc.tm.InTransaction(ctx, func(txCtx context.Context) error {
order, err := uc.orders.Create(txCtx, input.Order)
if err != nil {
return fmt.Errorf("create order: %w", err)
}
if err := uc.items.Create(txCtx, order.ID, input.Items); err != nil {
return fmt.Errorf("create order items: %w", err)
}
if err := uc.payments.MarkAuthorized(
txCtx,
input.PaymentID,
); err != nil {
return fmt.Errorf("authorize payment: %w", err)
}
return nil
})
}
The implementation is short, but several mistakes fail silently. The next sections explain why the context key has its own type, why ExtractDB comes before Transaction, and what happens when a repository receives the original ctx.
Who should manage the transaction
The transaction boundary should match the business operation. Only the use case knows which actions must succeed together, so it calls the transaction manager.
The use case does not need to know how the manager runs BEGIN, COMMIT, and ROLLBACK. It sees only the Manager interface with one method. The composition root provides the implementation, so replacing GORM with pgx does not affect the use case.
The callback receives a derived context. Every repository call inside it must use that context.
The manager owns the transaction boundary, while each repository executes queries on the correct connection. A repository knows how to call ExtractDB, but it neither opens the transaction nor decides when to commit it.
How to store a transaction in context
The listing declares an unexported txContextKey type, even though a string would be shorter:
context.WithValue(ctx, "tx", tx)
Every Context forms a chain with its current value and its parents. Value searches for a key from the current context up through that chain.
A collision occurs when two packages use the same string key in one chain:
request context
└── middleware A: "tx" = firstValue
└── middleware B: "tx" = secondValue
└── Value("tx") returns secondValue
A distinct key type makes another package’s key unequal to yours even when both are visually named tx. This follows the recommendation in the context.WithValue documentation.
Two package functions read the stored value, with different contracts:
ExtractDBreturns the current transaction or the primary connection;ExtractTxrequires an active transaction and returns an error when none exists.
Why ExtractDB must come first
Before starting another Transaction, the manager selects the current connection:
db := ExtractDB(ctx, m.db)
This line belongs before the Transaction call. A nested operation shows why.
Suppose payment logic has moved into a separate PaymentUseCase. It can run on its own, so it also opens a boundary with InTransaction. Checkout calls that neighboring use case instead of calling the payment repository directly:
func (uc *CheckoutUseCase) Execute(
ctx context.Context,
input CheckoutInput,
) error {
return uc.tm.InTransaction(ctx, func(txCtx context.Context) error {
if _, err := uc.orders.Create(txCtx, input.Order); err != nil {
return err
}
return uc.payment.Execute(txCtx, input.PaymentID)
})
}
If the manager always starts transactions from the root m.db, the result is two independent transactions:
func (m *GormManager) InTransaction(
ctx context.Context,
fn func(context.Context) error,
) error {
return m.db.Transaction(func(tx *gorm.DB) error {
return fn(context.WithValue(ctx, txKey, tx))
})
}
The actual structure is:
m.db
├── tx1: CheckoutUseCase
└── tx2: PaymentUseCase
The inner callback receives tx2, and repositories correctly extract it from context. But tx2 has no relationship to tx1. If the inner transaction commits and the outer transaction later rolls back, the payment changes remain in the database.
A separate transaction also causes less obvious failures. It cannot see uncommitted data from the outer transaction because PostgreSQL does not allow dirty reads. If the outer transaction has already locked a required row, the inner one may wait for that lock until its deadline expires.
ExtractDB changes the receiver of the Transaction call:
no transaction in context → m.db.Transaction → regular transaction
transaction in context → tx1.Transaction → nested transaction
GORM creates a nested transaction through a savepoint only when Transaction is called on the current tx. The official GORM documentation shows this form.
Completing the inner callback no longer commits its data independently of the outer transaction. The final COMMIT or ROLLBACK still belongs to the outer boundary.
The order of operations in InTransaction
The order inside InTransaction matters because each operation uses the result of the previous one:
ExtractDBselects the primary connection or an existing transaction.WithContextgives GORM the operation’s cancellation signal and deadline.Transactionopens a regular transaction or a savepoint.context.WithValuemakes the resultingtxavailable to repositories farther down the call stack.
If the project prohibits nested transactions, enforce that rule in code:
var ErrNestedTransaction = errors.New("nested transaction is not allowed")
func (m *GormManager) InTransaction(
ctx context.Context,
fn func(context.Context) error,
) error {
if err := ctx.Err(); err != nil {
return err
}
if _, err := ExtractTx(ctx); err == nil {
return ErrNestedTransaction
}
return m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
txCtx := context.WithValue(ctx, txKey, tx)
return fn(txCtx)
})
}
An accidental nested call now returns a clear error instead of creating a separate transaction.
How repositories use the transaction
A repository receives its primary connection at construction time and selects the working connection again on every call through dbFromContext. Outside InTransaction, the method returns r.db. Inside a transaction, ExtractDB returns the tx stored in context. The method signature stays unchanged, while COMMIT and ROLLBACK remain under the manager’s control.
This does not always mean true autocommit. By default, GORM wraps write operations in its own transactions. The SkipDefaultTransaction option disables that behavior; see the GORM documentation for details.
Every repository method must use dbFromContext. Calling r.db.WithContext(ctx) directly inside an outer transaction quietly executes the query through the primary connection, so the outer ROLLBACK cannot undo it.
Another common mistake is passing the original ctx instead of txCtx:
return uc.tm.InTransaction(ctx, func(txCtx context.Context) error {
// Wrong: the repository cannot see the transaction stored in txCtx.
return uc.orders.Create(ctx, order)
})
Pass the derived context instead:
return uc.tm.InTransaction(ctx, func(txCtx context.Context) error {
return uc.orders.Create(txCtx, order)
})
The transaction boundary in a use case
The Execute method coordinates three repositories without importing GORM. Its code contains no infrastructure types, only Manager and its own dependencies. The three independent writes from the beginning of the article now share one boundary.
Returning any error from the callback causes a ROLLBACK. Returning nil allows the manager to run COMMIT. The Transaction method also returns a commit error, so the caller must not consider the operation successful until InTransaction itself has completed.
Operations that require a transaction
The fallback in ExtractDB is useful for ordinary CRUD methods that may run independently. Some operations, however, must never run outside a transaction.
For example, a queue adapter stores a job in PostgreSQL beside business data. If it silently uses the primary connection, the job may survive after the order rolls back. Such a contract needs the strict ExtractTx function:
func (q *JobQueue) Enqueue(
ctx context.Context,
args PublishOrderArgs,
) error {
tx, err := transaction.ExtractTx(ctx)
if err != nil {
return fmt.Errorf("extract transaction: %w", err)
}
return q.insertWithGormTx(ctx, tx, args)
}
Choosing between ExtractDB and ExtractTx forms part of the method contract:
- a method that can run independently uses
ExtractDB; - an operation that must be atomic with its caller uses
ExtractTx.
How context controls the lifecycle
Checking ctx.Err() before the transaction begins is not sufficient:
if err := ctx.Err(); err != nil {
return err
}
This check rejects an already cancelled context, but cancellation may happen immediately afterward. Pass ctx to GORM before calling Transaction:
db := ExtractDB(ctx, m.db).WithContext(ctx)
database/sql.BeginTx uses the supplied context until the transaction ends. If the context is cancelled, the package rolls back the transaction and Commit returns an error. The database/sql documentation specifies this behavior.
Calling WithContext only inside individual repository methods cancels the corresponding SQL queries, but it does not necessarily bind the beginning and end of the entire transaction to the request context. Set the context both before Transaction and before queries.
Do not store the transactional context or pass it to a background goroutine that outlives the callback:
return tm.InTransaction(ctx, func(txCtx context.Context) error {
go repo.Update(txCtx, orderID)
return nil
})
By the time the goroutine executes its query, the transaction may already be closed. Background work needs its own context with an appropriate lifetime and its own transaction.
Pessimistic locking
A shared transaction can safely combine a locking read, a state check, and a write:
func (uc *PayOrderUseCase) Execute(
ctx context.Context,
orderID int64,
) error {
return uc.tm.InTransaction(ctx, func(txCtx context.Context) error {
order, err := uc.orders.GetForUpdate(txCtx, orderID)
if err != nil {
return err
}
if !order.CanBePaid() {
return ErrOrderCannotBePaid
}
return uc.orders.MarkPaid(txCtx, orderID)
})
}
The repository adds SELECT FOR UPDATE:
func (r *OrderRepository) GetForUpdate(
ctx context.Context,
orderID int64,
) (Order, error) {
var order Order
err := r.dbFromContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", orderID).
Take(&order).Error
if err != nil {
return Order{}, fmt.Errorf("select order for update: %w", err)
}
return order, nil
}
PostgreSQL locks the rows returned by the query. A concurrent UPDATE, DELETE, or another locking statement against the same row waits for the current transaction to finish. A regular SELECT is not blocked, and no row lock is taken when the query finds nothing. The wait may also end because of a deadline or deadlock detection. The PostgreSQL documentation defines the exact rules.
Keep the transaction short. External HTTP calls and other slow work should not run while holding a row lock. At the same time, checks that depend on the locked state cannot be moved outside the transaction without care: the data may change between the read and the write.
Limitations
Tx-in-context removes infrastructure arguments from method signatures, but makes the dependency implicit. The official Go documentation recommends context values for request-scoped data that crosses API boundaries, not as a general-purpose parameter container. A transaction fits the intended lifetime, but its presence is still invisible in the method signature.
The approach carries several risks:
- a new repository method may access
r.dbdirectly; - a caller may pass the wrong context to a repository;
- the fallback may conceal a missing required transaction;
- the manager interface does not expose the isolation level or read-only mode;
- a transactional
Contextcannot be used after the callback returns.
Conventions and tests reduce some of these risks. Critical operations need the strict ExtractTx. If an application frequently uses different isolation levels, read-only transactions, or fully explicit dependencies, a Unit of Work or a separate transactional-session object may be easier to follow.
Unit of Work as an explicit alternative
Martin Fowler defines Unit of Work as an object that tracks changes within a business transaction, then coordinates writing them and resolving concurrency problems. In the original definition, the pattern is closely tied to an object model: the Unit of Work remembers new, changed, and deleted objects before persisting the accumulated changes in one transaction.
In Go, the name often refers to a narrower construction: an object opens one sql.Tx, creates a set of repositories on it, and passes that set to a callback. The use case works with transactional repositories explicitly:
type Stores struct {
Orders OrderRepository
Items OrderItemRepository
Payment PaymentRepository
}
type UnitOfWork interface {
RunInTx(
ctx context.Context,
fn func(Stores) error,
) error
}
Inside the callback, the primary connection cannot be mistaken for a transactional one because the required repositories arrive as arguments. A different mistake remains possible: using a field on the original use case instead of a repository from Stores.
The article Repositories, transactions, and unit of work in Go presents a detailed implementation. It starts with a DBTX interface, demonstrates a single-repository transaction, and arrives at a UnitOfWork that creates several repositories on one sql.Tx.
That article also criticizes passing a transaction through context: the dependency does not appear in the signature, and the wrong context silently falls back to the connection pool. The same criticism applies to the implementation here. The difference is that the use case does not put *sql.Tx into context itself and knows nothing about database/sql or GORM; the manager handles that work. The risk of passing the original ctx instead of txCtx remains.
The two APIs trade explicitness for a smaller surface:
- tx-in-context preserves ordinary repository interfaces and compact signatures but requires discipline when passing context;
- Unit of Work passes transactional repositories explicitly to the callback but adds the
Storescontainer and requires it to be assembled for every transaction.
An explicit Unit of Work may be easier to understand when the set of repositories is small and stable. When repository calls sit deep in the call stack and context.Context already flows through every method, the manager in this article needs less supporting code.
When to use an existing transaction manager
A small manager is easy to write, but a production implementation may need to handle nesting, savepoints, transaction options, multiple databases, and driver-specific behavior.
If maintaining that implementation is outside the project’s scope, Avito’s go-transaction-manager is an existing option. The library follows the same general model: a manager owns the boundary, while a repository obtains the transaction or primary connection through a context getter. It includes adapters for database/sql, sqlx, GORM, pgx, MongoDB, and Redis, together with nested-transaction support.
Using a library does not decide the architecture for you. Before adopting one, define:
- whether nested transactions are allowed and which semantics they need;
- whether each database needs its own context key;
- which isolation levels the application uses;
- whether fallback to the primary connection is acceptable;
- how integration tests verify rollback and context cancellation.
The library README notes that multiple databases need distinct context keys and nested operations through different managers require a special middleware chain. These are the same problems that make an unconditional m.db.Transaction call unsafe in a custom implementation.
How to test the implementation
A unit test for orchestration can use a small fake:
type FakeManager struct {
Err error
}
func (m FakeManager) InTransaction(
ctx context.Context,
fn func(context.Context) error,
) error {
if m.Err != nil {
return m.Err
}
return fn(ctx)
}
This fake verifies how the use case calls dependencies and returns errors, but it does not prove that transactions work. The manager and repositories need integration tests against a real database.
Cover these scenarios:
- All operations succeed and their changes are committed.
- The second repository returns an error and the first repository’s changes roll back.
- A nested
InTransactionsucceeds, then the outer callback returns an error. All changes roll back. - The nested callback returns an error. The result follows the chosen savepoint policy.
- The context is cancelled, so
Commitfails and no data is persisted. - A method that requires
ExtractTxis called outside a transaction and returnsErrTransactionNotFound.
The third test detects an implementation that always calls m.db.Transaction and creates an independent inner transaction. Without this case, the defect stays hidden: the nested callback succeeds and its data persists, diverging only when the outer boundary rolls back.
Practical rules
Review an implementation against this checklist:
- Put the transaction boundary in the use case that knows the complete business operation.
- Make the use case depend on the manager interface, not
*gorm.DB. - Call
ExtractDBbeforeTransaction. - Call
WithContext(ctx)before opening the transaction. - Pass
txCtx, not the originalctx, to every repository inside the callback. - Make each repository method select its connection through
ExtractDB. - Use
ExtractTxfor operations that must not run outside a transaction. - Either implement nesting correctly with savepoints or reject it with an explicit error.
- Keep the transactional context inside the callback and never pass it to a background goroutine.
- Verify rollback, nesting, and context cancellation with integration tests.
Tx-in-context applies to one local transaction only. It cannot provide atomicity across multiple databases or external systems, but it keeps a particular driver or ORM type out of business logic.
References
- context package documentation;
- database/sql: BeginTx and the transaction lifecycle;
- context in GORM;
- transactions, savepoints, and nested transactions in GORM;
- PostgreSQL transaction isolation;
- explicit and row-level locking in PostgreSQL;
- Unit of Work in Martin Fowler’s catalog;
- repositories, transactions, and Unit of Work in Go;
- Avito’s go-transaction-manager.