Implement a Custom Configuration Provider in .NET 7

Goker Akce
5 min readJan 10, 2023

--

If you want to control some of your configuration values from the database you may find this article interesting.

Hi people! In this article, we will create a custom configuration provider with reload functionality. Please read the scenario below to get the context about what we will be doing as an example;

Scenario: There is a web API project and we want to activate or deactivate some features in runtime. So we decided to control the related configuration values through our database. With that approach we will be able decide whether the feature is active based on a configuration value from the database.

Now, if you wonder how to implement this scenario you are in the right place, let’s get started!

This example will include:
- Mssql server to keep the config values (in the StarfishSettings table)
- EntityFramework Core 7

First things first, if you are not familiar with configuration providers, here is an explanation;

Basic explanation of configuration providers:
Configuration providers allow developers to retrieve configuration data from various sources, such as JSON files, environment variables, and Azure Key Vault. These configuration sources are abstracted behind a unified API.

To create a custom configuration provider, we need a ConfigurationSource and a ConfigurationProvider.

Let's create the configuration source first.

SqlServerConfigurationSource.cs

//SqlServerConfigurationSource.cs

using Microsoft.EntityFrameworkCore;

namespace Starfish.Web.Configuration;

public class SqlServerConfigurationSource : IConfigurationSource
{
public required Action<DbContextOptionsBuilder> OptionsAction { get; init; }

public bool ReloadPeriodically { get; init; }

public int PeriodInSeconds { get; init; } = 5;

public IConfigurationProvider Build(IConfigurationBuilder builder) =>
new SqlServerConfigurationProvider(this);
}

The IConfigurationSource interface resides in Microsoft.Extensions.Configuration namespace

  • OptionsAction will be used to create a DbContext to connect our database.
  • ReloadPeriodically defines whether we need to reload the values from the database periodically.
  • PeriodInSeconds is an optional property and the default is 5. It means configuration values will be reloaded every 5 seconds.
  • Build is the method that comes from the IConfigurationSource interface and will return our configuration provider.

Now, we can create the configuration provider. It will be responsible for loading/reloading configuration values and also saving default values.

SqlServerConfigurationProvider.cs

//SqlServerConfigurationProvider.cs

using Microsoft.EntityFrameworkCore;
using Starfish.Infrastructure.Data;
using Starfish.Shared;

namespace Starfish.Web.Configuration;

public class SqlServerConfigurationProvider : ConfigurationProvider, IDisposable
{
private SqlServerConfigurationSource Source { get; }
private readonly Timer? _timer;

public SqlServerConfigurationProvider(SqlServerConfigurationSource source)
{
Source = source;

if (Source.ReloadPeriodically)
{
_timer = new Timer
(
callback: ReloadSettings,
dueTime: TimeSpan.FromSeconds(10),
period: TimeSpan.FromSeconds(Source.PeriodInSeconds),
state: null
);
}
}

public override void Load()
{
var builder = new DbContextOptionsBuilder<DataContext>();
Source.OptionsAction(builder);

using (var dbContext = new DataContext(builder.Options))
{
dbContext.Database.EnsureCreated();

Data = dbContext.StarfishSettings.Any()
? dbContext.StarfishSettings
.ToDictionary<StarfishSettings, string, string?>(c => c.Id, c => c.Value, StringComparer.OrdinalIgnoreCase)
: CreateAndSaveDefaultValues(dbContext);
}
}

private static IDictionary<string, string?> CreateAndSaveDefaultValues(DataContext context)
{
var settings = new Dictionary<string, string?>(
StringComparer.OrdinalIgnoreCase)
{
[$"{nameof(StarfishOptions)}:{nameof(StarfishOptions.FraudCheckerEnabled)}"] = "false",
[$"{nameof(StarfishOptions)}:{nameof(StarfishOptions.PerformanceMonitorEnabled)}"] = "false"
};

context.StarfishSettings.AddRange(
settings.Select(kvp => new StarfishSettings(kvp.Key, kvp.Value))
.ToArray());

context.SaveChanges();

return settings;
}

private void ReloadSettings(object? state)
{
Load();
OnReload();
}

public void Dispose()
{
_timer?.Dispose();
}
}

ConfigurationProvider class resides in Microsoft.Extensions.Configuration namespace. It implements the IConfigurationProvider interface and our class inherits the ConfigurationProvider class because we want to use the existing functionality and override a method to make it work for us.

Let’s understand what this class does from top to bottom;

  • If ReloadPeriodically is true we set a timer to reload the configuration periodically.
  • We set dueTime as 10 seconds. It will wait 10 seconds to start the timer then every 5 seconds it will call the ReloadSettings() method. I set a due time to avoid concurrency issues at startup.
  • OnReload() method comes from the inherited class which is the ConfigurationProvider. Basically, it propagates that the configuration has been reloaded. It doesn’t matter if there is a change in the values we are just letting know that we reloaded the values so the listeners can use the latest version of the configuration.
  • Load() method creates the DataContext using the DbContextOptionsBuilder and sets the Data with what we have in the StarfishSettings table. If the settings table is empty then the default values will be created (optional).

An implementation detail:
Behind the scene OnReload() method uses change tokens. If you want to learn more about it you can check the Microsoft documentation and have a look at my previous article How to Use Change Tokens in .NET 7?

Attention!
DbContext is not thread-safe so we need to be careful about its usage. Otherwise, we may end up having concurrency issues at startup or runtime.

Last but not least, I’ll share some code snippets from the DataContext class and also show how to add our custom configuration source to the pipeline.

DataContext.cs

// DataContext.cs

using Microsoft.EntityFrameworkCore;
using Starfish.Shared;

namespace Starfish.Infrastructure.Data;

public class DataContext : DbContext
{
public DataContext()
{
}

public DataContext(DbContextOptions<DataContext> options) : base(options)
{
}

...

public DbSet<StarfishSettings> StarfishSettings { get; set; }

}

StarfishSettings.cs

// StarfishSettings.cs
namespace Starfish.Shared;

public record StarfishSettings(string Id, string Value);

Program.cs

// Program.cs

var builder = WebApplication.CreateBuilder(args);

builder.Services
.AddStarfishDatabase(builder.Configuration)
.AddStarfishHostedServices(builder.Environment.IsDevelopment());

// Add Custom Configuration Source
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Configuration.Sources.Add(new SqlServerConfigurationSource
{
OptionsAction = (optionsBuilder) => optionsBuilder.UseSqlServer(connectionString),
ReloadPeriodically = true,
PeriodInSeconds = 5
});

// I added StarfishOptions class as a config
builder.Services.Configure<StarfishOptions>(configuration.GetSection(nameof(StarfishOptions)));



...

We added our custom configuration source to the Sources! From now on the values that we keep in the table will be retrieved and used by the services.

Let’s use the StarfisOptions class as a config object and try to read the FraudCheckerEnabled value from a service.

StarfishOptions.cs

namespace Starfish.Shared;

public class StarfishOptions
{
public bool FraudCheckerEnabled { get; set; }
public bool PerformanceMonitorEnabled { get; set; }
}

BankTransactionService.cs

using Microsoft.Extensions.Options;
using Starfish.Core.Models;
using Starfish.Shared;

namespace Starfish.Core.Services;

public class BankTransactionsService : IBankTransactionsService
{
private readonly IOptionsMonitor<StarfishOptions> _options;

public BankTransactionsService(IOptionsMonitor<StarfishOptions> options)
{
_options = options;
}

...

private bool IsValidTransaction(BankTransaction transaction)
{
if (!_options.CurrentValue.FraudCheckerEnabled)
{
return true;
}

...

}
}

For the sake of the article, I’ve only kept the configuration part of the class. We used IOptionsMonitor to retrieve the current value. Whenever we change the value of FraudCheckerEnabled from our database it will be reflected in max 5 seconds.

How It Looks in Action!

End of the article
I’ve tried to keep this article as concise as possible. There are other ways to implement your custom configuration provider but you can check my way to get some inspiration! Please feel free to add comments and suggestions.

--

--