Configuration Provider in .NET Based on Background Service
Connecting ASP .NET Core Configuration System with Dependency Injection enabled
IHostedService
.NET provides a versatile configuration framework, allowing us to implement a custom configuration provider. Most of the time, the provider's goal is to read configuration data from some source in the background. In the meantime, ASP .NET Core provides us with a simple way to enable a DI-based background process by injecting an IHostedService
into our DI container. However, there is no built-in way to connect those two. Gladly, there is a (slightly tricky) way to do it - let me show you!
Or jump straight to the TLDR for spoilers 😉
Create a Bridge Between DI-Container and Configuration
First thing first, let's create our project. We'll use the web
template to have all the required dependencies, like Microsoft.Extensions.Configuration
in place by default:
dotnet new web
Now, let's move to the most important question of this article: How do we connect data from objects inside a dependency injection container to the configuration system that does not have access to it? The answer is using the good old Singleton.
Since we can only have one singleton in an application we can use the singleton as a data container to share the data between the DI-system and the rest of the app. Here's how we can implement it on a very basic level:
public class ConfigurationStore
{
public static ConfigurationStore Instance { get; } = new();
private Dictionary<string, object> data = new();
private ConfigurationStore(){}
}
We will also want the data "consumers" to know about the data changes as soon as they arrive, so let's spin up a little listening system:
private List<Action<Dictionary<string, object>>> listeners = new();
public void AddListener(Action<Dictionary<string, object>> listener)
{
listeners.Add(listener);
listener(data);
}
public void NotifyListeners()
{
foreach (var listener in listeners)
{
listener(data);
}
}
Finally, let's implement methods for writing and reading the data. Of course, we'll also NotifyListeners
whenever the data is being updated. Here's the code:
public void SetValue(string key, object value)
{
data[key] = value;
NotifyListeners();
}
public void SetAll(IReadOnlyDictionary<string, object> newData)
{
data = newData.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
NotifyListeners();
}
public T GetValue<T>(string key) => (T)data[key];
public T? GetValueOrDefault<T>(string key) {
if (data.TryGetValue(key, out object? value)) {
return (T)value;
}
return default;
}
Now, with the bridge implemented let's first use it in a configuration provider!
Implementing our Background Configuration Provider
I've described the process of making custom configuration providers in the dedicated article. Basically, all we need to do is to call the Load
method whenever we detect changes. Here our listening system comes in handy - we'll subscribe to the changes in the constructor and call the Load
method from an update handler.
The Load
method doesn't accept any parameters, so to access the updated data we would first need to capture it in a private field _rawData
. Also, since in our case calling the Load
method basically means that data has been changed we should also call OnReload
at the end to notify the configuration system about the changes. Here's the complete code:
The classes are expected to be nested inside the
ConfigurationStore
class, hence the short name. You can find the full class code at the end of the article.
public class Source(ConfigurationStore store) : IConfigurationSource
{
public IConfigurationProvider Build(IConfigurationBuilder builder) => new Provider(store);
}
public class Provider : ConfigurationProvider
{
private Dictionary<string, object> _rawData = new();
public Provider(ConfigurationStore store)
{
store.AddListener(data => {
_rawData = data;
Load();
});
}
public override void Load()
{
Data = _rawData.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToString());
OnReload();
}
}
Now, you should be able to utilize the ConfigurationStore
for updating the actual configuration. Let me show you how:
Assembling a Working System
We'll do a very simple IHostedService
, using our ConfigurationStore
to update the Counter
value. Here's what it might look like:
public class Counting
{
public const string Key = "Counter";
public class BackgroundService(ILogger<BackgroundService> logger, ConfigurationStore store) : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken)
{
var currentValue = store.GetValueOrDefault<int>(Key);
logger.LogInformation("Incrementing counter. Current value: {currentValue}", currentValue);
store.SetValue(Key, currentValue + 1);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
}
Of course, with the implementation, the incrementation will happen only once, which is not something we wish for. Instead, we should increment
I have a dedicated article about implementing safe timer functionality. Long story short, we'll need the Backi.Timers
nuget package:
dotnet add package Backi.Timers
Now, let's integrate the SafeTimer
into our background service. Here's what our code should look like:
using Backi.Timers;
public class Counting
{
public const string Key = "Counter";
public class BackgroundService(ILogger<BackgroundService> logger, ConfigurationStore store) : IHostedService
{
private SafeTimer _timer = null!;
public Task StartAsync(CancellationToken cancellationToken)
{
logger.LogInformation("Starting counting timer");
_timer = SafeTimer.RunNowAndPeriodically(
TimeSpan.FromSeconds(1),
() => {
var currentValue = store.GetValueOrDefault<int>(Key);
logger.LogInformation("Incrementing counter. Current value: {currentValue}", currentValue);
store.SetValue(Key, currentValue + 1);
},
(ex) => logger.LogError(ex, "Error in counting timer")
);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
logger.LogInformation("Stopping counting timer");
_timer.Stop();
return Task.CompletedTask;
}
}
}
Finally, let's register all the things we've built app in this article. We'll need to add our configuration source, add our singleton to the DI container, and register our background service there as well:
((IConfigurationBuilder)builder.Configuration).Add(new ConfigurationStore.Source(ConfigurationStore.Instance));
builder.Services.AddSingleton(ConfigurationStore.Instance);
builder.Services.AddHostedService<Counting.BackgroundService>();
Let's also slightly update logging to get a more compact view:
builder.Logging.AddSimpleConsole(c => c.SingleLine = true);
Here's what we should get after starting our app via dotnet run
Let's also check that we will be able to get the configuration value from Microsoft's IConfiguration
object. Let's first install a package, providing the GetRequiredValue
extension method:
dotnet add package Confi
Next, we'll add an endpoint:
using Confi;
// ...
app.MapGet("/counter", (IConfiguration config) => config.GetRequiredValue(Counting.Key));
Calling that endpoint via curl localhost:5057/counter
we should get the current count, in my case, it was 16
. That wraps up our little journey, let's do a quick recap in the final section.
TLDR;
We've found a way to connect IHostedService
with the Configuration system using the good old Singleton. Here's a complete code of the underlying store, allowing passing configuration data between services:
public class ConfigurationStore
{
public static ConfigurationStore Instance { get; } = new();
private Dictionary<string, object> data = new();
private ConfigurationStore(){}
private List<Action<Dictionary<string, object>>> listeners = new();
public void AddListener(Action<Dictionary<string, object>> listener)
{
listeners.Add(listener);
listener(data);
}
public void NotifyListeners()
{
foreach (var listener in listeners)
{
listener(data);
}
}
public void SetValue(string key, object value)
{
data[key] = value;
NotifyListeners();
}
public void SetAll(IReadOnlyDictionary<string, object> newData)
{
data = newData.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
NotifyListeners();
}
public T GetValue<T>(string key) => (T)data[key];
public T? GetValueOrDefault<T>(string key) {
if (data.TryGetValue(key, out object? value)) {
return (T)value;
}
return default;
}
public class Source(ConfigurationStore store) : IConfigurationSource
{
public IConfigurationProvider Build(IConfigurationBuilder builder) => new Provider(store);
}
public class Provider : ConfigurationProvider
{
private Dictionary<string, object> _rawData = new();
public Provider(ConfigurationStore store)
{
store.AddListener(data => {
_rawData = data;
Load();
});
}
public override void Load()
{
Data = _rawData.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToString());
OnReload();
}
}
}
We've used the object in our Counting.BackgroundService
to showcase the functionality. There are still some improvements to make, but I don't want to overflow this article, so maybe next time.
The source code for this article can be found here on github. This article, as well as the underlying library, is part of the project, called Confi - don't hesitate to give it a star! ⭐
And also … claps are appreciated! 👏