Help us shape the future of Go! Take the 2026 Go Developer Survey

database

package
v0.0.0-...-67c9fd2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 13, 2026 License: MIT Imports: 16 Imported by: 0

README

Database package documentation

The database package provides everything needed to talk to Postgres: connection pooling, transaction handling, repositories, and an embedded set of SQL migrations.

It is the only layer that knows about the driver. Everything above it works with domain types and errorx error types instead of pgx sentinels.


Components

Database service (database.go)

Manages the connection pool via pgxpool.

db, err := database.New(cfg.Database)
if err != nil {
    return err
}
defer db.Close()
  • Each call returns an independent Service. There is no package-level singleton, so tests can run against isolated pools and the caller owns the lifetime.
  • Failures are returned, not panicked, so the server reports a clear startup error.
  • Connection is bounded by a 10s timeout, so an unreachable database fails fast instead of hanging the process.
  • Options adjust the pool before it opens. WithQueryTracer is how tracing is attached, without any repository knowing about it.
  • Close must be called. cmd/serve closes it after the HTTP server has drained, and does so even when the shutdown itself failed, so a shutdown error cannot leak connections.

The Service interface exposes:

Method Purpose
Health(ctx) Pool statistics and reachability, used by the readiness probe
Pool() The *pgxpool.Pool, for queries
DB() A *sql.DB view of the same pool, for goose
Close() Releases the pool

Health reports connection counts, acquire timings and destroy counters, and annotates the result when the pool looks saturated. That detail is logged for operators — the readiness endpoint returns only up.

Transactions (tx.go)

TxManager abstracts begin/commit/rollback:

Method Purpose
Begin(ctx) Starts a transaction
Execute(ctx, fn) Runs fn in a transaction, committing when it returns nil and rolling back otherwise

Two details are load-bearing:

  • The result is named. The deferred function decides between commit and rollback; an unnamed result would be copied out before that runs, hiding a failed COMMIT from the caller.
  • A rollback error never replaces the original. The business failure is what the caller needs; a rollback error is joined onto it as additional context via errors.Join.

Panics are rolled back and re-thrown, preserving the original stack. A rollback of an already-closed transaction is treated as benign.

type ProfileRepository struct {
    database.TxManager
    database.Service
}

func (r ProfileRepository) UpdateWithTx(ctx context.Context, p *profile.Profile) (*profile.Profile, error) {
    // add more db operations here, e.g. a SELECT ... FOR UPDATE lock by id

    err := r.Execute(ctx, func(tx pgx.Tx) error {
        return tx.QueryRow(ctx, profileUpdateSql,
            p.FirstName, // $1
            p.LastName,  // $2
            p.Id,        // $3
        ).Scan(&p.Id, &p.CreatedAt, &p.UpdatedAt)
    })

    return p, err
}
Repositories (repositories/)

Encapsulate data access, keeping it out of the business logic. Each repository takes a database.Service and gets a TxManager for free.

SQL lives in repositories/sql/*.sql and is pulled in with //go:embed, so statements stay readable and syntax-highlighted rather than buried in string literals. All queries are parameterized.

var (
    //go:embed sql/profile_get_by_id.sql
    profileGetByIdSql string
)

Repositories satisfy the generic internal.Repository[T, ID] interface, so the service layer depends on the shape, not the implementation.

Pool tuning (pool.go)

Sizing and timeouts come from configuration rather than from pgx's defaults.

Left unset, pgx derives MaxConns from the host CPU count — so the same image quietly gets a different ceiling on every machine size, while the database's connection limit is a shared budget that no single service should size by accident. Stating the number matters more than the number itself.

statement_timeout is applied by Postgres, not by the client, which is what makes it effective: cancelling a context abandons the client's wait, while the server keeps executing and the connection stays busy. It applies to migrations too, since they share this configuration.

Every value is optional and zero means "not configured" rather than zero — pgx rejects a pool of zero connections, and an unset duration should keep pgx's own default rather than disable the behaviour outright.

Query tracing (tracing.go)

WithQueryTracer installs a pgx.QueryTracer on the pool, so every query gets a client span:

db, err := database.New(cfg.Database, database.WithQueryTracer(otel.GetTracerProvider()))

Instrumenting at the driver is what keeps this invisible to the layers above. No repository is annotated, no call site changes, and queries issued inside a transaction are covered too — by construction rather than by remembering.

Two decisions are load-bearing:

  • Arguments are never recorded. The statement text goes on the span, and because statements are parameterised it carries no values. data.Args holds the real ones — names, emails, tokens — and spans leave the process for a third-party backend. There is a test asserting no argument reaches an attribute.
  • pgx.ErrNoRows is not an error. It is how a lookup reports "not found", which is an ordinary outcome. Marking those spans failed would bury the real failures.
Error classification (repositories/errors.go)

The repository is the only layer that can tell a missing row apart from a genuine failure, so it is the layer that tags errors:

Driver condition Domain type
pgx.ErrNoRows errorx.ErrNotFound
context.DeadlineExceeded, context.Canceled errorx.ErrUnavailable
Anything else errorx.ErrInternal
DELETE/UPDATE matching zero rows errorx.ErrNotFound

Two of these matter more than they look. A DELETE that matches nothing is not a success, even though the driver reports no error. And a request that ran out of time is not a server fault — reporting 500 would tell a caller the service is broken when it was merely too slow, and would put a genuine fault and an ordinary timeout in the same bucket on every dashboard.


Migrations (migrations/)

Schema changes are managed with goose and embedded into the binary with //go:embed migrations/*.sql, so a deployed image carries its own schema history.

Create a new migration:

goose -dir database/migrations create xxx sql

Apply and roll back:

make migrate-up    # applies every pending migration
make migrate-down  # rolls back the most recent migration only

Notes:

  • migrate up runs with goose.WithAllowMissing(). Test seed data shares goose's version ledger with the schema migrations, so a schema migration newer than a seed would otherwise make the seed look out of order.
  • migrate down reverts a single migration. To unwind everything, call goose.DownTo(db, dir, 0) — that is what the round-trip test does.
Requires PostgreSQL 18+

20260825120000_profile_id_uuidv7.sql switches the profiles primary key default from gen_random_uuid() to uuidv7(), which was added in Postgres 18.

v4 is random, so inserts land at random points in the primary key's B-tree: every insert dirties a different page and the index fragments as the table grows. v7 embeds a millisecond timestamp in its high bits, so new rows append to the right-hand edge of the index instead. Existing rows keep their v4 identifiers; both versions are valid uuid values and mix freely in one column.


Testing

Repository tests live in repositories/tests/ and run against a real Postgres 18 container started by pkg/testinfra, so behaviour is verified against the actual database rather than a mock.

database/migrations_test.go covers the migrations themselves:

  • a full up → down → up round trip, which is what proves the up and down steps have not drifted apart;
  • an assertion that generated ids really are UUIDv7;
  • a static check that no down step drops a table no up step creates.

Docker must be running. Use go test -short ./... to skip the container-backed suites.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func GetDialect

func GetDialect() string

func GetMigrationFS

func GetMigrationFS() embed.FS

Types

type Option

type Option func(*pgxpool.Config)

Option adjusts the pool configuration before the pool is opened.

func WithQueryTracer

func WithQueryTracer(tp trace.TracerProvider) Option

WithQueryTracer records a span for every query the pool executes.

Instrumenting at the driver means no repository has to know about tracing: every query, including those inside a transaction, is covered by construction rather than by remembering to annotate each call site.

type Service

type Service interface {
	// Health returns a map of health status information.
	// The keys and values in the map are service-specific.
	Health(ctx context.Context) map[string]string

	// Close terminates the database connection.
	// It returns an error if the connection cannot be closed.
	Close() error

	// DB returns the database connection.
	DB() *sql.DB

	// Pool returns the pgx connection pool.
	Pool() *pgxpool.Pool
}

Service represents a service that interacts with a database.

func New

func New(cfg config.DatabaseConfig, opts ...Option) (Service, error)

New opens a connection pool and verifies it is reachable.

Each call returns an independent Service; the caller owns its lifetime and must Close it. Failures are returned rather than panicked, so the server can report a clear startup error.

Options adjust the pool before it opens; WithQueryTracer is how tracing is attached without any repository knowing about it.

type TxManager

type TxManager interface {
	Begin(context.Context) (pgx.Tx, error)
	Execute(context.Context, func(pgx.Tx) error) error
}

TxManager defines a method to execute a transaction from Begin until Commit or Rollback.

func NewTxManager

func NewTxManager(db Service) TxManager

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL