A generic Unit of Work (UOW) pattern implementation for Go with PostgreSQL support using pgx/v5.
This library provides a clean and type-safe implementation of the Unit of Work pattern, designed to simplify database transaction management in Go applications. It supports PostgreSQL through the pgx/v5 driver and offers a generic interface that can work with any repository registry.
- Generic Design: Type-safe implementation using Go generics
- PostgreSQL Support: Built on top of
pgx/v5for high-performance PostgreSQL operations - Transaction Management: Automatic transaction handling with commit/rollback
- Isolation Levels: Support for all PostgreSQL transaction isolation levels
- Read-Only Transactions: Built-in support for read-only transaction modes
- Error Handling: Comprehensive error handling with custom error types
- Resource Management: Automatic connection pool management
go get github.com/callmemars1/go-uowtype UserRepository interface {
Create(ctx context.Context, user *User) error
GetByID(ctx context.Context, id string) (*User, error)
}
type OrderRepository interface {
Create(ctx context.Context, order *Order) error
GetByUserID(ctx context.Context, userID string) ([]*Order, error)
}
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)
}
factory, err := pgxv5.NewFactory(pool, NewRepoRegistry)
if err != nil {
log.Fatal(err)
}
// Note: In real applications, the factory should be a long-lived resource
// Only call factory.Release() when shutting down your application// Simple transaction
err := uow.RunTx(ctx, factory, func(uow uow.UOW[RepoRegistry]) error {
repos := uow.MustRepoRegistry()
user := &User{Name: "John Doe"}
if err := repos.Users.Create(ctx, user); err != nil {
return err
}
order := &Order{UserID: user.ID, Amount: 100}
return repos.Orders.Create(ctx, order)
}, uow.DefaultTxOptions())
// Transaction with result
result, err := uow.RunTxWithResult(ctx, factory, func(uow uow.UOW[RepoRegistry]) (*User, error) {
repos := uow.MustRepoRegistry()
return repos.Users.GetByID(ctx, "user-123")
}, uow.SerializableTxOptions())type UOW[TRepoRegistry any] interface {
MustRepoRegistry() TRepoRegistry
Begin(ctx context.Context, options TxOptions) error
Commit(ctx context.Context) error
Rollback(ctx context.Context) error
}type Factory[TRepoRegistry any] interface {
NewUOW(ctx context.Context) (UOW[TRepoRegistry], error)
Release() error
}The library uses standard database/sql.TxOptions for transaction configuration:
type TxOptions struct {
Isolation IsolationLevel
ReadOnly bool
}DefaultTxOptions(): Read committed, read-writeSerializableTxOptions(): Serializable, read-writeReadOnlyTxOptions(): Read committed, read-only
sql.LevelDefaultsql.LevelReadUncommittedsql.LevelReadCommittedsql.LevelRepeatableReadsql.LevelSerializablesql.LevelSnapshotsql.LevelLinearizable
Executes a transaction without returning a result:
func RunTx[TRepoRegistry any](
ctx context.Context,
factory Factory[TRepoRegistry],
action TxAction[TRepoRegistry],
options TxOptions,
) errorExecutes a transaction and returns a result:
func RunTxWithResult[TRepoRegistry any, TReturn any](
ctx context.Context,
factory Factory[TRepoRegistry],
action TxActionWithResult[TRepoRegistry, TReturn],
options TxOptions,
) (res *TReturn, err error)The library provides custom error types:
ErrTransactionNotStarted: Returned when attempting to access repositories before starting a transaction
- Always use the helper functions:
RunTxandRunTxWithResulthandle transaction lifecycle automatically - Define repository interfaces: This ensures type safety and testability
- Use appropriate isolation levels: Choose based on your application's consistency requirements
- Handle errors properly: Always check returned errors from UOW operations
- Release resources: Call
factory.Release()only when shutting down your application, not immediately after creation
The factory should be a long-lived resource in your application. Here are common patterns:
var factory uow.Factory[RepoRegistry]
func init() {
pool, err := pgxpool.New(context.Background(), "postgres://...")
if err != nil {
log.Fatal(err)
}
factory, err = pgxv5.NewFactory(pool, NewRepoRegistry)
if err != nil {
log.Fatal(err)
}
}
func shutdown() {
if factory != nil {
factory.Release()
}
}type App struct {
Factory uow.Factory[RepoRegistry]
}
func NewApp() *App {
pool, err := pgxpool.New(context.Background(), "postgres://...")
if err != nil {
log.Fatal(err)
}
factory, err := pgxv5.NewFactory(pool, NewRepoRegistry)
if err != nil {
log.Fatal(err)
}
return &App{Factory: factory}
}
func (app *App) Shutdown() {
if app.Factory != nil {
app.Factory.Release()
}
}package main
import (
"context"
"log"
"github.com/callmemars1/go-uow"
"github.com/callmemars1/go-uow/pgxv5"
"github.com/jackc/pgx/v5/pgxpool"
)
type User struct {
ID string
Name string
}
type UserRepository interface {
Create(ctx context.Context, user *User) error
GetByID(ctx context.Context, id string) (*User, error)
}
type userRepository struct {
tx pgx.Tx
}
func NewUserRepository(tx pgx.Tx) UserRepository {
return &userRepository{tx: tx}
}
func (r *userRepository) Create(ctx context.Context, user *User) error {
// Implementation using r.tx
return nil
}
func (r *userRepository) GetByID(ctx context.Context, id string) (*User, error) {
// Implementation using r.tx
return nil, nil
}
type RepoRegistry struct {
Users UserRepository
}
func NewRepoRegistry(tx pgx.Tx) RepoRegistry {
return RepoRegistry{
Users: NewUserRepository(tx),
}
}
func main() {
ctx := context.Background()
pool, err := pgxpool.New(ctx, "postgres://user:password@localhost:5432/dbname")
if err != nil {
log.Fatal(err)
}
factory, err := pgxv5.NewFactory(pool, NewRepoRegistry)
if err != nil {
log.Fatal(err)
}
// In a real application, you would typically:
// 1. Store the factory as a global variable, or
// 2. Pass it through dependency injection, or
// 3. Use it as a singleton
// Only call factory.Release() when shutting down your application
// Create a user
err = uow.RunTx(ctx, factory, func(uow uow.UOW[RepoRegistry]) error {
repos := uow.MustRepoRegistry()
user := &User{Name: "John Doe"}
return repos.Users.Create(ctx, user)
}, uow.DefaultTxOptions())
if err != nil {
log.Fatal(err)
}
}The library provides base interfaces that allow you to implement the UOW pattern for any database. The core interfaces are database-agnostic:
type UOW[TRepoRegistry any] interface {
MustRepoRegistry() TRepoRegistry
Begin(ctx context.Context, options TxOptions) error
Commit(ctx context.Context) error
Rollback(ctx context.Context) error
}
type Factory[TRepoRegistry any] interface {
NewUOW(ctx context.Context) (UOW[TRepoRegistry], error)
Release() error
}To implement UOW for a different database, you need to:
- Implement the
UOWinterface for your database's transaction type - Implement the
Factoryinterface to create UOW instances - Use the same helper functions (
RunTx,RunTxWithResult) with your implementation
import (
"database/sql"
"github.com/go-sql-driver/mysql"
)
type MySQLUOW[TRepoRegistry any] struct {
tx *sql.Tx
repoRegistry TRepoRegistry
}
func (u *MySQLUOW[TRepoRegistry]) MustRepoRegistry() TRepoRegistry {
return u.repoRegistry
}
func (u *MySQLUOW[TRepoRegistry]) Begin(ctx context.Context, options TxOptions) error {
// MySQL doesn't support all PostgreSQL isolation levels
// Map to supported levels or use defaults
return nil // Transaction already started
}
func (u *MySQLUOW[TRepoRegistry]) Commit(ctx context.Context) error {
return u.tx.Commit()
}
func (u *MySQLUOW[TRepoRegistry]) Rollback(ctx context.Context) error {
return u.tx.Rollback()
}
type MySQLFactory[TRepoRegistry any] struct {
db *sql.DB
repoFactory func(*sql.Tx) TRepoRegistry
}
func NewMySQLFactory(db *sql.DB, repoFactory func(*sql.Tx) TRepoRegistry) *MySQLFactory[TRepoRegistry] {
return &MySQLFactory[TRepoRegistry]{
db: db,
repoFactory: repoFactory,
}
}
func (f *MySQLFactory[TRepoRegistry]) NewUOW(ctx context.Context) (uow.UOW[TRepoRegistry], error) {
tx, err := f.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
return &MySQLUOW[TRepoRegistry]{
tx: tx,
repoRegistry: f.repoFactory(tx),
}, nil
}
func (f *MySQLFactory[TRepoRegistry]) Release() error {
return f.db.Close()
}import (
"database/sql"
_ "github.com/mattn/go-sqlite3"
)
type SQLiteUOW[TRepoRegistry any] struct {
tx *sql.Tx
repoRegistry TRepoRegistry
}
func (u *SQLiteUOW[TRepoRegistry]) MustRepoRegistry() TRepoRegistry {
return u.repoRegistry
}
func (u *SQLiteUOW[TRepoRegistry]) Begin(ctx context.Context, options TxOptions) error {
return nil // Transaction already started
}
func (u *SQLiteUOW[TRepoRegistry]) Commit(ctx context.Context) error {
return u.tx.Commit()
}
func (u *SQLiteUOW[TRepoRegistry]) Rollback(ctx context.Context) error {
return u.tx.Rollback()
}
type SQLiteFactory[TRepoRegistry any] struct {
db *sql.DB
repoFactory func(*sql.Tx) TRepoRegistry
}
func NewSQLiteFactory(db *sql.DB, repoFactory func(*sql.Tx) TRepoRegistry) *SQLiteFactory[TRepoRegistry] {
return &SQLiteFactory[TRepoRegistry]{
db: db,
repoFactory: repoFactory,
}
}
func (f *SQLiteFactory[TRepoRegistry]) NewUOW(ctx context.Context) (uow.UOW[TRepoRegistry], error) {
tx, err := f.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
return &SQLiteUOW[TRepoRegistry]{
tx: tx,
repoRegistry: f.repoFactory(tx),
}, nil
}
func (f *SQLiteFactory[TRepoRegistry]) Release() error {
return f.db.Close()
}Once you have your custom implementation, you can use it with the same helper functions:
// MySQL example
db, err := sql.Open("mysql", "user:password@/dbname")
if err != nil {
log.Fatal(err)
}
factory := NewMySQLFactory(db, NewRepoRegistry)
defer factory.Release()
err = uow.RunTx(ctx, factory, func(uow uow.UOW[RepoRegistry]) error {
repos := uow.MustRepoRegistry()
// Your transaction logic here
return nil
}, uow.DefaultTxOptions())- Go 1.24.4 or later
- PostgreSQL database (for the provided pgx/v5 implementation)
github.com/jackc/pgx/v5driver (for the provided pgx/v5 implementation)