Skip to content

Lifecycle Management

Confii provides thread-safe lifecycle operations for reloading, extending, freezing, overriding, mutating, and observing runtime configuration.

Runtime I/O is context-controlled and Reload/Extend prepare private candidates without blocking readers. See Context, cancellation, and operation lifecycles.

Confii runtime lifecycle


Admission before resolution

Materialization admits what it can before contacting any provider:

load sources -> parse -> merge
  -> admit secret-reference syntax        <- no provider contacted yet
  -> resolve secrets                      <- providers initialized here
  -> validate (schema, types, validators)
  -> publish

A reference-shaped token the grammar cannot parse fails here, naming the configuration path and the offending locator. Nothing is fetched. This holds for construction, Reload and Extend alike, and a failed admission leaves the previous configuration in place.

Why some admission stays after resolution

Two checks deliberately run later, because moving them earlier would reject valid configurations:

Type and schema admission. A field typed int holding ${secret:db/port} carries a string until the secret resolves. Schema constraints and exact-type admission describe the resolved value, not the placeholder standing in for it.

Sensitivity classification. It is derived from the unresolved configuration either way, so the result is identical; only the assignment point differs. A failed reload rolls the configuration back without restoring the classification, so assigning it from a candidate that never became live would leave the wrong one behind.

Why routing is not admitted

When you supply your own resolver, routing belongs to it. A custom ManagedSecretResolver may carry its own provider registry, so refusing an alias Confii does not recognize would break it. The bundled single-store resolver reports ErrProviderRoutingUnsupported at resolution instead.


Reload

Reload re-reads configuration sources and builds a private, fully materialized candidate. Confii publishes it atomically only after loading and validation succeed. If a source fails under ErrorPolicyRaise, the candidate is discarded and readers continue to observe the previous snapshot.

err := cfg.ReloadWithContext(ctx)
if err != nil {
    log.Printf("reload failed: %v", err)
}

Only reload files whose mtime or SHA256 content hash has changed. This avoids unnecessary parsing when most files have not been modified.

err := cfg.ReloadWithContext(ctx, confii.WithIncremental(true))

Load and validate from sources without applying any changes. Useful for pre-flight checks in CI or before a deploy.

err := cfg.ReloadWithContext(ctx, confii.WithDryRun(true))
if err != nil {
    log.Printf("dry-run failed: %v", err)
}
// Config remains unchanged regardless of outcome

Override the validate_on_load setting for this specific reload.

err := cfg.ReloadWithContext(ctx, confii.WithReloadValidate(true))

Combine reload options

Multiple reload options may be combined in one call:

err := cfg.ReloadWithContext(ctx,
    confii.WithIncremental(true),
    confii.WithReloadValidate(true),
)

Frozen configs cannot reload

Calling Reload on a frozen config returns ErrConfigFrozen. Unfreeze first or use Override for temporary changes.


Extend

Add a new loader at runtime and merge its configuration on top of the existing state. The new loader is also registered for future reloads.

err := cfg.ExtendWithContext(ctx, loader.NewJSON("extra.json"))
if err != nil {
    log.Fatal(err)
}

// The new source is now part of the config
val, _ := cfg.Get("extra.key")

Extend vs Reload

Extend adds a new source and merges it immediately. Reload re-reads all existing sources. After Extend, the new loader is included in subsequent reloads.


Override

Apply temporary scoped overrides. Returns a restore function that reverts to the original state. This is especially useful in tests.

restore, err := cfg.Override(map[string]any{
    "database.host": "test-db",
    "database.port": 15432,
})
if err != nil {
    log.Fatal(err)
}
defer restore() // always restore when done

host, _ := cfg.Get("database.host") // "test-db"

Test-friendly pattern

Override temporarily unfreezes the config, applies changes, then the restore function re-freezes it back to its original state.

func TestDatabaseConfig(t *testing.T) {
    restore, _ := cfg.Override(map[string]any{
        "database.host": "localhost",
        "database.port": 5433,
    })
    defer restore()

    // Test code runs with overridden config
    host, _ := cfg.Get("database.host")
    assert.Equal(t, "localhost", host)
}

Freeze

Make the configuration immutable. Any mutation attempt (Set, Reload, Extend, RollbackToVersion) returns ErrConfigFrozen.

cfg.Freeze()

err := cfg.Set("key", "value")
// err wraps ErrConfigFrozen

fmt.Println(cfg.IsFrozen()) // true

Configuration may also be frozen during construction:

cfg, err := confii.NewWithContext[any](ctx,
    confii.WithLoaders(loader.NewYAML("config.yaml")),
    confii.WithFreezeOnLoad(true),
)
cfg, err := confii.NewBuilder[any]().
    AddLoader(loader.NewYAML("config.yaml")).
    EnableFreezeOnLoad().
    BuildWithContext(ctx)

ErrConfigFrozen

Use errors.Is(err, confii.ErrConfigFrozen) to check for frozen state errors:

if errors.Is(err, confii.ErrConfigFrozen) {
    log.Println("config is frozen, cannot modify")
}

Set

Set a value by dot-separated key path. Thread-safe and respects frozen state.

err := cfg.Set("app.name", "my-service")

Protected Set

Use WithOverride(false) to prevent overwriting an existing key. This is useful for setting defaults without clobbering user-supplied values.

// Only set if "app.name" does not already exist
err := cfg.Set("app.name", "default-name", confii.WithOverride(false))
if err != nil {
    // key already exists
    log.Println(err)
}

Set invalidates the typed model cache

After Set, the next call to cfg.Typed() will re-decode and re-validate the config.


OnChange

Register callbacks that fire when configuration values change after a reload. Callbacks receive the key path, old value, and new value.

cfg.OnChange(func(key string, oldVal, newVal any) {
    log.Printf("config changed: %s = %v -> %v", key, oldVal, newVal)
})

cfg.OnChange(func(key string, oldVal, newVal any) {
    if key == "log.level" {
        updateLogLevel(newVal.(string))
    }
})

Multiple callbacks

Callbacks run in registration order for each changed key. Callback panics are recovered and do not propagate.

When do callbacks fire?

Callbacks fire after successful Set, Override, override restoration, Reload, Extend, secret refresh, and version rollback commits. Dry runs, rejected candidates, and no-op operations do not deliver changes.


Full Lifecycle Example

package main

import (
    "context"
    "errors"
    "fmt"
    "log"

    confii "github.com/confiify/confii-go/v2"
    "github.com/confiify/confii-go/v2/loader"
)

func main() {
    ctx := context.Background()

    // Create config
    cfg, err := confii.NewWithContext[any](ctx,
        confii.WithLoaders(loader.NewYAML("config.yaml")),
        confii.WithEnv("production"),
    )
    if err != nil {
        log.Fatal(err)
    }

    // Register change callback
    cfg.OnChange(func(key string, oldVal, newVal any) {
        fmt.Printf("changed: %s\n", key)
    })

    // Extend with another source
    _ = cfg.ExtendWithContext(ctx, loader.NewJSON("overrides.json"))

    // Set a value with protection
    _ = cfg.Set("feature.enabled", true, confii.WithOverride(false))

    // Temporary override for testing
    restore, _ := cfg.Override(map[string]any{"database.host": "test-db"})
    fmt.Println(cfg.GetStringOr("database.host", "")) // "test-db"
    restore()

    // Reload with dry-run first
    if err := cfg.ReloadWithContext(ctx, confii.WithDryRun(true)); err != nil {
        log.Printf("dry-run failed: %v", err)
    } else {
        _ = cfg.ReloadWithContext(ctx) // apply for real
    }

    // Freeze when done
    cfg.Freeze()
    if err := cfg.Set("key", "val"); errors.Is(err, confii.ErrConfigFrozen) {
        fmt.Println("config is frozen")
    }
}