Validation

Both managers accept validators attached to a registration. A validator is a predicate plus a human-readable reason string; a config type or environment variable can have any number of validators.

JSON configs

JsonConfigManager.BuildInstance("default")
    .Register<AppSettings>("app-settings.json")
    .ApplyValidatorFor<AppSettings>(c => !string.IsNullOrWhiteSpace(c.ApiBaseUrl), "ApiBaseUrl must not be empty")
    .ApplyValidatorFor<AppSettings>(c => c.TimeoutSeconds > 0, "TimeoutSeconds must be positive")
    .Build();

The fluent order of Register<T> and ApplyValidatorFor<T> doesn’t matter, only whether T is registered by the time Build() runs: ApplyValidatorFor<T> itself performs no check when called, it just records the validator. Attaching a validator for a T that’s never registered throws InvalidOperationException at Build().

Environment variables

EnvVarConfigManager.BuildInstance("default")
    .Register("API_KEY")
    .ApplyValidatorFor("API_KEY", v => v.Length >= 32, "API_KEY must be at least 32 characters")
    .Build();

Same rule: order is free, but a name that’s never registered throws InvalidOperationException at Build().

Aggregation at build time

Every registered type or variable with at least one validator is checked once, during Build(), reading and deserializing/decrypting it as needed. Failures don’t stop at the first one: every failing validator for every failing type/variable is collected into a single ConfigValidationException:

try
{
    JsonConfigManager.BuildInstance("default")
        .Register<AppSettings>("app-settings.json")
        .Register<ConnectionStrings>("connection-strings.json")
        .ApplyValidatorFor<AppSettings>(c => c.TimeoutSeconds > 0, "TimeoutSeconds must be positive")
        .ApplyValidatorFor<ConnectionStrings>(c => c.Primary is not null, "Primary connection string is required")
        .Build();
}
catch (ConfigValidationException ex)
{
    // message covers both AppSettings and ConnectionStrings if both failed
    Console.WriteLine(ex.Message);
}

A build-time validation failure rolls back the same way any other build failure does: no instance is left registered under that name. See Core Concepts.

Types/variables that pass their validators are pre-cached during this same pass, so the first Get after a successful Build() doesn’t pay for a redundant re-read.

ConfigValidationException carries no structured collection of failures: every reason string for every failing type or variable is folded into the exception’s Message (one line per label, semicolon-separated reasons). Parse the message if you need failures programmatically.

Validation on write

For JSON configs, Put<T> re-runs T’s validators (if any) before writing. A failing Put throws ConfigValidationException immediately; nothing is written to disk:

var settings = configMgr.Get<AppSettings>();
settings.TimeoutSeconds = -1;

configMgr.Put(settings); // throws ConfigValidationException, file unchanged

EnvVarConfigManager has no Put; environment variables are read-only from the manager’s perspective, so their validators only ever run at Build() time.

What’s not aggregated

A CryptographicException from a wrong encryption key is not caught and folded into ConfigValidationException alongside validator failures. It surfaces on its own, unaggregated, straight out of Build(). See Encryption.

Next steps

  • Encryption: how a wrong key differs from a validation failure