Building a NuGet Packages Architecture Part 5 – Service Registration Methods
This is part 5 in a series of 5 articles about building a software architecture based on NuGet packages and feeds. In this series you will see:
- How to refactor your codebase to make it work with packages
- How to build packages on your local machine
- How to build packages using an Azure build pipeline and add the generated packages to a feed
- How to consume your packages in your applications
- Different ways to improve usage of your packages and types with service registration methods
- Various tips and tricks to enhance your package and how to use them
You'll find all the articles in the series here:
In this part you see different ways to add service registration methods to your packages, making consuming and configuring your types simple and straightforward.
If you're interested in purchasing the entire article series now as a 60+ page PDF document, check out this post: Introducing a new article series: Building a NuGet packages architecture.
Introduction
In the initial code (that you can find in the Before folder in the GitHub repository for this article) I had some code in extension methods that registers services with the dependency injection system. Here's an example that registers the DropMailOnLocalDiskMailSender and specifies the folder on disk where the emails should be saved:
public static IServiceCollection AddDropLocalSmtpServer( this IServiceCollection services, string folder) { services.AddSingleton<IMailSender, DropMailOnLocalDiskMailSender>( x => new DropMailOnLocalDiskMailSender(folder)); return services; }
Then in Program.cs or Startup.cs you call it like this:
builder.Services.AddDropLocalSmtpServer(builder.Configuration["MailFolder"]);
Now that all the code for these applications is in a package, having good service registration methods is even more important as it'll help consumers of your packages to easily discover how to use and configure your code. Instead of knowing how to manage your type and whether to add it as a Singleton, or in transient scope and so on, all they need to do is call a single method and specify some configuration data.
For more information about dependency injection with ASP.NET Core and the IServiceCollection interface, check out these links:
- https://docs.microsoft.com/en-us/dotnet/core/extensions/dependency-injection-usage
- https://docs.microsoft.com/en-us/dotnet/core/extensions/dependency-injection
- https://www.stevejgordon.co.uk/aspnet-core-dependency-injection-what-is-the-iservicecollection
When writing your service registration methods, there are a few things to consider:
Which namespace to use
A common practice is to use the same namespace as IServiceCollection which is Microsoft.Extensions.DependencyInjection. That way your users will see your types show up automatically when bringing up IntelliSense on the services collection without having to add your namespaces first:
On Microsoft's web site, you'll find contradictory advice on this. The article https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection, says:
"We recommended that apps follow the naming convention of creating extension methods in the Microsoft.Extensions.DependencyInjection namespace."
However, the article https://docs.microsoft.com/en-us/dotnet/core/extensions/options-library-authors says:
"DO NOT use the Microsoft.Extensions.DependencyInjection namespace for non-official Microsoft packages."
I would normally agree with the recommendation of not using someone else's namespaces but this case I think it's fine.
How to handle configuration
With the .NET dependency framework, you have a number of options to register your types with the necessary configuration. For example:
- A method that accepts one or more primitive types.
- A method that accepts a class with configuration data.
- A method that accepts an IConfiguration which is registered with the dependency injection framework so it can be retrieved later through an instance of an IOptions<T>, an IOptionsSnapshot<T> or an IOptionsMonitor<T> where T is a custom class to handle configuration that can optionally be refreshed when the underlying source changes.
- A method that accepts an IConfiguration instance which is used to populate a class with read-only (or read-write properties).
- A method that accepts an Action<T> where T is a custom configuration class to allow a developer to configure your types in code.
I'll discuss each option in the next sections. Note: in order to extend the IServiceCollection class, you will always need to install the package Microsoft.Extensions.DependencyInjection.Abstractions. Then depending on how you want to register your types, you need to install additional packages which I'll highlight bewlow.
A method that accepts one or more primitive types
This is simple to implement as all it needs are one or more parameters on the service registration method. There's an example in the FileSystemFileProvider project that looks as the code below. Notice how AddFileSystemFileProvider takes in the root folder that it uses to locate and store the files.
public static IServiceCollection AddFileSystemFileProvider( this IServiceCollection services, string rootFolder) { services.AddSingleton<IFileProvider, FileSystemFileProvider>( x => new FileSystemFileProvider(rootFolder)); return services; }
In Startup.cs / Program.cs, you then register the type as follows:
builder.Services.AddFileSystemFileProvider(builder.Configuration["RootFolder"]);
The simplicity comes at a cost, though. Firstly, this code instantiates the instance of FileSystemFileProvider directly, so you can't use the dependency system to inject other dependencies such as a logger. Secondly, since the configuration data (the rootFolder in this case) is determined at startup and injected in the singleton, you can't update it when the configuration data changes. For a provider like the FileSystemFileProvider, that's not an issue though.
A method that accepts a class with a number of configuration settings
This is very similar to the preceding example (with the same pros and cons), except that the service registration method now accepts a class rather than a primitive type. Here's a quick example from the Spaanjaars.FileProviders.Azure project that uses a class called AzureStorageConfiguration that contains a single property called ConnectionString. In a real-world app, you could extend this class with additional properties to further configure the AzureStorageFileProvider.
public static IServiceCollection AddAzureStorageFileProvider ( this IServiceCollection services, AzureStorageConfiguration configuration) { services.AddSingleton<IFileProvider, AzureStorageFileProvider>( x => new AzureStorageFileProvider(configuration.ConnectionString)); return services; }
In Startup.cs / Program.cs, you then register the type as follows:
builder.Services.AddAzureStorageFileProvider(new AzureStorageConfiguration(...));
Instead of using the configuration class in the extension method, you could also forward it to the constructor of AzureStorageFileProvider, further encapsulating the class's behavior.
A method that accepts an IConfiguration instance and uses Configure to make that class available to other types
This is probably my favorite method as it hides the complexity of binding the configuration data in your extension method while still leveraging the full dependency injection options. Your extension metods receive an instance of IConfiguration. You then call Configure, specify the type of your configuration class and then pass the IConfiguration instance to the DI system, Then in your type, you accept one of the following where T is the type of your configuration class:
IOptions<T> - Provides access to the configuration data as it is at startup. Fast and simple, but doesn't allow you to see updates of the data. This is great for many scenarios, including registering singletons that don't need updated configuration data.
IOptionsSnapshot<T> - Registered as scoped, this gives you data that is computed on every request. Since it's registered as scoped, you can't use it in singleton types.
IOptionsMonitor<T> - Allows you to read the configuration data and be notified whenever the underlying data changes. So, for example, when you change a config setting in appsettings.json, your code can pick it up immediately. I have implemented this last option in the DropMailOnLocalDiskMailSender class so it's notified whenever the configured folder on disk is changed.
For an interesting read about singletons, scoped and transient registrations, check out this article: https://dotnetcoretutorials.com/2018/03/20/cannot-consume-scoped-service-from-singleton-a-lesson-in-asp-net-core-di-scopes/.
Here's how I implemented the IOptionsMonitor<T>:
- First, I created a class called MailOnDiskConfiguration that has a single read-write property called MailFolder:
public class MailOnDiskConfiguration { public string MailFolder { get; set; } }
Your properties need to have a getter and a setter or the binding will fail. A later example shows how to use private setters as well.
- I installed the package Microsoft.Extensions.Options.ConfigurationExtensions. This contains an overload of Configure that accepts an instance of IConfiguration.
- I created an extension method as follows to register my type:
public static IServiceCollection AddEmailToLocalDisk( this IServiceCollection services, IConfiguration configuration) { services.Configure<MailOnDiskConfiguration>(configuration); services.AddSingleton<IMailSender, DropMailOnLocalDiskMailSender>(); return services; }
Note how I am not newing up my DropMailOnLocalDiskMailSender type. Instead, I just tell the DI framework which implementation of IMailSender to use. I also tell it to configure an instance of MailOnDiskConfiguration with the given configuration object. This is fairly standard configuration logic you would also write in a standard .NET application directly. Where this gets interesting is with the IOptionsMonitor in the class's constructor:
public DropMailOnLocalDiskMailSender( IOptionsMonitor<MailOnDiskConfiguration> optionsDelegate) { EnsureFolder(optionsDelegate.CurrentValue.MailFolder); optionsDelegate.OnChange(x => EnsureFolder(x.MailFolder)); } private static void EnsureFolder(string folder) { _rootFolder = folder; Directory.CreateDirectory(_rootFolder); }
This constructor receives an instance of IOptionsMonitor<MailOnDiskConfiguration> which it uses to be notified of changes to the configuration folder. EnsureFolder then stores the folder in a private field called _rootFolder and calls CreateDirectory to ensure the folder exists (this method is a no-op when the folder already exists so is safe to call even when the folder exists). The field _rootFolder is then used by the SendMessageAsync method to determine where to store the files.
- The final step is registering the type at startup which is pretty straightforward now:
builder.Services.AddEmailToLocalDisk(
builder.Configuration.GetSection("MailOnDiskConfiguration"));This reads the config setting MailOnDiskConfiguration that could look like this:
"MailOnDiskConfiguration": {
"MailFolder": "C:\\SomeEmailFolder"
}With this code, the MailFolder will be C:\SomeEmailFolder initially. Then when you change the configuration file and save the changes, OnChange is fired which then calls EnsureFolder which updates _rootFolder with the new value and ensures that the new folder exists.
For more information about the Options pattern, check out the following links:
- https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options
- https://referbruv.com/blog/posts/working-with-options-pattern-in-aspnet-core-the-complete-guide
- https://andrewlock.net/how-to-use-the-ioptions-pattern-for-configuration-in-asp-net-core-rc2/
A method that accepts an IConfiguration instance and manually binds to a configuration with read-only properties
Sometimes it's helpful to have configuration classes with private setters to prevent further (accidental) modifications. You can do that by calling Bind and passing it an instance of your class and specify that you want non-public properties to be bound as well. Here's an example where I use Bind in the service registration extension method, but the same works inside your Startup or Program class. Note that in this example I don't register the type with the Configure method and neither do I use an IOptions<T> in the InternalMailSender's constructor. Instead, I new up an instance of that class directly. I have to do this as I want to specify the type of IMailSender that it uses internally specifically:
public static IServiceCollection AddInternalMailSender( this IServiceCollection services, IConfigurationSection configuration, IMailSender mailSender) { var mailConfig = new InternalMailConfiguration(); configuration.Bind(mailConfig, options => options.BindNonPublicProperties = true); services.AddSingleton<IMailSender, InternalMailSender>( x => new InternalMailSender(mailConfig, mailSender)); return services; }
Which you can call like this:
var mailConfig = new SendGridSettings(); builder.Configuration.GetSection("SendGridSettings").Bind(mailConfig, options => options.BindNonPublicProperties = true); IOptions<SendGridSettings> appSettingsOptions = Options.Create(mailConfig); builder.Services.AddInternalMailSender(builder.Configuration.GetSection( "MailOnDiskConfiguration"), new SendGridMailSender(appSettingsOptions));
Since the SendGridMailSender expects an IOptions<T>, I have to jump through some hoops and use the static Create method on the Options class to wrap my settings in an IOptions<T>. The Create method is often used for unit tests and it feels a little awkward to use it here. If you find yourself in the situation where you need to create instances of your types in service registration methods or in calls to them, consider adding an overloaded constructor that simply accepts the configuration class itself, like so:
public SendGridMailSender(IOptions<SendGridSettings> sendGridSettings) : this(sendGridSettings.Value) { } public SendGridMailSender(SendGridSettings sendGridSettings) { _apiKey = sendGridSettings.ApiKey; }
which simplifies the configuration to this:
var mailConfig = new SendGridSettings(); builder.Configuration.GetSection("SendGridSettings").Bind(mailConfig, options => options.BindNonPublicProperties = true); builder.Services.AddInternalMailSender(builder.Configuration.GetSection( "MailOnDiskConfiguration"), new SendGridMailSender(appSettingsOptions));
Note the call to Bind that specifies BindNonPublicProperties. With that option you can bind to read-only properties with a private setter (or with init in more recent versions of C#). This allows you to create immutable classes with read-only properties.
If you want to take this a step further, you can write code that builds the instance using reflection, allowing you to take in all values for the settings class in a constructor, rather than in its properties directly. This is not supported out of the box in .NET, but you can find NuGet packages and source code as a starting point here:
- https://github.com/MartinJohns/ConfigurationContrib
- https://www.nuget.org/packages/Microsoft.Extensions.Configuration.ImmutableBinder/
- https://www.nuget.org/packages/DevTrends.ConfigurationExtensions/
A method that accepts an Action<T> where T is a custom configuration class to allow a developer to configure your types in code.
The final method I like to discuss is the option of adding an Action<T> to your service registration so your calling code can populate your settings class programmatically. Here's an example (that you can find in the SendGrid project):
public static IServiceCollection AddSendGrid(this IServiceCollection services, Action<SendGridSettings> settings) { var instance = new SendGridSettings(); settings(instance); services.AddSingleton<IMailSender, SendGridMailSender>( x => new SendGridMailSender(instance.ApiKey)); return services; }
This code creates an instance of the settings class and then passes it to the Action which allows calling code to configure it:
builder.Services.AddSendGrid(o => o.ApiKey = builder.Configuration["SendGridApiKey"])
This solution could be useful if you want to give consumers of your packages programmatic control over the configuration.
Validation
The configuration data often needs to be in a very specific format, with validation rules applied to the various properties. Consider for example this configuration class:
public class HttpClientSettings
{
public string RootUrl { get; private set; } public int Timeout { get; private set; } }
When you inject these options in, say, a class that makes HTTP requests, you want to make sure it's in a valid state. For example, the RootUrl should be required and the Timeout should be a positive integer with a max value of 90. While you could validate these settings in every class that uses them, it's much better to validate it at startup and fail fast when the configuration data doesn't match your requirements. Here's how to do it:
- Install the following two packages into your package project (or consuming application depending on how you add your options):
Microsoft.Extensions.Hosting
Microsoft.Extensions.Options.DataAnnotations - Add validation rules to your class. You can use the attributes in the System.ComponentModel.DataAnnotations namespace. For example, the following code makes the RootUrl required and forces the timeout to be between 0 and 90 (inclusive):
public class HttpClientSettings
{ [Required]
public string RootUrl { get; private set; } [Range(0,90)] public int Timeout { get; private set; } }
public class HttpClientSettings : IValidatableObject
{
[Required]
public string RootUrl { get; private set; } [Range(0,90)]
public int Timeout { get; private set; } public IEnumerable<ValidationResult> Validate( ValidationContext validationContext)
{
if (RootUrl.StartsWith("https", StringComparison.CurrentCultureIgnoreCase) && Timeout > 10)
{
yield return new ValidationResult("Timeout cannot be greater than 10 when using an https URL."); }
}
} - Next, when registering your configuration data, call ValidateDataAnnotations() and ValidateOnStart() like this:
builder.Services.AddOptions<HttpClientSettings>().Bind(
builder.Configuration.GetSection("HttpClientSettings"), o => o.BindNonPublicProperties = true ).ValidateDataAnnotations().ValidateOnStart();This uses yet another method to add your configuration data (calling AddOptions) but the end result is the same: your configuration data is read and stored in a type called HttpClientSettings which can then be injected in classes that expect an IOptions<HttpClientSettings> or one of the other IOption* types discussed earlier.
What's different this time is that now the data in the HttpClientSettings type is checked according to your validation rules before it's registered with the DI container. If, for example, you would configure your application as follows:
"HttpClientSettings": {
"RootUrl": "https://example.com",
"Timeout": 30
}your application won't start up because of the validation rule in the Validate method that requires the Timeout to be less than or equal to 10 when using an https URL. You'll see the following exception appear telling you exactly what the issue is:
DataAnnotation validation failed for 'HttpClientSettings' members: '' with the error: 'Timeout cannot be greater than 10 when using an https URL.'.
This will help consumers of your packages catch and fix configuration errors early on in the process.
Wrapping up
In this article you saw a variety of ways to implement service registration methods to register your types and their configuration data with the .NET dependency injection system. I personally favor IOptions<T> (or a descendant) and then let the DI framework handle instantiation of your types. This is often the easiest way for developers to register and use your types. ended the article with a quick look at validating your configuration data to catch and fix errors as soon as possible.
In the next and final article in this series, I'll discuss a few options to enhance your packages. I'll show you how to add more metadata and an icon to your package so it stands out more. I'll close off the article with a few miscellaneous topics like XML documentation, Unit Tests and debugging tips.
Source code
You find the full source code for these articles in the following Github repository: https://github.com/Imar/Articles.NuGetPackagesDemo.
Where to Next?
Wonder where to go next? You can read existing comments below or you can post a comment yourself on this article.
Links in this Document
Doc ID | 632 |
Full URL | https://imar.spaanjaars.com/632/building-a-nuget-packages-architecture-part-5-service-registration-methods |
Short cut | https://imar.spaanjaars.com/632/ |
Written by | Imar Spaanjaars |
Date Posted | 03/20/2022 14:38 |
Talk Back! Comment on Imar.Spaanjaars.Com
I am interested in what you have to say about this article. Feel free to post any comments, remarks or questions you may have about this article. The Talk Back feature is not meant for technical questions that are not directly related to this article. So, a post like "Hey, can you tell me how I can upload files to a MySQL database in PHP?" is likely to be removed. Also spam and unrealistic job offers will be deleted immediately.
When you post a comment, you have to provide your name and the comment. Your e-mail address is optional and you only need to provide it if you want me to contact you. It will not be displayed along with your comment. I got sick and tired of the comment spam I was receiving, so I have protected this page with a simple calculation exercise. This means that if you want to leave a comment, you'll need to complete the calculation before you hit the Post Comment button.
If you want to object to a comment made by another visitor, be sure to contact me and I'll look into it ASAP. Don't forget to mention the page link, or the Doc ID of the document.