This chapter explored the Options pattern, a powerful tool allowing us to configure our ASP.NET Core applications. It enables us to change the application without altering the code. The capability even allows the application to reload the options at runtime when a configuration file is updated without downtime. We learned to load settings from multiple sources, with the last loaded source overriding previous values. We discovered the following interfaces to access settings and learned that the choice of interface influences the lifetime of the options object:
- IOptionsMonitor<TOptions>
- IOptionsFactory<TOptions>
- IOptionsSnapshot<TOptions>
- IOptions<TOptions>
We delved into manually configuring options in the composition root and loading them from a settings file. We also learned how to inject options into a class and configure multiple instances of the same options type using named options. We explored encapsulating the configuration logic into classes to apply the single responsibility principle (SRP). We achieved this by implementing the following interfaces:
- IConfigureOptions<TOptions>
- IConfigureNamedOptions<TOptions>
- IPostConfigureOptions<TOptions>
We also learned that we could mix configuration classes with inline configurations using the Configure and PostConfigure methods and that the registration order of configurators is crucial as they are executed in order of registration.We also delved into options validation. We learned that the frequency at which options objects are validated depends on the lifetime of the options interface used. We also discovered the concept of eager validation, which allows us to catch incorrectly configured options classes at startup time. We learned to use data annotations to decorate our options with validation attributes such as [Required]. We can create validation classes to validate our options objects for more complex scenarios. Those validation classes must implement the IValidateOptions<TOptions> interface. We also learned how to bridge other validation frameworks like FluentValidation to complement the out-of-the-box functionalities or accommodate your taste for a different validation framework.We explored a workaround allowing us to inject options classes directly into their consumers. Doing this allows us to control their lifetime from the composition root instead of letting the types consuming them control their lifetime. This approach aligns better with dependency injection and Inversion of Control principles. That also makes testing the classes easier.Finally, we looked at the .NET 8 code generators that change how the options are handled but do not impact how we use the Options pattern. We also explored the ValidateOptionsResultBuilder type, also introduced in .NET 8.The Options pattern helps us adhere to the SOLID principles, as illustrated next:
- S: The Options pattern divides managing settings into multiple pieces where each has a single responsibility. Loading unmanaged settings into strongly typed classes is one responsibility, validating options using classes is another, and configuring options from multiple independent sources is one more.
On the other hand, I find data annotations validation to mix two responsibilities in the options class, bending this principle. If you like data annotations, I don’t want to stop you from using them.
Data annotations can seem to improve development speed but make testing validation rules harder. For example, testing a Validate method that returns a ValidateOptionsResult object is easier than attributes.
- O: The different IOptions*<Toptions> interfaces break this principle by forcing the consumer to decide what lifetime and capabilities the options should have. To change the lifetime of a dependency, we must update the consuming class when using those interfaces. On the other hand, we explored an easy and flexible workaround that allows us to bypass this issue for many scenarios and inject the options directly, inverting the dependency flow again, leading to open/closed consumers.
- L: N/A
- I: The IValidateOptions<TOptions> and IConfigureOptions<TOptions> interfaces are two good examples of segregating a system into smaller interfaces where each has a single purpose.
- D: The options framework is built around interfaces, allowing us to depend on abstractions.
Again, the IOptions*<Toptions> interfaces are the exceptions to this. Even if they are interfaces, they tie us to implementation details like the options lifetime. In this case, I think it is more beneficial to inject the options object directly (a data contract) instead of those interfaces.
Next, we explore .NET logging, which is another very important aspect of building applications; good traceability can make all the difference when observing or debugging applications.