Skip to content
Zever
Home

Providers and adapters

How small interfaces, self-registering adapters, and the container make backend swaps a config change.

3 min readEdit on GitHub

Every backend concern is one small interface with several adapters, resolved only by the container. Changing sqlite to postgres or memory to redis stays a config edit, never a handler rewrite.

Role Rule
Interface Stays small. Never add methods (it breaks implementers); add a new interface instead.
Adapter Self-registers; one subdirectory per backend.
Container Sole resolver; owns construction, caching, retry, and close.
Config Selects the adapter (DB__PRIMARY__ADAPTER=postgres) plus options.

Application code speaks the interface; the container speaks the registry:

conn, err := db.Open(db.SQLite, db.Options{Path: ":memory:"})

Each package follows one pattern:

// Package db: one small interface.
type DB interface {
Query(ctx context.Context, query string, args ...any) (Rows, error)
Exec(ctx context.Context, query string, args ...any) (int64, error)
Ping(ctx context.Context) error
Close(ctx context.Context) error
Dialect() string
}
// Adapter sqlite registers itself; postgres registers itself elsewhere.
// Call Register once from main (no init magic); app code holds no globals.
func Register() { _ = db.Register(db.SQLite, New) }

Development uses zero infrastructure; production changes names only:

Terminal window
DB__PRIMARY__ADAPTER=sqlite
DB__PRIMARY__PATH=data/app.db
CACHE__DEFAULT__ADAPTER=memory
QUEUE__DEFAULT__ADAPTER=memory
Terminal window
DB__PRIMARY__ADAPTER=postgres
DB__PRIMARY__DSN=postgres://app:secret@db:5432/app
DB__PRIMARY__MAX_CONNS=20
CACHE__DEFAULT__ADAPTER=redis
CACHE__DEFAULT__ADDR=redis:6379

Env equivalents use <SERVICE>__<NAME>__<FIELD> with no prefix: DB__PRIMARY__ADAPTER=postgres, DB__PRIMARY__MAXCONNS=20, CACHE__DEFAULT__ADAPTER=redis. Handlers keep calling c.DB() and c.Cache(); register the adapter module for the name you choose. zever doctor reports per-service health. Under zero-infra defaults, the bookings example documents expected FAILs (missing API key, JWT secret, cities file, embed FS): config gaps, not framework bugs.

Most swaps are invisible to handlers: placeholders stay ? on both db drivers (postgres rewrites to $n before pgx) and route params read through router.Param. Where backends differ, the adapter fails fast or documents the gap. Two known gaps:

  • MinConns is a pool minimum on postgres but SetMaxIdleConns on sqlite.
  • Regex route constraints exist on fiber but not stdhttp.

An excerpt follows. The full table covers all 37 services from ai to workflow, each with a default adapter (zero-infra except ai): see the adapters matrix.

Service Adapters Default
db sqlite, postgres sqlite
cache memory, redis, db memory
queue memory, redis, db memory
router fiber, stdhttp stdhttp
auth jwt, session, oidc jwt
session memory, redis, db memory
storage local, s3, r2 local
search db, postgres, meilisearch, sqlite db
vectorstore db, sqlite, pgvector, qdrant db
payment / billing stub, stripe, paddle stub
log noop, zerolog, slog, pretty slog
secrets env, vault env with ZEVER prefix
  1. Implement the existing interface exactly: no extra exported methods on the facade.
  2. Parse options with typed readers and return InvalidOptionsError on unparsable values.
  3. Never log raw option maps; expose redaction through Redact / RedactedServices.
  4. Make shutdown cheap and idempotent: implement Close(ctx), Close(), or Stop() so the container’s probing finds it.
  5. Keep tests hermetic (sqlite :memory:, in-memory fakes) and gate live tests behind env such as POSTGRES_DSN.

Unexported adapter structs are named driver with receiver d; timeouts are time.Duration with Default*Timeout constants.

  • Close shape is a provider contract. See Container for which signature each adapter takes.
  • Fail closed. Unsupported ORM features return dialect.ErrUnsupportedByDialect (prefixed orm:) instead of wrong SQL, strict config rejects unknown keys at load, and redaction masks secrets everywhere.
The swap changes nothing at runtime

Cause: an env override (CACHE__DEFAULT__ADAPTER) beats the file. Fix: unset or correct the env variable.

An accessor fails with UnknownAdapterError

Cause: a typo in the adapter name (config accepts any non-empty name), or its Register() was never called. Fix: match the matrix spelling (postgres, not postgresql) and register the adapter module before resolving.

A handler sees different SQL on postgres

Cause: a misunderstanding of the placeholder rewrite. Fix: keep writing ?; the postgres driver rewrites to $n before pgx.

A fiber-only route constraint breaks stdhttp

Cause: regex constraints exist on fiber only. Fix: restructure the route using constructs both drivers share, or pin fiber.

A live adapter test fails in CI

Cause: the test needs a real backend but no DSN is set. Fix: gate it behind env (POSTGRES_DSN='postgres://user:pass@localhost:5432/test?sslmode=disable').