7 minute read

Sometimes an application needs to provide configuration defaults while still allowing the normal configuration pipeline to override them.

Those defaults might be part of the application itself rather than something that changes between deployments. I still want appsettings.json, environment variables, user secrets, command-line arguments or another configuration provider to replace them when necessary.

I also want the defaults to live in C# as an object graph rather than as duplicated JSON or a dictionary of flattened configuration paths.

For example, imagine these are the defaults for a library used by the application:

var defaults = new LibraryDefaults
{
    Retry = new RetryDefaults
    {
        Count = 3,
        Delay = TimeSpan.FromSeconds(5)
    },
    Endpoints =
    [
        "https://one.example",
        "https://two.example"
    ]
};

public sealed class LibraryDefaults
{
    public required RetryDefaults Retry { get; init; }

    public required string[] Endpoints { get; init; }
}

public sealed class RetryDefaults
{
    public required int Count { get; init; }

    public required TimeSpan Delay { get; init; }
}

These values belong in code, but I still want them to behave like configuration defaults.

That is the problem ObjectConfigurationExtensions is meant to solve.

The same defaults in appsettings.json

The most obvious way to make those values part of configuration is to put them in appsettings.json:

{
  "Library": {
    "Retry": {
      "Count": 3,
      "Delay": "00:00:05"
    },
    "Endpoints": [
      "https://one.example",
      "https://two.example"
    ]
  }
}

There is nothing wrong with this when the values are genuinely external configuration.

If they are expected to change independently from the application, a configuration file is probably exactly where they belong.

But application-owned defaults evolve together with the source code. Keeping them in JSON means maintaining another representation of that structure outside the code that owns it.

A property rename or structural refactoring can leave perfectly valid JSON behind that no longer binds in the way I intended. Expressing the defaults as C# does not make every configuration change magically safe, but it keeps the structure close to the code and lets the compiler participate in many of those changes.

Keeping configuration in memory

Microsoft.Extensions.Configuration already has an in-memory provider, so I can keep the values in code without introducing another library.

For a handful of scalar values, AddInMemoryCollection works very well:

builder.Configuration.AddInMemoryCollection(
    new Dictionary<string, string?>
    {
        ["FeatureEnabled"] = "true",
        ["RetryCount"] = "3"
    });

The representation gets less pleasant once the configuration has some structure:

builder.Configuration.AddInMemoryCollection(
    new Dictionary<string, string?>
    {
        ["Library:Retry:Count"] = "3",
        ["Library:Retry:Delay"] = "00:00:05",
        ["Library:Endpoints:0"] = "https://one.example",
        ["Library:Endpoints:1"] = "https://two.example"
    });

At this point I am describing the same object graph by hand, using configuration paths and string values instead of properties and .NET types.

The in-memory provider is doing its job correctly. The dictionary is simply no longer the representation I want to maintain.

Defaults on options are not default configuration

There is another very reasonable solution when the eventual consumer uses the .NET options pattern.

If I own the options type, I can put defaults directly on it:

public sealed class MyOptions
{
    public int RetryCount { get; set; } = 3;
}

If that solves the problem, I do not need another configuration provider.

The options infrastructure also gives us Configure<TOptions>, IConfigureOptions<TOptions>, PostConfigure<TOptions> and IPostConfigureOptions<TOptions>.

For example:

builder.Services.Configure<LibraryOptions>(options =>
{
    options.RetryCount = 3;
});

This works even when LibraryOptions belongs to another package.

But it happens at a different layer.

Configure<TOptions> says what should happen when a particular options object is created. It does not add values to IConfiguration.

That distinction matters if the same configuration section is read directly through IConfiguration, manually bound using Get<T>(), consumed by registration code inside another library, or bound to another model.

PostConfigure<TOptions> is further downstream still. It modifies an options object after the normal configuration steps have already run, which is useful for final adjustments or derived values but is not equivalent to supplying defaults to configuration itself.

Providing defaults to an options object is not the same as providing default configuration.

Sometimes I want the defaults to exist before any particular consumer decides how to interpret them.

Adding strongly typed fallback defaults

That is where Kralizek.Extensions.Configuration.Objects fits.

Install it from NuGet:

dotnet add package Kralizek.Extensions.Configuration.Objects

Then the object from the opening can become the fallback for the Library configuration section:

var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddObjectAsFallback(defaults, "Library");

The object is flattened into the same configuration paths that I could have written manually:

Library:Retry:Count
Library:Retry:Delay
Library:Endpoints:0
Library:Endpoints:1

The difference is that I keep authoring and maintaining the defaults as a typed object graph.

From that point on, there is nothing special about the resulting values. They are part of IConfiguration and can be read directly, bound to another object, consumed through the options system or used by a library that knows nothing about ObjectConfigurationExtensions.

The interesting part is the word fallback.

The configuration pipeline already exists

In a typical ASP.NET Core application, WebApplication.CreateBuilder(args) has already populated builder.Configuration before application code starts adding its own providers.

That pipeline includes the usual sources such as appsettings.json, environment-specific JSON, user secrets in Development, environment variables and command-line arguments.

A normal configuration provider added afterwards gets higher precedence than the providers already in the pipeline.

ObjectConfigurationExtensions exposes that normal behavior through AddObject:

var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddObject(new
{
    FeatureEnabled = true
});

If one of the existing providers already contains FeatureEnabled, the value from the object wins because its provider was appended later.

That is useful when the object is meant to provide overrides.

Defaults need the opposite behavior.

AddObjectAsFallback inserts the object provider at the beginning of the existing provider chain instead of appending it:

var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddObjectAsFallback(defaults, "Library");

The providers registered by the host keep their normal precedence over the object values.

So the C# object establishes the baseline, while an appsettings.json value, environment variable, user secret, command-line argument or another provider can override any part of it according to the usual configuration rules.

There is no separate merge step and no special logic in the eventual consumer.

The strongly typed object simply becomes the lowest-precedence part of the configuration pipeline.

Defaults for consumers I do not control

Keeping the defaults at the configuration layer is especially useful when I do not control how a component consumes its settings.

A library might bind a section internally:

services.AddSomeLibrary(
    configuration.GetSection("Library"));

It might register its own options pipeline, bind the section manually, or read individual values from IConfiguration.

If I use Configure<LibraryOptions>, I am participating specifically in the options pipeline for LibraryOptions.

If I provide defaults through configuration instead, the values are available regardless of which of those consumption patterns the library uses.

As long as the component reads from the same IConfiguration, the fallback values are already there.

This is a useful distinction for configuration models I do not own, but it is not limited to third-party libraries. The same idea applies whenever multiple consumers need to see the same baseline configuration.

Use the simplest representation that fits

ObjectConfigurationExtensions is not intended to replace the existing configuration mechanisms.

If a value is deployment-specific, putting it in JSON, environment variables, secrets or an external configuration service is usually the right choice.

If I own an options type and a property initializer expresses the default correctly, that is simpler.

If I need to add a couple of scalar values programmatically, AddInMemoryCollection is already there.

If I specifically need to control how one options type is constructed, Configure<TOptions> or PostConfigure<TOptions> may be exactly the right abstraction.

The object provider becomes useful when the defaults are naturally represented as a typed object graph but still need to participate in the configuration pipeline and its normal precedence rules.

Source-generated serialization

Internally, ObjectConfigurationExtensions uses System.Text.Json to turn the object graph into configuration values.

Version 4 also provides overloads accepting JsonTypeInfo<T> so the same approach can be used when reflection-based serialization is not appropriate, including trimming and Native AOT scenarios:

[JsonSerializable(typeof(LibraryDefaults))]
internal partial class AppJsonContext : JsonSerializerContext;

builder.Configuration.AddObjectAsFallback(
    defaults,
    AppJsonContext.Default.LibraryDefaults,
    "Library");

The serialization mechanism changes, but the resulting configuration and its precedence rules do not.

Recap

Defaults do not have to live on an options type or in a configuration file.

When they belong to the application but still need normal configuration semantics, they can be expressed as strongly typed C# objects and added to the bottom of the configuration pipeline.

Compared with JSON, the structure stays closer to the code that evolves with it. Compared with an in-memory dictionary, complex graphs stay natural to author. Compared with configuring one options type, the defaults exist at the configuration layer and are available to every consumer of that configuration.

AddObjectAsFallback provides that baseline without changing how the rest of the .NET configuration stack works. Existing providers can still override the defaults using the precedence rules they already have.

Most of the time we turn configuration into objects.

Sometimes an object is the configuration default we wanted in the first place.

Support this blog

If you liked this article, consider supporting this blog by buying me a pizza!