A generic Unit of Work implementation for Go. Transactions are opened, committed and rolled back in one place; your code only writes the work that happens inside them.
Two implementations ship with the library: pgxv5 (PostgreSQL over pgx/v5) and
stdsql (anything with a database/sql driver).
go get github.com/callmemars1/go-uow/v2Requires Go 1.25 or later. Upgrading from v0.x/v1.x? See
Migrating from v1 — the import path changed and there are
breaking API changes.
The registry is whatever set of repositories your transaction needs. It is built per transaction, over that transaction's handle.
type RepoRegistry struct {
Users UserRepository
Orders OrderRepository
}
func NewRepoRegistry(tx pgx.Tx) RepoRegistry {
return RepoRegistry{
Users: NewUserRepository(tx),
Orders: NewOrderRepository(tx),
}
}pool, err := pgxpool.New(ctx, "postgres://user:password@localhost:5432/dbname")
if err != nil {
log.Fatal(err)
}
defer pool.Close() // the pool is yours: the factory never closes it
factory := pgxv5.NewFactory(pool, NewRepoRegistry)The factory is a long-lived value — build it once at startup and inject it.
// No result: committed if the action returns nil, rolled back otherwise.
err := uow.RunTx(ctx, factory, func(r RepoRegistry) error {
user := &User{Name: "John Doe"}
if err := r.Users.Create(ctx, user); err != nil {
return err
}
return r.Orders.Create(ctx, &Order{UserID: user.ID, Amount: 100})
}, uow.DefaultTxOptions())
// With a result, returned by value.
user, err := uow.RunTxWithResult(ctx, factory, func(r RepoRegistry) (User, error) {
return r.Users.GetByID(ctx, "user-123")
}, uow.ReadOnlyTxOptions())RunTx and RunTxWithResult are the only sanctioned entry points. They own the
whole lifecycle: acquire, begin, commit or roll back, and release — including
when the action panics, in which case the transaction is rolled back and the
panic is re-raised.
Options are plain *sql.TxOptions. Three helpers cover the common cases:
| Helper | Isolation | Access |
|---|---|---|
uow.DefaultTxOptions() |
read committed | read-write |
uow.SerializableTxOptions() |
serializable | read-write |
uow.ReadOnlyTxOptions() |
read committed | read-only |
Passing nil, or sql.LevelDefault, leaves the choice to the server — the
library does not substitute a level of its own.
pgxv5 rejects levels PostgreSQL does not have (sql.LevelSnapshot,
sql.LevelLinearizable, sql.LevelWriteCommitted): Begin fails with
pgxv5.ErrUnsupportedIsolationLevel rather than quietly downgrading you to read
committed.
Optimistic concurrency needs a re-read and a retry, and a retry only makes sense
in a new transaction. RunTxWithRetry does exactly that: every attempt gets a
fresh UOW and a fresh transaction.
balance, err := uow.RunTxWithRetry(ctx, factory, func(r RepoRegistry) (int, error) {
account, err := r.Accounts.GetByID(ctx, id) // re-read on every attempt
if err != nil {
return 0, err
}
return r.Accounts.Withdraw(ctx, account, 100)
}, uow.SerializableTxOptions(), pgxv5.DefaultRetryPolicy())pgxv5.DefaultRetryPolicy() retries three times with a jittered backoff on
SQLSTATE 40001 (serialization failure) and 40P01 (deadlock detected), matched
through errors.As on *pgconn.PgError — never on the error text. Everything
else is returned immediately.
A cancelled context stops the retries at once, including mid-backoff. When the
attempts run out, the error wraps uow.ErrRetryExhausted and the last failure
stays reachable through errors.Is.
Which errors deserve a retry is a driver decision, so the core carries only the predicate:
type RetryPolicy struct {
MaxAttempts int // total attempts, not extra tries; default 3
BaseBackoff time.Duration // doubles per attempt
MaxBackoff time.Duration // 0 means uncapped
IsRetryable func(error) bool // nil retries nothing
}stdsql ships no predicate because its driver is unknown — pass your own, or
pgxv5.IsRetryable for any PostgreSQL driver whose errors unwrap to
*pgconn.PgError.
Off by default. Enable it by handing the factory a *slog.Logger:
factory := pgxv5.NewFactoryWithOptions(pool, logger, pgxv5.DefaultOptions(), NewRepoRegistry)Options.QueryLogLevel sets the level (pgxv5.WithQueryLogLevel(slog.LevelInfo),
pgxv5.DisableQueryLogging()). At tracelog.LogLevelNone no tracing wrapper is
built at all, so disabled logging costs nothing.
Logged statements include their arguments. Point this at a logger you are happy to have personal data in, or leave it off.
Every failure is wrapped with the stage it happened at
(unitofwork: failed to begin transaction: …), and the original error stays
reachable through errors.Is / errors.As. An error the action itself returned
is passed through unwrapped.
If a rollback fails on top of another failure, both are kept: the message names
the rollback error and errors.Is still finds the original.
| Sentinel | Meaning |
|---|---|
uow.ErrTransactionNotStarted |
Commit/Rollback before Begin |
uow.ErrRetryExhausted |
RunTxWithRetry ran out of attempts |
pgxv5.ErrUnsupportedIsolationLevel |
the requested level has no PostgreSQL equivalent |
type UOW[TRepoRegistry any] interface {
MustRepoRegistry() TRepoRegistry
Begin(ctx context.Context, options *sql.TxOptions) error
Commit(ctx context.Context) error
Rollback(ctx context.Context) error
Close()
}
type Factory[TRepoRegistry any] interface {
NewUOW(ctx context.Context) (UOW[TRepoRegistry], error)
}
func RunTx[TRepoRegistry any](
ctx context.Context,
factory Factory[TRepoRegistry],
action TxAction[TRepoRegistry],
options *sql.TxOptions,
) error
func RunTxWithResult[TRepoRegistry, TReturn any](
ctx context.Context,
factory Factory[TRepoRegistry],
action TxActionWithResult[TRepoRegistry, TReturn],
options *sql.TxOptions,
) (TReturn, error)
func RunTxWithRetry[TRepoRegistry, TReturn any](
ctx context.Context,
factory Factory[TRepoRegistry],
action TxActionWithResult[TRepoRegistry, TReturn],
options *sql.TxOptions,
retry RetryPolicy,
) (TReturn, error)Close is the cleanup hook the RunTx* helpers defer immediately after creating
a UOW. It rolls back a still-open transaction and releases the connection, is
idempotent, and rolls back on a background context so cleanup still happens when
the request context is already cancelled.
It is only ever called from inside RunTx*, after Begin has succeeded, so the
"transaction not started" case is unreachable through the public API. It stays a
panic rather than an error return so the action signature carries no error that
can never occur. Implementing UOW yourself is the only way to reach it — that
is the contract those implementations must honour.
Factories never close the pool or the *sql.DB they were given: its lifecycle
belongs to whoever created it (usually a DI container's shutdown hook).
Any type satisfying UOW and Factory works with the helpers. stdsql covers
every database/sql driver:
db, err := sql.Open("pgx", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
factory := stdsql.NewFactory(db, func(tx *sql.Tx) RepoRegistry {
return RepoRegistry{Users: NewUserRepository(tx)}
})Four breaking changes. All of them are compile errors, so nothing fails silently.
1. The import path gained /v2.
-import "github.com/callmemars1/go-uow"
-import "github.com/callmemars1/go-uow/pgxv5"
+import uow "github.com/callmemars1/go-uow/v2"
+import "github.com/callmemars1/go-uow/v2/pgxv5"2. NewFactory no longer returns an error.
-factory, err := pgxv5.NewFactory(pool, NewRepoRegistry)
-if err != nil {
- log.Fatal(err)
-}
+factory := pgxv5.NewFactory(pool, NewRepoRegistry)3. Factory.Release() is gone. It closed a pool the factory did not own.
Close the pool where you created it.
-defer factory.Release()
+defer pool.Close()4. The action receives the registry, and results come back by value.
-res, err := uow.RunTxWithResult(ctx, factory, func(u uow.UOW[RepoRegistry]) (*User, error) {
- r := u.MustRepoRegistry()
- return r.Users.GetByID(ctx, id)
-}, uow.DefaultTxOptions())
-name := res.Name // res is a *User
+res, err := uow.RunTxWithResult(ctx, factory, func(r RepoRegistry) (User, error) {
+ return r.Users.GetByID(ctx, id)
+}, uow.DefaultTxOptions())
+name := res.Name // res is a User; the zero value on errorImplementing UOW yourself? The interface gained Close(). It must roll
back an open transaction, release whatever the implementation holds, and do
nothing on a second call.
Also worth knowing, though neither is a compile error:
- Unsupported isolation levels now fail instead of silently becoming read
committed, and
sql.LevelDefaultis left to the server. Code that passedsql.LevelLinearizableand believed it got linearizability was already broken; it now finds out. - A failing
Beginno longer leaks its pooled connection. Applications that worked around this by oversizingMaxConnscan size it for real load.
go build ./...
go test ./... -race # starts PostgreSQL in testcontainers; needs Docker
golangci-lint run