Skip to content

Repository files navigation

go-uow

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).

Installation

go get github.com/callmemars1/go-uow/v2

Requires 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.

Quick start

1. Define the repository registry

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),
    }
}

2. Build the factory

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.

3. Run transactions

// 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.

Transaction options

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.

Retries on concurrency conflicts

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.

Query logging

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.

Errors

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

API

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.

Why MustRepoRegistry panics

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.

The pool is yours

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).

Other databases

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)}
})

Migrating from v1

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 error

Implementing 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.LevelDefault is left to the server. Code that passed sql.LevelLinearizable and believed it got linearizability was already broken; it now finds out.
  • A failing Begin no longer leaks its pooled connection. Applications that worked around this by oversizing MaxConns can size it for real load.

Development

go build ./...
go test ./... -race    # starts PostgreSQL in testcontainers; needs Docker
golangci-lint run

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages