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/
49 lines
2.5 KiB
Markdown
49 lines
2.5 KiB
Markdown
# 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{}`:
|
|
|
|
```go
|
|
type ctxTxKey struct{}
|
|
```
|
|
|
|
`UnitOfWork.Do` begins a transaction, injects it into the context, and calls the user-supplied function with the enriched context:
|
|
|
|
```go
|
|
ctx = context.WithValue(ctx, ctxTxKey{}, tx)
|
|
fn(ctx)
|
|
```
|
|
|
|
`Client.GetExecutor(ctx)` checks the context for an active transaction first:
|
|
|
|
```go
|
|
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.Context` and `db postgres.Client`; they do not need a separate `Tx` parameter.
|
|
- **Positive**: Nesting `UnitOfWork.Do` calls 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.Do` call would use a closed transaction. Callers must not spawn goroutines from inside the `Do` function that outlive `Do`.
|