uow is a framework-agnostic Unit of Work and transaction manager for Go.
It provides:
- immutable execution-scoped binding resolution
- explicit and ambient transaction execution
- strict or emulated nested transactions
- tenant-aware client selection
- rollback-only semantics
- native
net/httpand Fiber v2 integration packages - interceptor and hook-based observability
The package is designed for service code that should depend on a small,
stable UnitOfWork contract while leaving concrete adapter behavior at the
edge of the application.
Transactional code often drifts into framework-specific middleware, adapter
types leaking into application services, or inconsistent nested semantics.
uow centralizes those rules in a transport-neutral package:
- owners resolve one binding per execution
- repositories fetch the current backend handle through
CurrentHandle() - explicit and ambient execution use the same resolver and transaction model
- tenant and override precedence remain deterministic under test
go get github.com/pakasa-io/uowThe module ships first-party adapters for:
database/sqlingithub.com/pakasa-io/uow/adapters/sql- GORM in
github.com/pakasa-io/uow/adapters/gorm
It also ships first-party framework integration packages for:
net/httpingithub.com/pakasa-io/uow/framework/http- Fiber v2 in
github.com/pakasa-io/uow/framework/fiber
package main
import (
"context"
"database/sql"
"fmt"
"github.com/pakasa-io/uow"
sqladapter "github.com/pakasa-io/uow/adapters/sql"
)
func main() {
db, err := sql.Open("driver-name", "dsn")
if err != nil {
panic(err)
}
defer db.Close()
registry := uow.NewRegistry()
registry.MustRegister(uow.Registration{
Adapter: sqladapter.New("sql"),
Client: db,
ClientName: "primary",
Default: true,
})
manager, err := uow.NewManager(registry, uow.DefaultConfig(), uow.ManagerOptions{})
if err != nil {
panic(err)
}
err = manager.InTx(context.Background(), uow.RootTx(
uow.WithLabel("bootstrap"),
), func(ctx context.Context) error {
current := sqladapter.MustCurrent(uow.MustFrom(ctx))
fmt.Printf("%T\n", current)
return nil
})
if err != nil {
panic(err)
}
}ExecutionConfig and TxConfig remain available as plain structs. When you
want additive construction instead of editing struct literals, use Exec(...)
for ambient execution and RootTx(...) for explicit root transactions:
execCfg := uow.Exec(
uow.WithClient("primary"),
uow.WithTransactional(uow.TransactionalOn),
uow.WithReadOnly(),
uow.WithLabel("reports"),
)
txCfg, err := uow.TxConfigFromExecution(execCfg)
if err != nil {
panic(err)
}
err = manager.InTx(context.Background(), txCfg, func(ctx context.Context) error {
return nil
})Runnable end-to-end examples live under examples/:
Manager is the entry point for binding resolution and managed execution.
Use:
ResolveInfoorResolveBindingfor owner-side lookupAttachto bind a default-resolved non-transactionalUnitOfWorkBindto create a non-transactional execution-scopedUnitOfWorkRunfor ambient request/job/command executionInTxandInNestedTxfor explicit transactional execution
Application code should depend on UnitOfWork, not adapter-specific clients.
Key rules:
Binding()exposes metadata onlyCurrentHandle()returns the live transactional handle when a root existsCurrentHandle()returns the bound client in non-transactional flows- repositories should acquire the current handle at call time
Binding resolution is deterministic and mode-aware:
- ambient resolution applies
BindingOverridebeforeExecutionConfig - explicit resolution applies
TxConfigbeforeBindingOverride - tenant-specific registrations win over non-tenant registrations
- tenant fallback is allowed only when tenant resolution is not required
NestedStrict:
- requires adapter nested transaction or savepoint support
- returns
ErrNestedTxUnsupportedwhen unavailable
NestedEmulated:
- never requires adapter nested support
- nested rollback marks the root rollback-only
- nested commit is logical only
Start with uow.DefaultConfig() and override only the fields your
application needs:
cfg := uow.DefaultConfig()
cfg.TransactionMode = uow.GlobalAuto
cfg.NestedMode = uow.NestedEmulated
cfg.RequireTenantResolution = trueYou can also load the serializable subset from environment variables:
cfg, err := uow.ConfigFromEnv("UOW")Supported keys:
UOW_NESTED_MODEUOW_TRANSACTION_MODEUOW_DEFAULT_ADAPTER_NAMEUOW_DEFAULT_CLIENT_NAMEUOW_STRICT_OPTION_ENFORCEMENTUOW_ALLOW_OPTION_DOWNGRADEUOW_REQUIRE_TENANT_RESOLUTION
Custom finalize policies remain code-only because they are Go interfaces.
Managed execution should always propagate the UnitOfWork:
u := uow.MustFrom(ctx)
handle := u.CurrentHandle()Optional context helpers:
WithBindingOverride/BindingOverrideFromWithTenantID/TenantIDFromContextContextTenantPolicy
The httpuow package provides:
httpuow.Middleware(manager, cfg)for standard middleware compositionhttpuow.Wrap(manager, cfg, handler)for per-route wrapping- request-time execution, tenant, and binding override resolution
- optional status-based rollback policies
Per-route configuration is explicit because each route can be wrapped with its
own Config:
mux.Handle("/users", httpuow.Wrap(manager, httpuow.Config{
Execution: uow.ExecutionConfig{
Transactional: uow.TransactionalOn,
Label: "list-users",
},
ResolveTenant: func(r *http.Request) (string, error) {
return r.Header.Get("X-Tenant-ID"), nil
},
RollbackOnStatus: httpuow.RollbackOn5xx,
}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
work := uow.MustFrom(r.Context())
_ = work.CurrentHandle()
w.WriteHeader(http.StatusOK)
})))For broader defaults, use the same middleware at the router or subrouter level:
secured := httpuow.Middleware(manager, httpuow.Config{
Execution: uow.ExecutionConfig{Transactional: uow.TransactionalOn},
})Status-based rollback is transport policy only. The middleware marks the active transaction rollback-only when configured and a matching status code is observed.
Transactional net/http routes are response-buffered until finalization so a
commit failure can still produce an error response. Streaming or hijacked
responses should run with TransactionalOff.
The fiberuow package provides:
fiberuow.Middleware(manager, cfg)for app/group middlewarefiberuow.Wrap(manager, cfg, handler)for route-specific wrapping- tenant and binding override resolution from
*fiber.Ctx - optional rollback by status code and/or returned handler error
Group middleware:
api := app.Group("/api", fiberuow.Middleware(manager, fiberuow.Config{
Execution: uow.ExecutionConfig{Transactional: uow.TransactionalOn},
ResolveTenant: func(c *fiber.Ctx) (string, error) {
return c.Get("X-Tenant-ID"), nil
},
}))Per-route override:
app.Get("/reports/:id", fiberuow.Wrap(manager, fiberuow.Config{
Execution: uow.ExecutionConfig{
Transactional: uow.TransactionalOn,
Label: "report-detail",
},
RollbackOnStatus: fiberuow.RollbackOn5xx,
}, func(c *fiber.Ctx) error {
work := uow.MustFrom(c.UserContext())
_ = work.CurrentHandle()
return c.SendStatus(fiber.StatusOK)
}))Fiber middleware uses c.UserContext() / c.SetUserContext(...) to bridge the
transport lifecycle into the core context.Context propagation model.
The sqladapter package expects a registered *sql.DB client and exposes:
sqladapter.New(name)for adapter constructionsqladapter.Current(uow)to obtain adatabase/sqlquery handlesqladapter.MustCurrent(uow)when repository code prefers panic-on-miswiresqladapter.CurrentTx(uow)for transaction-specific paths
The adapter supports:
- root transactions
ReadOnlybegin options- standard
database/sqlisolation levels
The adapter intentionally does not advertise:
- nested/savepoint transactions
- backend transaction timeout semantics
That keeps the capability contract aligned with what database/sql can
guarantee portably.
The gormadapter package expects a registered *gorm.DB client and exposes:
gormadapter.New(name, options...)for adapter constructiongormadapter.Current(uow)to obtain the current*gorm.DBgormadapter.MustCurrent(uow)for panic-on-miswire repository codegormadapter.CurrentTx(uow)for transaction-only paths
By default the GORM adapter is conservative:
- root transactions are supported
ReadOnlyand isolation preferences are passed throughgorm.DB.Begin- nested transactions are reported as unsupported
When the backing dialect supports savepoints reliably, nested strict mode can be enabled explicitly:
adapter := gormadapter.New("gorm", gormadapter.WithNestedSavepoints(true))This keeps the default capability contract stable across databases while still allowing savepoint-backed nesting for deployments that have validated it.
The package returns wrapped errors that work with errors.Is and
errors.As.
Typical checks:
if errors.Is(err, uow.ErrRollbackOnly) { ... }
if errors.Is(err, uow.ErrNestedTxUnsupported) { ... }
var uerr *uow.UOWError
if errors.As(err, &uerr) && uerr.Kind == uow.ErrKindResolver { ... }Registrysupports concurrent reads and serialized writes.UnitOfWorkstate transitions are internally synchronized.- Nested scopes are lexical and must be finalized in LIFO order.
TxScopevalues are not designed for long-lived asynchronous use.
gofmt -w *.go
go test ./...
golangci-lint runGitHub Actions CI runs go test ./... on pushes and pull requests. A
repository-local .golangci.yml is included and an optional
manual lint workflow is available without making linting a required publish
gate yet.
- The public API is intentionally small and concrete.
- The module avoids framework and ORM dependencies in the core package.
- No distributed transaction support is provided.
- two-phase commit / distributed transactions
- cross-database atomicity
- framework-specific middleware in the core package
- automatic adapter implementations for every ORM