pgx v5-native PostgreSQL client with launcher lifecycle, health check, unit-of-work via context injection, and structured error mapping. What's included: - Executor / Tx / Client / Component interfaces using pgx native types (pgconn.CommandTag, pgx.Rows, pgx.Row) - New(logger, cfg) constructor; pgxpool initialised in OnInit - Config struct with env-tag support for all pool tuning parameters - UnitOfWork via context injection; GetExecutor(ctx) returns active Tx or pool - HandleError mapping pgerrcode constants to xerrors codes (AlreadyExists, InvalidInput, NotFound, Internal) - health.Checkable at LevelCritical; HealthCheck delegates to pgxpool.Ping Tested-via: todo-api POC integration Reviewed-against: docs/adr/
2.5 KiB
ADR-003: Unit of Work via Context Injection
Status: Accepted Date: 2026-03-18
Context
Database transactions must span multiple repository calls without requiring each repository method to accept a Tx parameter explicitly. Passing Tx as a parameter would leak transaction concepts into repository method signatures and force every call site to decide whether it is inside a transaction.
An alternative is ambient transaction state stored in a thread-local or goroutine-local variable, but Go has no such construct, and package-level state would break concurrent use.
Decision
The active transaction is stored in the request context.Context under an unexported key type ctxTxKey{}:
type ctxTxKey struct{}
UnitOfWork.Do begins a transaction, injects it into the context, and calls the user-supplied function with the enriched context:
ctx = context.WithValue(ctx, ctxTxKey{}, tx)
fn(ctx)
Client.GetExecutor(ctx) checks the context for an active transaction first:
if tx, ok := ctx.Value(ctxTxKey{}).(Executor); ok {
return tx
}
// fall back to pool
If there is no active transaction, GetExecutor returns the pool. This means repository code uses db.GetExecutor(ctx) uniformly and is agnostic about whether it is inside a transaction.
Tx.Commit(ctx) and Tx.Rollback(ctx) both accept ctx — this is supported by pgx.Tx and matches the overall pgx API convention.
On function error, UnitOfWork.Do calls Rollback and returns the original error. Rollback failures are logged but do not replace the original error.
Consequences
- Positive: Repository methods need only
ctx context.Contextanddb postgres.Client; they do not need a separateTxparameter. - Positive: Nesting
UnitOfWork.Docalls is safe — the inner call will pick up the already-injected transaction from the context, so a single transaction spans all nested calls. (pgx savepoints are not used; the outer transaction is reused.) - Positive: The unexported
ctxTxKey{}type prevents collisions with other packages that store values in the context. - Negative: The transaction is invisible from a type-system perspective — there is no way to statically verify that a function is called inside a
UnitOfWork.Do. Violations are runtime errors, not compile-time errors. - Negative: Passing a context that carries a transaction to a goroutine that outlives the
UnitOfWork.Docall would use a closed transaction. Callers must not spawn goroutines from inside theDofunction that outliveDo.