DEV Community

Cover image for Introducing Kiwi Config: Simplifying Configuration Management in .NET Ecosystems
Ajay Jain
Ajay Jain

Posted on

Introducing Kiwi Config: Simplifying Configuration Management in .NET Ecosystems

Introduction to Kiwi Config

Kiwi Config (Kiwify.Kiwi.Configuration) is an attribute-driven configuration library for .NET. This article walks through every concept and feature with detailed explanations and practical examples - from the simplest binding to nested hierarchies, dynamic defaults, and collection handling.


The Problem Kiwi Config Solves

Every .NET application reads configuration. The platform gives you IConfiguration, which is flexible but stringly-typed. A call like config["database:port"] compiles regardless of whether that key exists. A typo is silent. A missing key returns null. The code that reads configuration is scattered across the application, and there is no single place that says "this is everything this service reads from config."

ConfigurationBinder.Bind() and IOptions<T> improve on this by binding to typed objects, but they work by name matching convention - the class does not explicitly declare which key it reads, what default to use when the key is absent, or which fields are required. You learn about required-field problems at runtime, not at load time.

Kiwi Config takes a different position: configuration errors should fail at startup, not at runtime. If your service can start, its configuration is valid.

The config class is the schema. Every key a class reads, every default it applies, and every field it requires is declared as an attribute directly on the class. The library reads those declarations once at startup and populates the object. After that, the rest of the application works with a strongly-typed, fully-populated object - no string lookups, no nullable coalescing, no silent failures.


Implicit vs. Explicit Schema

Traditional .NET configuration uses an implicit schema: what a class reads from config is scattered across string lookups, discoverable only by reading the calling code. Kiwi Config uses an explicit schema: the class declares everything it reads, and the library enforces it at load time.

IConfiguration (traditional) Kiwi Config
Schema location Scattered across call sites Declared on the class
Defaults Hardcoded at each call site [ConfigKey("key", defaultValue)]
Required fields Not enforced Required = true - validated at startup
Failure point Runtime - first access of a missing key Startup - before the app handles requests
Discovery Read all callers Open the file

The same information that was once implied is now declared. That is the complete picture of what changes.


Source & Repository

Kiwi Config is open-source and available on GitHub:

https://github.com/kiwifylabs/kiwi-foundation-config-di


Installation

NuGet Package

<PackageReference Include="Kiwify.Kiwi.Configuration" Version="1.0.0" />
Enter fullscreen mode Exit fullscreen mode

.NET CLI

dotnet add package Kiwify.Kiwi.Configuration --version 1.0.0
Enter fullscreen mode Exit fullscreen mode

Source Code

https://github.com/kiwifylabs/kiwi-foundation-config-di

No other packages are required. Microsoft.Extensions.Configuration is included transitively.


Related Articles


The Basics: Your First Config Class

A config class needs two things: a [ConfigSection] attribute on the class that declares where in the configuration hierarchy the class lives, and [ConfigKey] attributes on each property that declare which key to read and what default to use.

using Kiwify.Kiwi.Platform.Configuration.Attributes;

[ConfigSection("app")]
public class AppConfig
{
    [ConfigKey("name", "MyApp")]
    public string Name { get; private set; } = string.Empty;

    [ConfigKey("port", 5000)]
    public int Port { get; private set; }

    [ConfigKey("debug", false)]
    public bool Debug { get; private set; }
}
Enter fullscreen mode Exit fullscreen mode

appsettings.json:

{
  "app": {
    "name": "ProductionApp",
    "port": 8080,
    "debug": false
  }
}
Enter fullscreen mode Exit fullscreen mode

To load it:

using Kiwify.Kiwi.Platform.Configuration;
using Microsoft.Extensions.Configuration;

var configuration = new ConfigurationBuilder()
    .AddJsonFile("appsettings.json")
    .AddEnvironmentVariables()
    .Build();

var appConfig = configuration.LoadConfiguration<AppConfig>();
Console.WriteLine(appConfig.Name);  // ProductionApp
Console.WriteLine(appConfig.Port);  // 8080
Console.WriteLine(appConfig.Debug); // False
Enter fullscreen mode Exit fullscreen mode

LoadConfiguration<T> is an extension method on IConfiguration. It creates a new instance of T and walks every property with a [ConfigKey] attribute, reads the value from configuration, converts it to the property type, and sets it. It returns the fully populated instance.


[ConfigSection] - Declaring the Root Key

[ConfigSection] tells the library which key in the configuration hierarchy this class belongs to.

[ConfigSection("database")]
public class DatabaseConfig { ... }
Enter fullscreen mode Exit fullscreen mode

When you call configuration.LoadConfiguration<DatabaseConfig>(), the library uses "database" as the prefix for every key it reads from that class. A property bound to "port" reads "database:port" from IConfiguration.

What [ConfigSection] is not:

  • It is not an absolute path. The key is always the starting prefix, and nested objects append to it.
  • It cannot use / to escape to the root. Only [ConfigKey] supports absolute paths.
  • Every class passed directly to LoadConfiguration<T>() must have [ConfigSection]. Every class used as a [ConfigObject] property must also have it.

[ConfigKey] - Binding a Property

[ConfigKey] maps a property to a configuration key within the class's section.

[ConfigKey("port", 5432)]
public int Port { get; private set; }
Enter fullscreen mode Exit fullscreen mode

The first argument is the key name (relative to the section). The second argument is the default value used when the key is absent from configuration.

Setter support

Private setters, protected setters, and init-only properties are all supported. The library sets values via reflection. The init restriction is compiler-enforced at compile time, not at runtime, so reflection bypasses it cleanly.

[ConfigKey("timeout", 30)]
public int Timeout { get; private set; }   // private setter - supported

[ConfigKey("name", "default")]
public string Name { get; init; } = string.Empty;  // init-only - supported
Enter fullscreen mode Exit fullscreen mode

No default

If you omit the default argument and the key is absent, the property gets the type's zero value (0, false, null). For strings, that means null. If you want string.Empty instead, use [ConfigKey("key", "")] or a GetDefaultName() method. This is the lowest-priority fallback.

[ConfigKey("retryCount")]
public int RetryCount { get; private set; }  // gets 0 if absent
Enter fullscreen mode Exit fullscreen mode

Property Initializers

Property initializers run before configuration binding. The library always overwrites the property - whether it sets a config value, an attribute default, a GetDefault* result, or a type zero value. Initializers are not part of the fallback chain and have no effect on what value the property ends up with after loading.

[ConfigKey("timeout", 30)]
public int Timeout { get; private set; } = 60;  // 60 is always overwritten by binding
Enter fullscreen mode Exit fullscreen mode

This also affects string properties. If a string property has no config value and no default, it will be null after loading, even if the initializer sets it to string.Empty:

[ConfigKey("name")]
public string Name { get; private set; } = "fallback";  // overwritten - gets null if absent
Enter fullscreen mode Exit fullscreen mode

Use [ConfigKey("name", "")] or GetDefaultName() if you need a non-null fallback.


Type Conversions

The library converts configuration string values to property types automatically.

Integers, longs, doubles, decimals

Standard numeric parsing. A value that cannot be converted throws InvalidOperationException immediately with the key path and failed value in the message.

[ConfigKey("port", 5000)]
public int Port { get; private set; }

[ConfigKey("maxFileSize", 104857600L)]
public long MaxFileSize { get; private set; }

[ConfigKey("threshold", 0.95)]
public double Threshold { get; private set; }
Enter fullscreen mode Exit fullscreen mode

Booleans

Booleans accept a wider set of truthy and falsy strings than strict "true"/"false" parsing. This matches how boolean flags commonly appear in environment variables and configuration files.

Truthy Falsy
true, 1, yes, on, enabled false, 0, no, off, disabled
[ConfigKey("metricsEnabled", false)]
public bool MetricsEnabled { get; private set; }
Enter fullscreen mode Exit fullscreen mode

Config value "yes"true. Config value "off"false. Any other value throws.

Enums

Enum values are parsed case-insensitively. The enum type is inferred from the property type.

public enum AppEnvironment { Development, Staging, Production }

[ConfigKey("environment", AppEnvironment.Development)]
public AppEnvironment Environment { get; private set; }
Enter fullscreen mode Exit fullscreen mode

Config value "production" or "Production"AppEnvironment.Production. An unrecognised value throws InvalidOperationException naming the key and the bad value.

Strings

Strings are passed through as-is - no conversion required.

[ConfigKey("connectionString", "")]
public string ConnectionString { get; private set; } = string.Empty;
Enter fullscreen mode Exit fullscreen mode

Default Value Resolution

When a key is absent from configuration, the library resolves a fallback using four levels of priority:

Priority Source Example
1 (highest) Value present in IConfiguration appsettings.json, environment variable
2 DefaultValue argument on [ConfigKey] [ConfigKey("port", 5432)]
3 GetDefault{PropertyName}() static method private static int GetDefaultPort()
4 (lowest) Type's zero value 0, false, null

If [ConfigKey] carries a DefaultValue, the library never calls GetDefault* - the attribute default takes precedence entirely.

[ConfigKey("port", 5432)]     // uses 5432 if absent; GetDefaultPort() is never called
public int Port { get; private set; }

[ConfigKey("maxConnections")] // no attribute default; GetDefaultMaxConnections() is called
public int MaxConnections { get; private set; }

[ConfigKey("name")]           // no attribute default, no GetDefault*; gets "" if absent
public string Name { get; private set; } = string.Empty;
Enter fullscreen mode Exit fullscreen mode

Required Fields

Mark a field as required when it must always be present in configuration and has no sensible default.

[ConfigKey("apiKey", Required = true)]
public string ApiKey { get; private set; } = string.Empty;

[ConfigKey("connectionString", Required = true)]
public string ConnectionString { get; private set; } = string.Empty;
Enter fullscreen mode Exit fullscreen mode

When the key is absent and Required = true:

  • The library does not fall through to GetDefault* or the type default.
  • LoadConfiguration<T> throws InvalidOperationException immediately.
  • The error message includes the full key path and the property name.
InvalidOperationException: Required configuration key 'app:apiKey' is missing
and no default value is provided. Property: AppConfig.ApiKey.
Enter fullscreen mode Exit fullscreen mode

Required = true combined with a DefaultValue is technically valid but redundant - the default satisfies the requirement before it can fire.


The GetDefault Convention

When a default needs to be computed rather than hardcoded - based on environment variables, machine characteristics, or external state - define a static method named GetDefault{PropertyName}() on the class.

[ConfigSection("database")]
public partial class DatabaseConfig
{
    [ConfigKey("maxConnections")]
    public int MaxConnections { get; private set; }

    [ConfigKey("commandTimeout")]
    public int CommandTimeout { get; private set; }
}

public partial class DatabaseConfig
{
    // Called when "database:maxConnections" is absent from config
    private static int GetDefaultMaxConnections()
        => Environment.ProcessorCount * 4;

    // Called when "database:commandTimeout" is absent from config
    private static int GetDefaultCommandTimeout()
        => int.TryParse(Environment.GetEnvironmentVariable("DEFAULT_TIMEOUT"), out var t) ? t : 30;
}
Enter fullscreen mode Exit fullscreen mode

The method is split into a partial class here to keep the defaults separate from the config declarations - a common pattern, not a requirement.

Rules for the method:

  • Must be static. Access modifier does not matter (private, internal, public all work).
  • Return type must exactly match the property type. A mismatch is detected at load time and throws.
  • Must accept no parameters. A method with parameters is detected at load time and throws.
  • If the method throws, the exception propagates uncaught through LoadConfiguration<T>. Guard against exceptions within the method if needed.
  • GetDefault* is never called when [ConfigKey] carries an attribute default - it is only the third-priority fallback.

Nested Configuration with [ConfigObject]

Configuration naturally forms hierarchies. A database section might contain a credentials sub-section. An app section might contain a caching sub-section. [ConfigObject] composes config classes to match the JSON structure.

How path resolution works

When the library encounters a [ConfigObject] property, it reads the nested type's [ConfigSection] key and appends it to the current path prefix using :. All properties of the nested type are then resolved relative to this new prefix.

[ConfigSection("app")]
public class AppConfig
{
    [ConfigKey("name")]
    public string Name { get; private set; } = string.Empty;
    // reads: "app:name"

    [ConfigObject]
    public DatabaseConfig Database { get; private set; } = null!;
    // nested prefix: "app:database"
}

[ConfigSection("database")]
public class DatabaseConfig
{
    [ConfigKey("server")]
    public string Server { get; private set; } = string.Empty;
    // reads: "app:database:server"

    [ConfigKey("port", 5432)]
    public int Port { get; private set; }
    // reads: "app:database:port"

    [ConfigObject]
    public CredentialsConfig Credentials { get; private set; } = null!;
    // nested prefix: "app:database:credentials"
}

[ConfigSection("credentials")]
public class CredentialsConfig
{
    [ConfigKey("username")]
    public string Username { get; private set; } = string.Empty;
    // reads: "app:database:credentials:username"

    [ConfigKey("password", Required = true)]
    public string Password { get; private set; } = string.Empty;
    // reads: "app:database:credentials:password"
}
Enter fullscreen mode Exit fullscreen mode

appsettings.json:

{
  "app": {
    "name": "OrderService",
    "database": {
      "server": "db.prod.example.com",
      "port": 5432,
      "credentials": {
        "username": "svc_orders",
        "password": "s3cr3t"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Loading AppConfig populates the entire hierarchy in one call:

var appConfig = configuration.LoadConfiguration<AppConfig>();

Console.WriteLine(appConfig.Name);                          // OrderService
Console.WriteLine(appConfig.Database.Server);               // db.prod.example.com
Console.WriteLine(appConfig.Database.Port);                 // 5432
Console.WriteLine(appConfig.Database.Credentials.Username); // svc_orders
Console.WriteLine(appConfig.Database.Credentials.Password); // s3cr3t
Enter fullscreen mode Exit fullscreen mode

Nested types and DI registration

When using Kiwi Config with Kiwi DI, only root config classes need [ConfigService] to be auto-loaded. Nested types like DatabaseConfig and CredentialsConfig in the example above are loaded as part of their parent - they do not need their own [ConfigService] unless you want them registered independently as well.

Circular reference detection

If a [ConfigObject] chain references a type that is already in its own ancestor chain, the library detects the cycle and throws InvalidOperationException. The check is per-load-call and tracks only the current path, so the same type can safely appear in separate, independent branches of the hierarchy.


Absolute Key Paths

Sometimes a property needs to read a value from an entirely different part of the configuration hierarchy - perhaps a global setting that is shared across sections. The / prefix on a [ConfigKey] key escapes the current section prefix and reads from the configuration root.

[ConfigSection("app")]
public class AppConfig
{
    [ConfigKey("name")]
    public string Name { get; private set; } = string.Empty;
    // reads "app:name" (normal, relative to section)

    [ConfigKey("/globalTimeout", 30)]
    public int GlobalTimeout { get; private set; }
    // reads root-level "globalTimeout" - the "app:" prefix is ignored
}
Enter fullscreen mode Exit fullscreen mode

appsettings.json:

{
  "globalTimeout": 60,
  "app": {
    "name": "MyApp"
  }
}
Enter fullscreen mode Exit fullscreen mode
var appConfig = configuration.LoadConfiguration<AppConfig>();
Console.WriteLine(appConfig.GlobalTimeout); // 60 - from root level
Enter fullscreen mode Exit fullscreen mode

Important: The / prefix applies only to [ConfigKey]. There is no equivalent for [ConfigSection] - section keys are always appended to the parent prefix. Only individual properties can escape the hierarchy.

Absolute paths also work inside nested types:

[ConfigSection("database")]
public class DatabaseConfig
{
    [ConfigKey("port", 5432)]
    public int Port { get; private set; }
    // reads "app:database:port" when nested under AppConfig

    [ConfigKey("/sharedSecret", Required = true)]
    public string SharedSecret { get; private set; } = string.Empty;
    // reads root-level "sharedSecret" regardless of nesting depth
}
Enter fullscreen mode Exit fullscreen mode

Collections

Properties typed as arrays or lists are populated by splitting a comma-separated configuration string.

[ConfigSection("app")]
public class AppConfig
{
    [ConfigKey("allowedPorts", "80,443,8080")]
    public int[] AllowedPorts { get; private set; } = Array.Empty<int>();

    [ConfigKey("allowedOrigins")]
    public List<string> AllowedOrigins { get; private set; } = new();

    [ConfigKey("featureFlags")]
    public string[] FeatureFlags { get; private set; } = Array.Empty<string>();
}
Enter fullscreen mode Exit fullscreen mode

appsettings.json:

{
  "app": {
    "allowedPorts": "80,443,8080",
    "allowedOrigins": "https://app.example.com,https://admin.example.com",
    "featureFlags": "analytics,darkMode,betaApi"
  }
}
Enter fullscreen mode Exit fullscreen mode

Supported collection types: T[], List<T>, and any type implementing IEnumerable<T>, IList<T>, or ICollection<T>.

⚠️ Collection Format Limitation

Kiwi Config expects collections as comma-separated strings. JSON array syntax is not supported.

Supported:

{ "app": { "allowedPorts": "80,443,8080" } }
Enter fullscreen mode Exit fullscreen mode

Not supported - will not be parsed as a collection:

{ "app": { "allowedPorts": [80, 443, 8080] } }
Enter fullscreen mode Exit fullscreen mode

If your configuration source uses JSON array syntax, use IConfiguration.GetSection(...).Get<T[]>() directly instead of [ConfigKey].

Edge cases

Input Behaviour
"8080, 8443" (spaces around commas) Whitespace is trimmed - produces [8080, 8443]
"value1,,value2" (double comma) Empty entries are removed - produces ["value1", "value2"]
"" (empty string) Returns an empty collection
"80,abc" for int[] Throws InvalidOperationException naming the key and the bad element

Using LoadConfiguration<T> Standalone

No DI container is required. LoadConfiguration<T> works anywhere you have an IConfiguration - whether or not a service collection exists.

CLI tools

[ConfigSection("tool")]
public class ToolConfig
{
    [ConfigKey("outputPath", "./output")]
    public string OutputPath { get; private set; } = string.Empty;

    [ConfigKey("verbose", false)]
    public bool Verbose { get; private set; }

    [ConfigKey("maxThreads")]
    public int MaxThreads { get; private set; }

    private static int GetDefaultMaxThreads() => Environment.ProcessorCount;
}

var configuration = new ConfigurationBuilder()
    .AddJsonFile("tool.json", optional: true)
    .AddEnvironmentVariables(prefix: "TOOL_")
    .AddCommandLine(args)
    .Build();

var toolConfig = configuration.LoadConfiguration<ToolConfig>();
// Use toolConfig directly - no service collection needed
Enter fullscreen mode Exit fullscreen mode

Test helpers

var inMemoryConfig = new ConfigurationBuilder()
    .AddInMemoryCollection(new Dictionary<string, string?>
    {
        ["database:host"] = "localhost",
        ["database:port"] = "5432",
        ["database:name"] = "testdb"
    })
    .Build();

var dbConfig = inMemoryConfig.LoadConfiguration<DatabaseConfig>();
// Use dbConfig to construct the class under test directly
Enter fullscreen mode Exit fullscreen mode

In-memory configuration is particularly useful in tests because it does not require files on disk and the values are explicit in the test code.

Pre-container startup code

// Read config before the DI container is built - for use in middleware setup, logging, etc.
var appConfig = configuration.LoadConfiguration<AppConfig>();
SetupLogging(appConfig.LogLevel);
ConfigureMiddleware(appConfig.Port);

// Later: pass the already-loaded instance to the DI container
services.AddKiwiServices(configuration, preLoadedConfigs: appConfig);
Enter fullscreen mode Exit fullscreen mode

Logging

Pass an ILogger to LoadConfiguration<T> to receive a detailed trace of what the library does during binding. This is useful during development and debugging.

var logger = loggerFactory.CreateLogger<DatabaseConfig>();
var dbConfig = configuration.LoadConfiguration<DatabaseConfig>(logger);
Enter fullscreen mode Exit fullscreen mode

Example output at Debug level:

dbg: Loading configuration for DatabaseConfig from section 'database'
dbg: Loaded config 'database:host' = db.prod.example.com
dbg: Loaded config 'database:port' = 5432 (default from: attribute)
dbg: Loaded config 'database:maxConnections' = 16 (default from: method:GetDefaultMaxConnections)
dbg: Loading nested configuration object 'Credentials' from section 'database:credentials'
dbg: Loaded config 'database:credentials:username' = svc_orders
dbg: Loaded config 'database:credentials:password' = *****
inf: Successfully loaded configuration for DatabaseConfig
Enter fullscreen mode Exit fullscreen mode

The library logs the raw value it resolves for each property. Sensitive values should be masked by the caller before logging - LoadConfiguration<T> has no built-in concept of sensitive keys.

Event Level
Starting load for a config type Debug
Property bound from config Debug
Property resolved from a default Debug (includes default source: attribute, method:GetDefaultXxx, type-default)
Nested object starting Debug
Config type loaded successfully Information
Property load failure Propagates as exception - no log entry

Logging is optional. Omit the logger argument when you do not need the output.


Error Messages

Errors include the full key path, the property name, and a description of what went wrong. They are all thrown as InvalidOperationException.

Configuration error at 'database:port': Cannot convert value 'abc' to type Int32.
  Check your configuration file. Details: Input string was not in a correct format.

Required configuration key 'app:apiKey' is missing and no default value is provided.
  Property: AppConfig.ApiKey.

Nested configuration type 'DatabaseConfig' used with [ConfigObject] must have [ConfigSection].
  Property: AppConfig.Database.

Circular reference detected in configuration hierarchy: Type 'DatabaseConfig' references itself
  either directly or through nested objects.

Default method 'DatabaseConfig.GetDefaultPort()' has wrong return type.
  Expected 'Int32', but got 'String'.

Default method 'DatabaseConfig.GetDefaultPort()' must have no parameters.
Enter fullscreen mode Exit fullscreen mode

All errors surface at load time - at startup, before the application enters its request-handling loop. There are no silent failures.


Validation Beyond Required Fields

Kiwi Config intentionally does not support range checks, format validation, or cross-property rules. The library validates structure (required keys, correct types, valid enum values) and stops there.

For additional constraints, validate the populated object immediately after loading:

var dbConfig = configuration.LoadConfiguration<DatabaseConfig>();

if (dbConfig.MaxConnections is < 1 or > 1000)
    throw new InvalidOperationException("MaxConnections must be between 1 and 1000.");

if (!Uri.TryCreate(dbConfig.BrokerUrl, UriKind.Absolute, out _))
    throw new InvalidOperationException($"BrokerUrl is not a valid URI: {dbConfig.BrokerUrl}");
Enter fullscreen mode Exit fullscreen mode

This keeps validation visible and co-located with the loading code. It also keeps the library focused.


Performance Guidance

LoadConfiguration<T> uses reflection to discover and populate properties.

  • Call once, store the result. The library does not cache. Each call does a full reflection walk and creates a new instance. Call LoadConfiguration<T>() once at startup and store or register the result.
  • Not for hot paths. Do not call it inside request handlers, loops, or frequently-executed code.
  • Thread safe. Multiple concurrent calls are safe - the method operates on independent instances with no shared mutable state.
  • Startup cost scales with depth. A deep nested hierarchy with many properties costs more than a flat class, but the cost is paid once at startup, not per request. Typical startup cost is in the range of microseconds to a few milliseconds, depending on the size of the configuration graph.

The expected pattern:

// At startup - once
var dbConfig = configuration.LoadConfiguration<DatabaseConfig>();
services.AddSingleton(dbConfig);

// In application code - injected, no further loading
public class OrderRepository(DatabaseConfig db) { ... }
Enter fullscreen mode Exit fullscreen mode

Complete Real-World Example

A microservice configuration with every feature of the library in use.

appsettings.json:

{
  "globalRateLimit": 1000,
  "service": {
    "name": "OrderService",
    "environment": "Production",
    "port": 5000,
    "allowedOrigins": "https://app.example.com,https://admin.example.com"
  },
  "database": {
    "connectionString": "Host=db.prod.example.com;Database=orders;Username=svc_orders",
    "commandTimeoutSeconds": 30,
    "retryAttempts": 3,
    "credentials": {
      "username": "svc_orders",
      "password": "s3cr3t"
    }
  },
  "messaging": {
    "brokerUrl": "amqp://rabbit.prod.example.com",
    "exchangeName": "orders",
    "prefetchCount": 10
  }
}
Enter fullscreen mode Exit fullscreen mode

Config classes:

using Kiwify.Kiwi.Platform.Configuration.Attributes;

public enum AppEnvironment { Development, Staging, Production }

// Root service config - reads a root-level value via absolute path
[ConfigSection("service")]
public class ServiceConfig
{
    [ConfigKey("name", Required = true)]
    public string Name { get; private set; } = string.Empty;

    [ConfigKey("environment", AppEnvironment.Development)]
    public AppEnvironment Environment { get; private set; }

    [ConfigKey("port", 5000)]
    public int Port { get; private set; }

    [ConfigKey("allowedOrigins")]
    public string[] AllowedOrigins { get; private set; } = Array.Empty<string>();

    // Reads root-level "globalRateLimit", not "service:globalRateLimit"
    [ConfigKey("/globalRateLimit", 500)]
    public int RateLimit { get; private set; }
}

// Database config with nested credentials and a computed default
[ConfigSection("database")]
public partial class DatabaseConfig
{
    [ConfigKey("connectionString", Required = true)]
    public string ConnectionString { get; private set; } = string.Empty;

    [ConfigKey("commandTimeoutSeconds", 30)]
    public int CommandTimeoutSeconds { get; private set; }

    [ConfigKey("retryAttempts", 3)]
    public int RetryAttempts { get; private set; }

    // No attribute default - computed dynamically
    [ConfigKey("maxConnections")]
    public int MaxConnections { get; private set; }

    [ConfigObject]
    public CredentialsConfig Credentials { get; private set; } = null!;
}

public partial class DatabaseConfig
{
    private static int GetDefaultMaxConnections() => Environment.ProcessorCount * 4;
}

// Nested credentials - loaded as part of DatabaseConfig
[ConfigSection("credentials")]
public class CredentialsConfig
{
    [ConfigKey("username", Required = true)]
    public string Username { get; private set; } = string.Empty;

    [ConfigKey("password", Required = true)]
    public string Password { get; private set; } = string.Empty;
}

// Messaging config - simple flat section
[ConfigSection("messaging")]
public class MessagingConfig
{
    [ConfigKey("brokerUrl", Required = true)]
    public string BrokerUrl { get; private set; } = string.Empty;

    [ConfigKey("exchangeName", "default")]
    public string ExchangeName { get; private set; } = string.Empty;

    [ConfigKey("prefetchCount", 10)]
    public int PrefetchCount { get; private set; }
}
Enter fullscreen mode Exit fullscreen mode

Loading all configs standalone - no DI container required:

var configuration = new ConfigurationBuilder()
    .AddJsonFile("appsettings.json")
    .AddEnvironmentVariables()
    .Build();

var service   = configuration.LoadConfiguration<ServiceConfig>();
var database  = configuration.LoadConfiguration<DatabaseConfig>();
var messaging = configuration.LoadConfiguration<MessagingConfig>();

Console.WriteLine($"{service.Name} [{service.Environment}] on :{service.Port}");
// OrderService [Production] on :5000

Console.WriteLine($"Rate limit: {service.RateLimit} req/s");
// Rate limit: 1000 req/s  (from root-level "globalRateLimit")

Console.WriteLine($"DB: {database.ConnectionString}");
Console.WriteLine($"DB connections: {database.MaxConnections}");
// DB connections: 16  (from GetDefaultMaxConnections(), 4 cores * 4)

Console.WriteLine($"DB credentials: {database.Credentials.Username}@...");
// DB credentials: svc_orders@...

Console.WriteLine($"MQ: {messaging.BrokerUrl} / {messaging.ExchangeName} (prefetch={messaging.PrefetchCount})");
// MQ: amqp://rabbit.prod.example.com / orders (prefetch=10)
Enter fullscreen mode Exit fullscreen mode

Validation after load:

if (database.CommandTimeoutSeconds is < 1 or > 300)
    throw new InvalidOperationException("CommandTimeoutSeconds must be between 1 and 300.");

if (messaging.PrefetchCount < 1)
    throw new InvalidOperationException("PrefetchCount must be at least 1.");
Enter fullscreen mode Exit fullscreen mode

When used with Kiwi DI, adding [ConfigService] to each class means a single services.AddKiwiServices(configuration) call loads and registers all of them automatically.


Configuration Reloading

Kiwi Config loads configuration once and produces an immutable object. There is no mechanism to re-read the configuration source and update the object in place, and the library does not observe IConfiguration change tokens.

This is intentional. Immutable config objects are safe to inject as singletons and share across threads without locks. For most applications - especially server applications where configuration changes require a restart to reason about reliably - this is the right default.

If you need configuration values to update at runtime without restarting the application, use IOptionsMonitor<T> from Microsoft.Extensions.Options. It observes configuration change tokens and provides access to the current value at each call site:

public class MyService(IOptionsMonitor<AppOptions> options)
{
    public void Do()
    {
        var current = options.CurrentValue; // always reflects the latest config
    }
}
Enter fullscreen mode Exit fullscreen mode

Kiwi Config and IOptionsMonitor<T> serve different needs and can coexist in the same application. Use Kiwi Config for startup-critical settings where immutability is a feature; use IOptionsMonitor<T> for settings that must update without a restart.


When to Use Kiwi Config vs IOptions<T>

Both libraries bind configuration to typed classes. The difference is in intent and guarantees.

Kiwi Config IOptions<T> / IOptionsMonitor<T>
How binding is declared Attributes on the class Convention (property names match config keys) or Configure<T>() calls
Default values Declared on the attribute Set in code via Configure<T>() or property initializers
Required fields Required = true - throws at load time Not built-in; validate after binding
Failure point Startup - before the app serves requests First access of IOptions<T>.Value
Reload support No - object is immutable after load Yes - IOptionsMonitor<T> observes change tokens
DI integration Optional - works without a container Requires IServiceCollection
Nested hierarchy [ConfigObject] with explicit path composition Automatic by property name matching

Choose Kiwi Config when:

  • You want the config class to be self-documenting - all keys, defaults, and requirements visible in one place.
  • Startup-time validation matters. A missing required key should fail immediately, not on the first request that happens to read the option.
  • You need computed defaults (GetDefault* methods) or absolute key paths.
  • You are outside a DI container (CLI tools, test fixtures, startup bootstrapping).

Choose IOptions<T> / IOptionsMonitor<T> when:

  • You need live reload - configuration values that update without restarting the application.
  • You are binding to a large set of settings where name-matching convention is less overhead than explicit attributes.
  • You are working with third-party libraries that already publish their own options types.

The two approaches can coexist in the same application. A common pattern is to use Kiwi Config for your own application settings (where explicitness matters) and IOptionsMonitor<T> for settings that require live reload.


Mental Model

Everything LoadConfiguration<T> does maps to five steps. Once you have this, the rest of the article is detail.

1. Start at [ConfigSection]
The attribute on the class sets the root key. All property lookups are relative to it: [ConfigSection("database")] means every key in the class reads from database:*.

2. Walk properties
For each property on T:

  • [ConfigKey] - read a scalar value from configuration at section:key
  • [ConfigObject] - recurse into the nested type, appending its [ConfigSection] key to the current prefix

3. Resolve the value (four-level priority)

1. Value present in IConfiguration          ← wins
2. DefaultValue on [ConfigKey]
3. GetDefault{PropertyName}() static method
4. Type's zero value (0, false, null)       ← last resort
Enter fullscreen mode Exit fullscreen mode

If Required = true and no value is found at levels 1-3, throw immediately.

4. Convert and assign
The resolved string is converted to the property type (int, bool, enum, collection, ...). Conversion failure throws with the key path and bad value in the message. The value is set via reflection - private and init setters are supported.

5. Fail fast on any error
Missing required keys, bad values, circular references, wrong GetDefault* signatures - all throw InvalidOperationException at load time. If LoadConfiguration<T> returns, the object is valid.


Feature Summary

Feature Attribute / API
Declare config section [ConfigSection("key")] on the class
Bind a property to a key [ConfigKey("key")] on the property
Set a static default [ConfigKey("key", defaultValue)]
Mark a field as required [ConfigKey("key", Required = true)]
Computed dynamic default private static T GetDefault{Prop}() method
Nested config object [ConfigObject] on a property
Read from config root [ConfigKey("/rootKey")] (slash prefix)
Collections (arrays/lists) Comma-separated config values
Flexible booleans true/1/yes/on/enabled, false/0/no/off/disabled
Case-insensitive enums Auto-parsed by property type
Load a config object configuration.LoadConfiguration<T>()
Load with diagnostics configuration.LoadConfiguration<T>(logger)

Top comments (0)