di

package
v1.8.2 Latest Latest
Warning

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

Go to latest
Published: Jul 19, 2026 License: MIT Imports: 18 Imported by: 0

README

di

Configuration loader for services built on core. Loads from yaml files, env vars, and etcd v3 (static + dynamic with native push-Watch). Generic typed API. Lock-free reads via atomic snapshot.

Quick start

cfg, err := di.NewConfig(ctx)
if err != nil {
    log.Fatal(err)
}

port := di.Get[string](cfg, "http.public.port")
timeout := di.GetOrDefault[time.Duration](cfg, "http.timeout", 30*time.Second)
currentRPS := di.Live[int](cfg, "limits.rps") // re-read on each call() for runtime tunables

Sources and priority

env vars  >  etcd dynamic  >  etcd static  >  yaml file  >  defaults

Any key can be overridden via env. If env is absent, the highest-priority source wins: dynamic > static > file > default.

Layer Who manages When changes
env operator (deploy) process restart
etcd dynamic operator (live) runtime — pushed via etcd Watch
etcd static operator (deploy) process restart
yaml file repo / image image rebuild
defaults core / app code code change

Bootstrap configuration

The following keys are read from env only (they decide where other configuration comes from):

  • APP_ENV (default: dev) — environment label, used in the etcd path prefix.
  • APP_SERVICE_NAME — required when etcd is configured. Used in the etcd path prefix.
  • APP_CONFIG_FILE_PATHS — comma-separated list of yaml file paths or directories (directories load config.yaml).
  • APP_CONFIG_FILE_PATH — legacy single path.
  • APP_CONFIG_ETCD_ENDPOINT — etcd address (required when any etcd path is set).
  • APP_CONFIG_ETCD_STATIC_PATHS — comma-separated list of paths read once at startup.
  • APP_CONFIG_ETCD_DYNAMIC_PATHS — comma-separated list of paths watched for live updates.
  • APP_CONFIG_ETCD_PATH — legacy single dynamic path.
  • APP_CONFIG_ETCD_REQUEST_TIMEOUT — Go-duration string ("5s", "500ms", "1m") bounding every etcd Get during static load and watcher resync. Default 5s. A malformed value fails startup with ErrInvalidEtcdRequestTimeout.

Etcd full key = {APP_ENV}/{APP_SERVICE_NAME}/{path}.

Public API

func Get[T ValueType](cfg *Config, key string) T
func GetOrDefault[T ValueType](cfg *Config, key string, def T) T
func Live[T ValueType](cfg *Config, key string) func() T
func LiveOrDefault[T ValueType](cfg *Config, key string, def T) func() T
  • Get — read once.
  • GetOrDefault — read once with a fallback.
  • Live — closure that re-reads on every call. Use for hot-reloadable values (rate limits, feature flags) that may change at runtime via etcd dynamic.
  • LiveOrDefaultLive with a fallback.

ValueType constraint: string | int | int64 | bool | float64 | time.Duration | []string.

Methods on *Config: GetAppEnv(), GetServiceName() — captured from env at startup, immutable for the process lifetime.

Defaults semantics

GetOrDefault returns the default only when the key is absent or the stored value can't be coerced to T. It does NOT return the default when the value is zero (0, "", empty slice). If you want zero-as-absent semantics, wrap manually:

v := di.Get[int](cfg, "k")
if v <= 0 {
    v = fallback
}

Lifecycle

NewConfig(ctx) is fail-fast: errors when etcd is configured but unreachable, when paths can't be parsed, or when APP_SERVICE_NAME is empty with etcd configured. After successful start, etcd watch errors are logged but do not propagate — operators can fix etcd without restarting the service.

The dynamic watcher runs until ctx is cancelled, then closes the etcd client. Pass a long-lived ctx (typically the app's main context).

Container helper

Container[C, S] and NewContainer provide a two-step bootstrap (init config → init services). ServicesInit may return a Rollback invoked only when initialization fails — it releases partially-constructed resources. Runtime teardown of healthy resources is the job of lifecycle.Resource registered with app.App; on success the Rollback is discarded. See container.go godoc for details.

Migration from the typed-method API

Was Now
cfg.GetString("k") di.Get[string](cfg, "k")
cfg.GetStringOrDefault("k", "x") di.GetOrDefault[string](cfg, "k", "x")
cfg.GetInt("k") di.Get[int](cfg, "k")
cfg.GetBool("k") di.Get[bool](cfg, "k")
cfg.GetDuration("k") di.Get[time.Duration](cfg, "k")
cfg.GetStringSlice("k") di.Get[[]string](cfg, "k")
cfg.WatchString("k") di.Live[string](cfg, "k")
cfg.WatchInt("k") di.Live[int](cfg, "k")
cfg.WatchBool("k") di.Live[bool](cfg, "k")
cfg.WatchDuration("k") di.Live[time.Duration](cfg, "k")
cfg.WatchStringSlice("k") di.Live[[]string](cfg, "k")
cfg.GetStorage().GetString("k") di.Get[string](cfg, "k") (no direct storage access anymore)

For consumers that embed the config (type AppConfig struct { *di.Config; ... }), use di.Get[T](appCfg.Config, "k") — explicit access to the embedded pointer.

Documentation

Overview

Package di provides a generic, lock-free configuration loader and a thin Container wrapper for the two-step bootstrap pattern used by services built on core.

Configuration sources are loaded in this order, from lowest to highest priority:

defaults  <  yaml file  <  etcd static  <  etcd dynamic  <  env vars

env vars always win over every other source. Dotted keys map to underscore env vars, so http.frontend.port reads from HTTP_FRONTEND_PORT.

Reading values is done via the package-level generic functions Get[T], GetOrDefault[T], Live[T] and LiveOrDefault[T]; the *Config struct exposes only the immutable bootstrap fields (GetAppEnv, GetServiceName) captured at NewConfig time.

The dynamic etcd watcher composes the snapshot per-path (each path's last parsed map is stored separately and merged on every update), fixing the silent stale-key bug inherited from viper-remote: a PUT that drops a key now correctly removes it from the snapshot.

Etcd access uses the native etcd v3 client (go.etcd.io/etcd/client/v3) behind the small etcdKV interface — no viper, no viper-remote. fakeEtcdKV drives dynamic-update paths in unit tests without a real etcd cluster.

Path ownership: the caller passes full etcd keys via the app.config.etcd.{static,dynamic}.paths env vars. core/di does not prepend the service name or app env to the path. This keeps the loader storage-agnostic — vanilla etcd accepts arbitrary keys, while Elara and other layered stores require their own prefix conventions (e.g. "/{namespace}/...") which the deployment system is responsible for templating into the env var.

Tunables (env vars):

  • APP_CONFIG_ETCD_REQUEST_TIMEOUT: Go-duration string ("5s", "500ms") bounding every etcd Get call. Defaults to 5s. Invalid value fails startup with ErrInvalidEtcdRequestTimeout.

Sentinel errors are split across files for locality:

  • This file: ErrEmptyConfigPaths, ErrEtcdEndpointRequired, ErrEmptyEtcdStaticPaths, ErrEmptyEtcdDynamicPaths, ErrEtcdKeyNotFound, ErrServiceNameRequired.
  • etcdclient.go: ErrInvalidEtcdRequestTimeout.
  • loader_file.go: ErrNotRegularFile, ErrConfigFileTooLarge.
  • loader_etcd.go: ErrUnknownConfigType.

Index

Constants

View Source
const AppEnvDev = "dev"

AppEnvDev is the default value for app.env when no environment is set via env or yaml.

Variables

View Source
var (
	ErrEmptyConfigPaths      = errors.New("config paths is set but empty after parsing")
	ErrEtcdEndpointRequired  = errors.New("etcd endpoint is required when using etcd")
	ErrEmptyEtcdStaticPaths  = errors.New("etcd static paths is set but empty after parsing")
	ErrEmptyEtcdDynamicPaths = errors.New("etcd dynamic paths is set but empty after parsing")
	ErrEtcdKeyNotFound       = errors.New("etcd key not found")
	ErrServiceNameRequired   = errors.New("service name is required when etcd paths are configured")
)

Sentinel errors returned by NewConfig at startup.

View Source
var (
	ErrNotRegularFile     = errors.New("config path is not a regular file")
	ErrConfigFileTooLarge = errors.New("config file exceeds size limit")
)

Sentinel errors exposed by the file loader.

View Source
var ErrInvalidEtcdRequestTimeout = errors.New("invalid etcd request timeout")

ErrInvalidEtcdRequestTimeout is returned by NewConfig at startup when the value of APP_CONFIG_ETCD_REQUEST_TIMEOUT does not parse as a Go duration (e.g. "5s", "500ms"). Fail-fast — startup is the right place to surface a malformed env var.

View Source
var ErrUnknownConfigType = errors.New("unknown config type")

ErrUnknownConfigType is returned by parseConfigBytes when the configType argument is not one of the supported formats (yaml, yml, json).

Functions

func Get added in v1.5.0

func Get[T ValueType](cfg *Config, key string) T

Get returns the value for key as T. If key is absent or the value cannot be coerced to T, the zero value of T is returned. Use Get for one-shot reads at startup; values pulled from etcd dynamic updates after startup will not be reflected without re-reading.

func GetOrDefault added in v1.5.0

func GetOrDefault[T ValueType](cfg *Config, key string, def T) T

GetOrDefault returns the value for key as T, or def if the key is absent OR the value cannot be coerced to T.

func Live added in v1.5.0

func Live[T ValueType](cfg *Config, key string) func() T

Live returns a closure that reads the current value of key on every invocation. Use Live for hot-reloadable values (rate limits, feature flags) that may change at runtime via etcd dynamic.

Note: env vars override etcd dynamic. A key bound in env is locked for the process lifetime and Live() will keep returning the env value even if etcd PUTs a different value.

func LiveOrDefault added in v1.5.0

func LiveOrDefault[T ValueType](cfg *Config, key string, def T) func() T

LiveOrDefault is Live with a fallback when the key is absent or the value can't be coerced.

Types

type Config

type Config struct {
	// contains filtered or unexported fields
}

Config holds the resolved configuration store plus the immutable bootstrap fields (app env, service name) captured at NewConfig time.

Reads happen through the package-level generic functions di.Get[T] / di.GetOrDefault[T] / di.Live[T] / di.LiveOrDefault[T]. Reads are lock-free; the underlying snapshot is replaced atomically by the dynamic-watch goroutine when an etcd PUT arrives.

func NewConfig

func NewConfig(ctx context.Context) (*Config, error)

NewConfig is the production constructor. It loads the configuration from yaml files, etcd static, and etcd dynamic in priority order, publishes the initial snapshot, and starts the dynamic-update watcher in a background goroutine bound to ctx.

Behaviour:

  • app.env defaults to AppEnvDev when not set in env.
  • app.service.name is required if any etcd paths are configured.
  • app.config.etcd.endpoint is required if any etcd paths are configured.
  • The static-load etcd client is opened and closed within the load; the dynamic watcher owns its own client and closes it on ctx cancellation.

func (*Config) GetAppEnv

func (c *Config) GetAppEnv() string

GetAppEnv returns the application environment captured at NewConfig time. The value is immutable for the process lifetime. Surfaces the service's bootstrap identity (logs, metrics, traces); it is no longer used to build etcd paths — see the package doc on path ownership.

func (*Config) GetServiceName

func (c *Config) GetServiceName() string

GetServiceName returns the service name captured at NewConfig time. Immutable for the same reasons as GetAppEnv.

type Container

type Container[C, S any] struct {
	Config   C
	Services S
}

Container bundles a typed Config and a typed Services graph produced by the application. It is a thin convenience wrapper over the two-step bootstrap pattern: initConfig, then initServices.

Container does NOT track teardown for the happy path. Every component that holds resources must implement lifecycle.Resource (see github.com/sergeyslonimsky/core/lifecycle) and be registered with app.App — the container is only responsible for construction, not runtime teardown.

Typical usage:

container, err := di.NewContainer(ctx,
    myapp.NewConfig,
    myapp.NewServices,
)
if err != nil {
    log.Fatal(err)
}
cfg, svc := container.Config, container.Services

a := app.New()
svc.RegisterResources(a)       // add Resources (db, redis, ...)
a.Add(svc.HTTPServer)           // add Runners
log.Fatal(a.Run())

func NewContainer

func NewContainer[C, S any](
	ctx context.Context,
	initConfig func(context.Context) (C, error),
	initServices ServicesInit[C, S],
) (*Container[C, S], error)

NewContainer invokes initConfig first, then threads the resulting config into initServices. Returns the populated container or the first error encountered.

Failure semantics: if initServices returns an error alongside a non-nil Rollback, the Rollback is invoked before NewContainer returns. Rollback errors are joined with the init error via errors.Join for visibility.

Use ServicesInit for initServices when you construct resources (db, redis, otel providers) that need to be released on partial-failure startup paths.

type Rollback added in v1.5.0

type Rollback func(context.Context) error

Rollback releases resources constructed by a ServicesInit call that failed midway. It is NOT a shutdown hook — runtime teardown of healthy resources happens through lifecycle.Resource registered with app.App. NewContainer invokes Rollback only when ServicesInit returns a non-nil error and a non-nil Rollback alongside it; on success the Rollback is discarded.

type ServicesInit added in v1.3.0

type ServicesInit[C, S any] func(
	ctx context.Context,
	cfg C,
) (services S, rollback Rollback, err error)

ServicesInit is the signature expected by NewContainer. It builds the Services graph and MAY return a Rollback that releases resources constructed so far. If err is non-nil, NewContainer invokes the returned Rollback (if any) before propagating the error. On success the Rollback is discarded — runtime teardown is handled by app.App via lifecycle.Resource.

Return a nil Rollback when the initializer has nothing to release on partial construction.

type ValueType added in v1.5.0

type ValueType interface {
	string | int | int64 | bool | float64 | time.Duration | []string
}

ValueType is the closed set of types supported by the generic readers. Adding a new type means extending coerce[T] in lockstep.

Jump to

Keyboard shortcuts

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