Announcing ConfigurationExtensions - bind config to C# records and classes with constructors
This post discusses DevTrends.ConfigurationExtensions, a new NuGet package that extends the built-in .NET configuration functionality to allow config binding to positional C# records and classes with constructors.
What does it do?
The DevTrends.ConfigurationExtensions package allows you to use strongly typed models when binding from configuration sources such as appsettings.json. All config sources are supported. Unlike the built-in functionality, this works with positional records and classes without parameterless constructors. This can help avoid null reference warnings and runtime errors, particularly if you are using nullable reference types.
Where do I get it?
You can add the nuget package to your project with:
dotnet add package DevTrends.ConfigurationExtensions
You can view the source code on github.
How do I use it?
Given a record:
public record Foo(string Bar, int Blah);
If you have the following in your appsettings:
"Foo": {
"Bar": "hello",
"Blah": 42
}
You can bind the record to the settings by adding the following to Program.cs:
builder.Configuration.Bind<Foo>();
Of course, you will probably want to register the strongly typed configuration classes with the DI container so instead of using the above, you can use:
builder.Services.AddSingleton(builder.Configuration.Bind<Foo>());
What is supported?
You can currently bind to properties of the following type (plus nullable version including nullable reference types such as string?):
- string
- int
- bool
- decimal
- datetime
- nested classes or records
If you are not using the positional record syntax shown above, your classes or records must have a public constructor which sets all properties that you wish to automatically populate.
If you have any optional configuration values, be sure to use nullable types.
The package works on the 'fail fast' principle. Any non-nullable properties that cannot be set from configuration will result in an exception on application start.
Background
Many years ago, I wrote a post arguing that configuration settings are a dependency that should be injected. Today, in newer ASP.NET applications, most people do register strongly typed settings classes with the built-in IoC container and inject these configuration values into the classes that use them. This can be achieved very easily out of the box:
Create a strongly type config class:
public class MyConfig
{
public string StringValue { get; set; }
public int IntValue { get; set; }
}
Add the config keys. Here we use appsettings.json:
"MyConfig": {
"StringValue": "hello",
"intValue": 42
}
Register the type in Program.cs:
builder.Services.Configure<MyConfig>(builder.Configuration.GetSection(nameof(MyConfig)));
Add the config type to the constructor of the class that needs access to the config using the IOptions generic interface:
public MyService(IOptions<MyConfig> myConfig)
{
var stringValuefromConfig = myConfig.Value.StringValue;
}
As an alternative, if you do not like the IOptions construct and do not want your components to take an unnecessary dependency on it, you can use:
MyConfig options = new();
builder.Configuration.GetSection(nameof(MyConfig)).Bind(options);
builder.Services.AddSingleton(options);
or
builder.Services.Configure<MyConfig>(builder.Configuration.GetSection(nameof(MyConfig)));
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<MyConfig>>().Value);
Doing this means that we no longer needs to reference Microsoft.Extensions.Options and can change the constructor to:
public MyService(MyConfig myConfig)
{
var stringValuefromConfig = myConfig.StringValue;
}
All of these approaches generally works fine but there are three things I don't like:
- It requires that the config class has a parameterless constructor and public mutable properties
- It therefore doesn't work with c# (positional) records
- It silently ignores missing configuration values.
The last point can be remedied by utilising options validation together with the ValidateOnStart option added in .NET 6:
builder.Services.AddOptions<MyConfig>()
.Bind(builder.Configuration.GetSection(nameof(MyConfig)))
.ValidateDataAnnotations()
.ValidateOnStart();
You then need to decorate your config class with data annotations attributes:
public class MyConfig
{
[Required]
public string StringValue { get; set; }
[Range(1, 10)]
public int IntValue { get; set; }
}
With these two changes, missing configuration results in an exception on application start. This works ok but changes in the c# language over the last few years mean that we really should not need to resort to validation attributes to mark properties as mandatory. Instead, we could just use an int for when a setting is required and an int? when it is optional. With Nullable Reference Types, this can be extended to non-value types such as strings and nested classes.
Nullable Reference Types were introduced in C# 9 but it is only now in .NET 6 that the templates have been changed to enable them by default. If you use the standard approach outlined above, in .NET 6, you are presented with warnings for every non-nullable reference type property:
Non-nullable property 'StringValue' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.
This is just alerting you to a problem that was always present - you cannot rely on these values getting set. There are two common workarounds:
-
You can mark the properties as nullable (using string? instead of string) but then you need to check for null wherever they get used and you are just moving the problem elsewhere.
-
You can use the null forgiving operator and set the properties to default or null:
public class MyConfig { public string StringValue { get; set; } = default!; public int IntValue { get; set; }; }
If you do this, the warnings go away, but if the config value is actually missing then the property will remain null, and you won't know about it until you try and use the value. This is basically the same behaviour that you see with non reference types - you just aren't warned about it at compile time. In both cases, if you have missing config values, the properties will contain default values - 0 for an int, false for a bool, null for reference types etc. This is extremely dangerous out-of-the-box behaviour because it is so easily missed.
Binding from config means that compile time safety is not possible, but the next best thing is to fail on startup so we immediately know about the problem.
What if we change our config class to use a constructor:
public class MyConfig
{
public string StringValue { get; }
public int IntValue { get; }
public MyConfig(string stringValue, int intValue)
{
StringValue = stringValue;
IntValue = intValue; // optionally add guard clauses
}
}
More succinctly, if no parameter checks are required, we can use a positional record:
public record MyConfig(string StringValue, int IntValue);
Now the warnings have gone because the constructor ensures that all values are present and correct. Unfortunately, neither of these changes work because the built-in Bind functionality requires a parameterless constructor.
Cannot dynamically create an instance of type 'MyConfig'. Reason: No parameterless constructor defined.
Hence the need for this library. Using DevTrends.ConfigurationExtensions to bind your configuration, you can use the parameterized class constructor or the positional record approach.
If you set a property as a non-null type (such as string, int or datetime) then the extension will throw an exception at startup if the setting is not present.
If you set a property as nullable - either using a nullable type such as int? or using a nullable reference type such as string? - then a missing setting will be ignored and the property will remain null.
In either case, if the setting cannot be converted to the correct type, it will throw an exception.
Please try out the package and let me know your thoughts. I hope you find it useful.
Can't wait to see IDE auto complete support for configuration settings json file based on defined bindings in code. Intellij IDEA does for spring boot configuration settings. Is VS/C# so far from Java ecosystem?
Hi Paul,
binding to list of objects or types (string, int ...) is not supported?
public record Foo(IEnumerable<string> Bars);
@Peter - lists are not currently supported but PRs are welcome :-)
Hello Paul! Brilliant content! To my surprise, this is a topic for which there is not much discussion, in fact, yours was the only quality discussion I found, detailing the actual problem, and possible solutions.
Your library is great :) thank you!
I tell you that the approach that I have been using as a "good practice" is that for each Configuration class (for example MySettingClass), I create a class that extends it, and has the "GetMySettingClass" method. In it, I use the classic GetSection, and after that I configure all the exceptions that I want to throw if any of the binded properties is null or in the form I require.
I know we have the ability to validate our configuration classes in OptionPattern now. But what if I don't want to generate the configuration class as a dependency because I'm only going to use it once?
Thanks for your thoughts and reflections on this!