ligo_config

package module
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: May 17, 2026 License: MIT Imports: 13 Imported by: 0

README

ligo-config

Layered configuration for Ligo, inspired by @nestjs/config. Loads .env files, process environment, and programmatic loaders into a single injectable *Service, with typed binding into structs and go-playground/validator support.

Go Version License Tests Coverage

Install

go get github.com/linkeunid/ligo-config

Quick start

package main

import (
    "github.com/linkeunid/ligo"
    "github.com/linkeunid/ligo/adapters/echo"
    ligo_config "github.com/linkeunid/ligo-config"
)

func main() {
    app := ligo.New(
        ligo.WithRouter(echo.NewAdapter()),
        ligo.WithAddr(":8080"),
    )

    app.Register(
        ligo_config.Module(
            ligo_config.WithEnvFiles(".env.local", ".env"),
            ligo_config.WithExpand(true),
        ),
        myModule(),
    )

    _ = app.Run()
}

func myModule() ligo.Module {
    return ligo.NewModule("my",
        ligo.Providers(
            ligo.Factory[*MyService](NewMyService),
        ),
    )
}

func NewMyService(cfg *ligo_config.Service) *MyService {
    return &MyService{
        host: cfg.GetOr("HOST", "localhost"),
        port: cfg.GetIntOr("PORT", 8080),
    }
}
Eager loading (Load / MustLoad + ModuleWith)

When you need configuration values BEFORE ligo.New — e.g. to resolve the bind address for ligo.WithAddr, which wires at construction time, earlier than Module's OnInit hook — load eagerly and reuse the loaded *Service via ModuleWith:

cfg := ligo_config.MustLoad(
    ligo_config.WithEnvFiles(".env.local", ".env"),
    ligo_config.WithExpand(true),
)
addr := ":" + cfg.GetOr("PORT", "8080")

app := ligo.New(ligo.WithAddr(addr) /* … */)
app.Register(
    ligo_config.ModuleWith(cfg),  // publish the same Service into DI
    myFeatureModule(),
)

ModuleWith(svc) publishes the already-loaded service into the DI container without re-running the loader pipeline — avoids the double-load and option-drift you'd get from calling Load and Module with parallel option lists.

Use plain Module when no boot-time read is needed (the typical case).

Source precedence

Sources merge during OnInit in this order, lowest precedence first:

  1. .env files (listed via WithEnvFiles; first file wins among files)
  2. Programmatic Loaders (registered via WithLoader)
  3. Process environment (os.Environ)

Flip step 2 above step 3 with WithLoadersWin(true) — useful in tests where you want a deterministic config map regardless of the host's env.

Missing .env files are not errors. This lets .env.local override .env without forcing both to exist.

API

Reading values
cfg.Get("KEY")                       // (string, bool) — empty counts as present
cfg.GetOr("KEY", "default")          // string
cfg.MustGet("KEY")                   // (string, error) — ErrKeyNotFound if absent
cfg.GetInt("KEY")                    // (int, error)
cfg.GetIntOr("KEY", 42)              // int
cfg.GetBool("KEY")                   // (bool, error) — 1/0/true/false/yes/no/on/off
cfg.GetBoolOr("KEY", false)          // bool
cfg.GetDuration("KEY")               // (time.Duration, error) — "5s", "300ms", "1h30m"
cfg.GetDurationOr("KEY", time.Second)
cfg.All()                            // map[string]string — defensive copy
Typed binding

Bind[T] decodes the merged config into a fresh *T using struct tags and validates the result with go-playground/validator.

type DBConfig struct {
    Host    string        `default:"localhost" env:"DB_HOST"    validate:"required"`
    Port    int           `default:"5432"      env:"DB_PORT"    validate:"gt=0"`
    URL     string        `env:"DB_URL"        validate:"omitempty,url"`
    Timeout time.Duration `default:"5s"        env:"DB_TIMEOUT"`
    Tags    []string      `env:"DB_TAGS"`
}

cfg, err := ligo_config.Bind[DBConfig](svc)

Supported field kinds: string, bool, all int* and uint* widths, float32/64, time.Duration, time.Time (RFC3339), and slices of any of those (split by ,).

Namespaced injection (registerAs)

Namespace[T] registers *T as a DI provider, calling Bind under the hood. Downstream factories receive the typed config directly:

func DatabaseModule() ligo.Module {
    return ligo.NewModule("database",
        ligo.Providers(
            ligo_config.Namespace[DBConfig](),
            ligo.Factory[*Repo](NewRepo),
        ),
    )
}

func NewRepo(cfg *DBConfig) *Repo { /* … */ }

For custom binding logic (cross-field derivation, runtime defaults), use NamespaceFn:

ligo_config.NamespaceFn[DBConfig](func(svc *ligo_config.Service) (*DBConfig, error) {
    return &DBConfig{
        URL: fmt.Sprintf("postgres://%s:%d/%s",
            svc.GetOr("DB_HOST", "localhost"),
            svc.GetIntOr("DB_PORT", 5432),
            svc.GetOr("DB_NAME", "app")),
    }, nil
})
Variable expansion

WithExpand(true) enables POSIX-style interpolation in values:

HOST=localhost
PORT=5432
DATABASE_URL=postgres://${HOST}:${PORT}/app
GREETING=hello, ${NAME:-world}
JWT_SECRET=${JWT_SECRET:?env JWT_SECRET required}
  • ${VAR} — substitute VAR; empty if unset
  • ${VAR:-default} — substitute VAR; default if unset or empty
  • ${VAR:?message} — substitute VAR; fail startup with message if unset

References resolve against the already-merged map, so later sources can expand values defined by earlier ones.

Custom loaders

WithLoader(fn) adds a programmatic source. Use it to pull config from Consul, Vault, S3, JSON files, anywhere not covered by .env:

ligo_config.WithLoader(func(ctx context.Context) (map[string]string, error) {
    return fetchFromVault(ctx, "secret/app")
})

Multiple loaders run in registration order; later loaders override earlier ones. By default, loaders override .env files but not process env — flip that with WithLoadersWin(true).

Validation hook

WithValidate(fn) runs after sources merge but before downstream providers initialize. Return a non-nil error to abort startup:

ligo_config.WithValidate(func(s *ligo_config.Service) error {
    if _, err := s.MustGet("DATABASE_URL"); err != nil {
        return err
    }
    if _, err := s.MustGet("JWT_SECRET"); err != nil {
        return err
    }
    return nil
})

See also

  • Ligo — the framework this plugs into
  • the-basic sample — minimal Ligo app you can clone and run

License

MIT — see LICENSE.

Documentation

Overview

Package ligo_config provides a Ligo-native configuration service inspired by @nestjs/config. It loads layered configuration from .env files, process env, and programmatic loaders, then exposes a *Service that other Ligo providers consume via dependency injection.

app.Register(
    ligo_config.Module(
        ligo_config.WithEnvFiles(".env.local", ".env"),
        ligo_config.WithExpand(true),
    ),
    myFeatureModule(),
)

func NewMyService(cfg *ligo_config.Service) *MyService {
    return &MyService{
        host: cfg.GetOr("HOST", "localhost"),
        port: cfg.MustGetInt("PORT"),
    }
}

Index

Constants

View Source
const ModuleName = "ligo_config"

ModuleName is the registered name of the config module within Ligo's module tree. Useful for module composition diagnostics.

Variables

View Source
var (
	// ErrKeyNotFound is returned by MustGet when a key is absent.
	ErrKeyNotFound = errors.New("config: key not found")

	// ErrInvalidValue is returned when a value cannot be coerced to the
	// requested type (e.g. GetInt on a non-numeric string).
	ErrInvalidValue = errors.New("config: invalid value")

	// ErrLoadFailed wraps any failure that occurs while a Loader or
	// .env file is being parsed during Module initialization.
	ErrLoadFailed = errors.New("config: load failed")

	// ErrValidation wraps validator/v10 errors raised after Bind.
	ErrValidation = errors.New("config: validation failed")

	// ErrUnresolvedVariable is returned when ${VAR:?msg} expansion fails
	// because VAR is missing and a fail-on-unset marker is present.
	ErrUnresolvedVariable = errors.New("config: unresolved variable")
)

Functions

func Bind

func Bind[T any](svc *Service) (*T, error)

Bind decodes the configuration into a fresh *T by reading struct field tags. Supported tags:

  • env:"KEY" — source key (required for the field to be set)
  • default:"value" — used if the env key is unset
  • validate:"..." — go-playground/validator rules, applied after decode

Supported field types: string, bool, int/int8/int16/int32/int64, uint variants, float32, float64, time.Duration, time.Time (RFC3339), and slices of any of those (split by `,`).

type DBConfig struct {
    Host string `env:"DB_HOST" default:"localhost" validate:"required"`
    Port int    `env:"DB_PORT" default:"5432"`
    URL  string `env:"DB_URL"  validate:"required,url"`
}

cfg, err := ligo_config.Bind[DBConfig](svc)

func Module

func Module(opts ...Option) ligo.Module

Module returns a Ligo module that loads configuration from .env files, programmatic [Loader]s, and process env, then publishes a singleton *Service into the DI container.

It is the Go equivalent of ConfigModule.forRoot from @nestjs/config — register it once at the top of your module tree and inject *Service into downstream providers.

app.Register(
    ligo_config.Module(
        ligo_config.WithEnvFiles(".env.local", ".env"),
        ligo_config.WithExpand(true),
        ligo_config.WithValidate(func(s *ligo_config.Service) error {
            _, err := s.MustGet("DATABASE_URL")
            return err
        }),
    ),
    myFeatureModule(),
)

Loading happens during OnInit, so failures (missing required keys, validator errors, malformed .env files) abort startup before any downstream factory is constructed.

func ModuleWith added in v0.3.0

func ModuleWith(svc *Service) ligo.Module

ModuleWith returns a Ligo module that publishes a pre-built *Service into the DI container, skipping the loader pipeline. Pair with Load or MustLoad when you need to read config BEFORE ligo.New (e.g. for ligo.WithAddr) and want to reuse the same loaded values inside the app:

cfg := ligo_config.MustLoad(
    ligo_config.WithEnvFiles(".env.local", ".env"),
    ligo_config.WithExpand(true),
)
addr := ":" + cfg.GetOr("PORT", "8080")

app := ligo.New(ligo.WithAddr(addr))
app.Register(
    ligo_config.ModuleWith(cfg),
    myFeatureModule(),
)

This avoids the double-load and option-drift you'd get from calling Load and Module with parallel option lists. Use Module when no boot-time read is needed.

func MustBind

func MustBind[T any](svc *Service) *T

MustBind is the panicking variant of Bind. Use in package-level initialization where a missing config is genuinely fatal.

func Namespace

func Namespace[T any]() ligo.Provider

Namespace returns a ligo.Provider that produces a *T by calling Bind on the injected *Service. Other factories in the same module can then receive the typed config directly:

type DBConfig struct {
    Host string `env:"DB_HOST" default:"localhost"`
    Port int    `env:"DB_PORT" default:"5432"`
}

func DatabaseModule() ligo.Module {
    return ligo.NewModule("database",
        ligo.Providers(
            ligo_config.Namespace[DBConfig](),
            ligo.Factory[*Repo](NewRepo),
        ),
    )
}

func NewRepo(cfg *DBConfig) *Repo { /* … */ }

This mirrors registerAs from @nestjs/config — types act as the namespace label so each typed block is independently injectable.

func NamespaceFn

func NamespaceFn[T any](fn NamespaceFactory[T]) ligo.Provider

NamespaceFn is the same as Namespace but lets you supply a custom factory instead of going through Bind. Use when the env tag model doesn't fit (cross-field derivation, runtime feature flags, etc.).

ligo_config.NamespaceFn[DBConfig](func(svc *ligo_config.Service) (*DBConfig, error) {
    return &DBConfig{
        URL: fmt.Sprintf("postgres://%s:%d/%s",
            svc.GetOr("DB_HOST", "localhost"),
            svc.GetIntOr("DB_PORT", 5432),
            svc.GetOr("DB_NAME", "app")),
    }, nil
})

func Provider

func Provider() ligo.Provider

Provider returns just the *Service provider without the surrounding module. Use when you want to embed config setup inside an existing module instead of registering a dedicated one.

This variant skips loaders, .env files, and validation — it just publishes a *Service backed by os.Environ. Reach for Module when you need the full pipeline (env files, expansion, validators).

Types

type Loader

type Loader func(ctx context.Context) (map[string]string, error)

Loader is a programmatic configuration source. Loaders run during Module initialization and contribute key/value pairs that override values from .env files (but not process env, unless WithLoadersWin is set).

type NamespaceFactory

type NamespaceFactory[T any] func(svc *Service) (*T, error)

NamespaceFactory is a custom builder for a typed configuration block. It receives the merged *Service and returns a populated *T.

type Option

type Option func(*options)

Option configures the Module returned by Module / [ConfigModule].

func WithEnvFiles

func WithEnvFiles(paths ...string) Option

WithEnvFiles sets the list of .env files to attempt to load, in order from highest precedence to lowest. The first existing file's keys win among files. Missing files are skipped silently.

Default: [".env"].

func WithExpand

func WithExpand(expand bool) Option

WithExpand enables POSIX-style variable expansion inside values:

  • ${VAR} — substitute VAR; empty string if unset
  • ${VAR:-def} — substitute VAR; "def" if unset or empty
  • ${VAR:?msg} — substitute VAR; fail load with msg if unset or empty

Variables resolve against the already-merged map at expansion time, so later-loaded sources can reference earlier ones.

Default: false (literal values, no expansion).

func WithIgnoreEnvFile

func WithIgnoreEnvFile(ignore bool) Option

WithIgnoreEnvFile skips .env file loading entirely. Use in containerized environments where all configuration comes from the process env or explicit loaders.

func WithLoader

func WithLoader(loader Loader) Option

WithLoader appends a programmatic configuration source. Loaders run in the order they are registered; later loaders override earlier ones.

By default loaders override .env files but not process env. Use WithLoadersWin to flip that precedence.

func WithLoadersWin

func WithLoadersWin(win bool) Option

WithLoadersWin makes Loader-supplied keys override process env. Useful in tests where you want to inject a deterministic config map regardless of the host's environment.

func WithValidate

func WithValidate(v Validator) Option

WithValidate registers a hook that runs after all sources have been merged but before the Service is exposed to other providers. Returning a non-nil error aborts ligo.App.Run.

type Service

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

Service is the merged, immutable view over all configured sources. It is registered as a singleton provider by Module; inject *Service into any factory in the same module tree.

Service is safe for concurrent use.

func Load added in v0.2.0

func Load(opts ...Option) (*Service, error)

Load synchronously builds a *Service outside the Ligo DI lifecycle. Use in main() when you need configuration values BEFORE ligo.New — for example, to resolve the bind address passed to ligo.WithAddr, which wires at construction time, earlier than Module's OnInit hook.

svc, err := ligo_config.Load(ligo_config.WithEnvFiles(".env"))
if err != nil {
    panic(err)
}
addr := ":" + svc.GetOr("PORT", "8080")
app := ligo.New(ligo.WithAddr(addr), ...)

The returned *Service is independent from the one Module produces; they read the same sources but do not share state. If you only need config at runtime (inside handlers, use cases, providers), prefer injecting *Service via Module instead.

func MustLoad added in v0.2.0

func MustLoad(opts ...Option) *Service

MustLoad is the panicking variant of Load. Use in main() where a missing or malformed config should crash the process before ligo.New.

func (*Service) All

func (s *Service) All() map[string]string

All returns a defensive copy of the merged key/value map. Intended for debugging and validation hooks; not a hot path.

func (*Service) Get

func (s *Service) Get(key string) (string, bool)

Get returns the raw string value for key and a presence flag. Empty strings count as present.

func (*Service) GetBool

func (s *Service) GetBool(key string) (bool, error)

GetBool parses the value as a bool (1/0/true/false/yes/no/on/off, case-insensitive). Returns ErrKeyNotFound or ErrInvalidValue.

func (*Service) GetBoolOr

func (s *Service) GetBoolOr(key string, def bool) bool

GetBoolOr returns the bool value for key, or def on miss or parse error.

func (*Service) GetDuration

func (s *Service) GetDuration(key string) (time.Duration, error)

GetDuration parses the value via time.ParseDuration (e.g. "5s", "300ms", "1h30m"). Returns ErrKeyNotFound or ErrInvalidValue.

func (*Service) GetDurationOr

func (s *Service) GetDurationOr(key string, def time.Duration) time.Duration

GetDurationOr returns the time.Duration value for key, or def on miss or parse error.

func (*Service) GetInt

func (s *Service) GetInt(key string) (int, error)

GetInt parses the value as an int. Returns ErrKeyNotFound if absent and ErrInvalidValue if non-numeric.

func (*Service) GetIntOr

func (s *Service) GetIntOr(key string, def int) int

GetIntOr returns the int value for key, or def on miss or parse error.

func (*Service) GetOr

func (s *Service) GetOr(key, def string) string

GetOr returns the string value for key, or def if the key is absent.

func (*Service) MustGet

func (s *Service) MustGet(key string) (string, error)

MustGet returns the string value for key or wraps ErrKeyNotFound if the key is absent.

type Validator

type Validator func(*Service) error

Validator is an optional hook that runs after all sources have been merged. Return a non-nil error to abort startup.

Jump to

Keyboard shortcuts

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