apistockdocs
v0.4 GitHub apistock.dev
Technical/Subsystems

Runtime settings

apistock.dev/modules/settings stores non-secret tunables in PostgreSQL so operators change them without a redeploy. Decision: ADR-0031. Admin endpoints: ops API reference.

Environment or runtime setting?#

Put it in When Examples
Environment (internal/app/config.go, .env.example) It is a secret, infrastructure, or needed before the database connects DATABASE_URL, API keys such as RESEND_API_KEY, listen address, pool size
Runtime setting (internal/app/settings.go) An operator should change it without a deploy Code expiry, rate limits, sender name, frontend URL, maintenance mode

A value is never in both, and secrets are never runtime settings.

Declaring settings#

Declare every setting before building the store, usually in internal/app/settings.go:

go
reg := settings.NewRegistry()

codeTTL := settings.Duration(reg, "auth.verification_code_ttl", 15*time.Minute,
	settings.Describe("How long email verification codes stay valid."),
	settings.Range(5*time.Minute, time.Hour),
	settings.ReasonRequired(),
)
origins := settings.StringList(reg, "http.cors_origins", nil, settings.URL(), settings.MaxItems(20))
delivery := settings.Enum(reg, "mail.delivery", "async", []string{"sync", "async"})

Keys are dotted lowercase, namespaced by feature (module.name). Keys are public API: never rename one.

Declaration Go type Stored and edited as
Bool bool true
Int int 42
Float float64 0.5
String string "text"
Enum string "async", one of the allowed values
Duration time.Duration "15m", "1h30m"
StringList []string ["a", "b"]
Option Applies to Effect
Describe(text) all Help text shown to operators
Group(name) all Listing group (default: the key's first segment)
ReasonRequired() all Every change needs a reason
RestartRequired() all Get keeps the startup value; changes apply after restart
Range(lo, hi) Int, Float, Duration Bounds; types must match (Range(0.0, 1.0) for Float)
OneOf(values...) String, StringList items Allowed values
MaxLen(n) String, StringList items Maximum characters
MaxItems(n) StringList Maximum items
URL() String, StringList items Absolute http(s) URL
Email() String, StringList items Bare email address
Validate(func(T) error) all Custom check; messages must not include the value

An invalid declaration (bad key, duplicate, mismatched option, default outside its bounds) panics at startup.

Wiring the store#

go
store, err := settings.NewStore(ctx, pool, reg, recorder, settings.WithLogger(logger))
if err != nil {
	return err
}
// Run it with the app's runners so changes from other instances arrive.
runners := []app.Runner{server, store}
  • NewStore loads every stored value and freezes the registry.
  • The tables come from settings.Migrations, copied into db/migrations.
  • Store.Run listens with PostgreSQL LISTEN/NOTIFY on a dedicated connection, reloads after every reconnect, and reloads everything every 5 minutes (WithResyncInterval).

Reading settings#

Every handle implements config.Value[T]:

go
ttl := codeTTL.Get(ctx) // in-memory read, never a database call

Pass handles to use cases and library modules as config.Value[T], and call Get each time the value is needed. For a fixed value (tests, spec export), use config.Static(v).

Get returns the default when the setting was never changed, was reset, has a stored value that fails current validation, or before the store has loaded.

Changing settings#

Operators use PUT /ops/settings/{key}. In Go:

go
view, err := store.Set(ctx, "auth.verification_code_ttl", json.RawMessage(`"30m"`),
	settings.Change{Version: view.Version, Reason: "support backlog"})
Behaviour Detail
Actor The context must carry an authenticated actor (ErrActorRequired)
Versions Change.Version must equal the current version (ErrVersionConflict); a never-changed setting is version 0
Validation *InvalidValueError with a reason that never includes the value
No-op Setting the current value again, or resetting a default, changes nothing
History Old value, new value, reason, actor and request ID per change (Store.History)
Audit One settings.value.changed event per change, with version, reset and reason metadata
Propagation This instance immediately; others through NOTIFY within moments
Reset Store.Reset stores NULL, so versions never repeat

Views for admin panels#

Store.List() and Store.Get(key) return a View with the effective and default values, Modified, InvalidStoredValue, Version, UpdatedAt, UpdatedBy, ReasonRequired, RestartRequired, RestartPending and Constraints (min, max, one_of, max_len, max_items, format). Store.UnknownKeys() lists stored keys no longer declared.

Storage#

Table Holds
settings_values One row per changed setting: key, value (jsonb, NULL = default), version, updated_at, updated_by, org_id (reserved)
settings_history Every change with old and new values, reason, actor and request ID

Limitations#

  • No per-organisation settings yet (the org_id column is reserved).
  • No feature flags or percentage rollouts (v1.1).
  • A setting cannot be locked from the environment.
esc
↑↓ move↵ openesc close