ASP.NET Core and ASP.NET Core Configuration build upon some fundamental and important principles to maintain its vision. Having worked with some of those principles for years and more recently working with ASP.NET Core Configuration and guiding other team members, I’ve come up with a recommended practices view of the principles through the eyes of ASP.NET implementation examples. It has helped me to have a deconstructed view of ASP.NET Core (and many other things) with those principles in mind, I hope it helps you too.
Some of the principles:
- Separation of Concerns
- Loose coupling
- Interface Segregation
- Single Responsibility
- Dependency Inversion
- Don’t Repeat Yourself
- Explicit Dependencies
1. Compartmentalization: Sections Are Your Friends
<span id=1-1>1.1</span> Do separate independent configuration groups by sections
One big chunk of configuration (essentially a big chunk of key-value pairs) as the configuration for all the individual components of your application has maintainability issues. It Works™, but that means that every component that is individually configurable within your application is more tightly-coupled to all the others because there is no explicit abstraction between their settings data. Something more maintainable is to separate the component configurations from one another by a section in the configuration. An appsettings.json example:{ "Logging" { "Debug": { "LogLevel": { "Default": "Warning" } } }, "ConnectionStrings": { "BloggingDatabase": "Server=(localdb)\\mssqllocaldb;Database=EFGetStarted.ConsoleApp.NewDb;Trusted_Connection=True;" },}
…where the sections are “Logging” and “ConnectionStrings”, and subsections “Debug” and “LogLevel”.
ASP.NET does a lot to allow you to be explicit and to support Dependency Inversion, and section separation of configuration will make that and the following recommendations easier.
<span id=1-2>1.2</span> Do not couple all your classes to IConfiguration
In the same vein as 1.1, using a single
IConfiguration
instance all over the place is the same as all your components being coupled to each others’ configuration. IConfiguration
is loosely coupled, but maintain loose coupling at the class/component level too.
2. Configuration Isn’t Just appsettings
Out of the box, ASP.NET Supports appsettings.json, environment-specific appsettings.json, command-line arguments, environment variables, ini files, XML config files, in-memory data, and any custom source/provider you need.
Viewing the application configuration as being manifested by appsettings JSON files is very limiting. Worse case it becomes an expectation and the ability to change configuration depends on loading appsettings:
var builder = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json");
Configuration = builder.Build();
As we’ll see, ASP.NET configuration is very powerful and flexible, think of ASP.NET Configuration independently of appsettings and that appsettings is just one part of it.
<span id=2-1>2.1</span> Do think of ASP.NET configuration as a means to a Configuration Management end
ASP.NET Configuration is powerful; powerful enough to be the application’s pane of glass into Configuration Management. ASP.NET Configuration provides a unified view of the independent variables that affect the success/outcome of application functionality. (think of the application as a function and configuration is the container of “inputs” that affect dependent variables to be observed or measured).
<span id=2-2>2.2</span> Do not couple code to source of configuration data
ASP.NET Core configuration is powerful and flexible, there is no need for controller, model, or domain classes to know anything about details like appsettings.json, environment variables, etc. Let ASP.NET Configuration handle all of that heavy lifting so that controller, model, and domain depend only upon abstractions, including these configuration abstractions.
<span id=2-3>2.3</span> Consider using the Options Pattern for grouping settings
One of the goals of a Dependency Injection container is to act as a registry–an opt-in mechanism to resolve instances from types and to relieve construction logic from dependents. Settings, options, and configuration are genuinely unique; they’re not object-oriented but data containers or shapes. As data containers there’s an expectation of “default” values and “missing” values and often “missing” means falling back to “defaults”. But, in an opt-in registry situation, what does that mean?
That’s what
IOptions
does for you, you don’t have to know that “defaults” need to be registered or to force the responsibility of knowing what “defaults’ mean on a boostrapper. Using IOptions<T>
in your constructors means you don’t have to worry about that. Using IOptions<T>
is an indirect opt-in of T
, the container will go ahead and instantiate it if one hasn’t already been registered with specific values (it doesn’t do that with any other types). This means the constructor of the options class takes on the responsibility of what “defaults” mean–which is more maintainable.
The Pattern is using
IOptions<T>
in constructors to inject settings via an instance of T
, and that any configuration is loaded via services.Configure<TOptions>(Configuration.GetSection("TOptions"))
. But, a key part of that pattern are the defaults. So, the shapes you use as TOptions
types should set default values. For example:public class MyOptions
{
public MyOptions()
{
// Set default value.
Option1 = "value1_from_ctor";
}
public string Option1 { get; set; }
public int Option2 { get; set; } = 5;
}
For more detail, see Options Pattern
<span id=2-4>2.4</span> Do understand configuration source default precedence
When using the ASP.NET Core
WebHostBuilder
, the order of precedence of configuration sources is:- appsettings.json
- appsettings.env.json
- [User Secrets]
- Environment Variables
- Command Line
To be specific, the order of precedence mirrors the order the providers are added to the configuration builder. The above is really just how
WebHostBuilder
implements them.
You can override all that if you want, but don’t cause yourself that pain: accept the defaults of
WebHostBuilder
.<span id=2-5>2.5</span> Do know how to override settings
Configuration supports a variety of providers. Some inherently support structured, complex data; some don’t. JSON is the quintessential example of structured configuration for ASP.NET:
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
}
}
}
And command-line arguments are the quintessential example of unstructured:
MyApplication.exe --Verbosity true
But, configuration providers translate whatever structure they support into the flat structure that ASP.NET Core configuration supports. To do that, providers simply use a colon
:
as hierarchy level separators. So, the Default
key name from the above JSON has a fully-qualified configuration key name of Logging:LogLevel:Default
. Which means you can override what is in appsettings with key/value pair from anyware, e.g. via the command line: MyApplication --Logging:LogLevel:Default Error
And to override appsettings values with environment variable values, use a double underscore
__
as a hierarchy delimiter instead of a colon hierarchy level delimiter:SET Logging__LogLevel__Default=Error
3. ASP.NET Core bootstrapping is all about the Builder Pattern and the Dependency Injection technique
<span id=3.1>3.1</span> Do understand the bounds of builders and utilize them correctly
To maintain the level of loose coupling that has lead to ASP.NET Core advancements, builders are essential. Builders provide the ability to describe (declaratively) what is needed to compose things from dependencies, and avoid coding how (imperatively) to compose things with dependencies.
The flow of things is often useful when getting into advanced situations:
Building the web host starts in Program.Main, building configuration spawns from building the web host, configuration is built before spawning Startup from building the web host, Startup is created before configuring services, configuring services completes before application builder, application builder completes before controllers are instantiated, some services may be instantiated after the application builder completes.
That flow results in some expectations:
- Use the application builder to amend what a built application means.
- Don’t expect to use services with
IConfigurationBuilder
- Use the configuration builder only to describe what building configuration means.
- Don’t expect to do any configuration building when services start configuring.
- Use the services collection to register instances or types for dependencies instead of within the dependencies.
- Add dependent service information after adding the information for services they depend upon.
- If service instances are required before service configuration is complete, instantiate them in
ConfigureServices
and use them before adding them to the services collection.
Dependency inversion means that the order of operations start is opposite to the order of dependency:
- host→configuration→services→application
- application⇢services⇢configuration⇢host
<span id=3.2>3.2</span> Avoid constructing any instance that aren’t used through Services Collection
Needing to construct an instance before you’ve added all dependent services to the services collection is an indication of a potential dependency problem (not inverted, or circular). So, avoid doing that without a specific reason (besides to Get It To Work™).
Metrics
Smells
Some less-measurable things that you can watch for that will help recognize refactoring candidates…
Use of C# built-in types in constructor parameters
The C# built-in types are the building-blocks for all other complex types. Parametric Polymorphism and Ad hoc Polymorphism are the language features that allow the compiler and runtimes to differentiate which thing to use while supporting looser coupling. (e.g. Generics and Function Overloading). Dependency Inversion depends heavily on Dependency Injection and Dependency Injection depends heavily on that type inference to achieve that level of loose coupling.
So, the re-usability of a type is at odds with how reliably another type (dependent on that first type for instantiation input) can be used with Dependency Injection.
For example, I may have a controller that needs a URL, and that URL could be a string parameter:
public class NaiveController
{
public NaiveController(string oAuthUrl)
{
//...
}
}
and I could use the services collection to inject that URL when the controller is instantiated:
services.AddSingleton("http://api.example.com/fugassi");
…and the ASP.NET container can infer that when instantiating a
NaiveController
instance. But, that means you can only have that one string
object as a service. If you had another controller that needed a URL, the container wouldn’t be able to figure out which string instance to use (if it supported more than one, unnamed).
So, when you see the use of built-in types, or very ubiquitous types as inputs to controllers and services, consider refactoring to the Options Pattern or refactoring to a Service. i.e. it’s not just that URL is stored in a built-in type, the
Uri
type could have been used instead, and the problem would be the same. Refactored to Options Pattern:public class NaiveController
{
public NaiveController(IOptions<NaiveControllerOptions> options)
{
var url = options.Value.OAuthUrl;
//...
}
}
Refactoring to a service means that functionality is encapsulated within a service. In the case of the URL example, rather than creating an options type to store that string value, the controller should actually be dependent on another service that would use that URL value but provide behavior. Refactoring to a service example:
public class NaiveController
{
public NaiveController(IOAuthProvider oAuthProvider)
{
//...
}
}
//...
services.AddSingleton<IOAuthProvider>(new TwitterOAuthProvider(Configuration.OAuthUrl/*...*/));
This is an easy example to understand, but lack of testability of a controller the uses a URL directly might have reared its head before noticing this smell. ¯\_(ツ)_/¯
See also Introduce Parameter Object refactoring.
Use of IConfiguration
outside of Startup
or bootstrap
See 1.2. Use of
IConfiguration
outside of Startup
or bootstrap means you’re coupling many classes to IConfiguraiton
.
No comments:
Post a Comment