Updated on

A configuration provider is the piece of ASP.NET Core that turns one source of settings (a JSON file, environment variables, a database) into flat key-value pairs the rest of the app reads through IConfiguration. A custom one is two small classes: an IConfigurationSource that says how to build it, and a ConfigurationProvider subclass that fills a dictionary in Load().

Everything else follows from that dictionary. Whatever we put in it becomes configuration, so the same IOptions<T> binding, the same GetSection() calls, and the same override order work unchanged. The app never learns where the values came from.

The previous part covered the built-in configuration providers and the order they run in. This one adds a source of our own, backed by Entity Framework Core and a SQL Server database.

To download the source code for this article, you can visit the CustomConfigurationProvider folder in our GitHub repository. The source code for the whole series is here.

First, let’s upgrade our solution to support EF Core using the database-first approach.

What Is a Custom Configuration Provider in ASP.NET Core?

A custom configuration provider is a class that loads settings from a source ASP.NET Core does not support out of the box, and hands them to IConfiguration as flat key-value pairs.

Two types are involved. An IConfigurationSource is the recipe: the host holds a list of sources and asks each one to build a provider. The ConfigurationProvider subclass is the worker, and it does its whole job in Load().

Load() reads the source and assigns the result to the inherited Data dictionary. That dictionary is the provider’s entire contract with the framework. Reading, section binding and change tracking are the base class’s job.

Keys arrive flattened, with colons separating the levels, so a Pages:HomePage:Color row in a table becomes the same configuration key a nested JSON object would have produced.

Order decides who wins. A source added later overrides the same key from an earlier one, which is how a database sits on top of appsettings.json without either knowing about the other.

How Do We Prepare the Database for Configuration Data?

Before we start, let’s take a moment to clear all the user secrets and environment variables, we’ve set in the previous part. Once that’s finished, let’s proceed.

We need one NuGet package, the SQL Server provider for EF Core:

dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 10.0.11

Next, we need a class that will contain our key-value configuration pairs, in the Models folder:

public class ConfigurationEntity
{
    [Key]
    public string Key { get; set; } = string.Empty;

    public string? Value { get; set; }
}

And a DbContext class in the same folder:

public class ConfigurationDbContext(string? connectionString) : DbContext
{
    private readonly string? _connectionString = connectionString;

    public DbSet<ConfigurationEntity> ConfigurationEntities { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.UseSqlServer(_connectionString);
}

We need just one DbSet of ConfigurationEntity, which maps to our table in the database.

The context takes a connection string and configures itself in OnConfiguring(), and that is deliberate. Our provider runs while configuration is still being assembled, long before the dependency injection container exists, so it cannot be handed a context registered with AddDbContext(). It builds its own instead, which is also why this article never registers ConfigurationDbContext in Program.cs.

Of course, we need to point the connection string in the appsettings.json file at our own database:

"ConnectionStrings": {
  "sqlConnection": "Server=(localdb)\\MSSQLLocalDB;Database=CodeMazeCommerce;Trusted_Connection=True;TrustServerCertificate=True"
},

LocalDB comes with Visual Studio, so on Windows there is nothing else to install. Elsewhere, one container gives us the same thing: docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=<a strong password>" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest, with the connection string pointing at localhost,1433. A plaintext connection string in appsettings.json is fine for a demo and nothing else, and our article on where the connection string itself should live covers the alternatives.

We don’t need migrations here, and we don’t need to create the database by hand either. Our Load() method calls EnsureCreated(), which creates the database and the table on the first run. That is the only option that works, because the provider runs before the application has anything else with which to create them.

How Do We Implement a Custom Configuration Provider?

Implementing a provider means writing two classes and registering one of them.

The provider inherits ConfigurationProvider and overrides Load(). Inside Load() we read the source however we like (a query, a file, an HTTP call) and assign the results to Data, keyed the way configuration keys are written.

The source implements IConfigurationSource, whose single Build() method returns a new provider. It exists so the host can hold a lightweight description of the source and defer construction until the configuration root is assembled.

Registration is the third piece, and it is where custom providers usually go wrong. builder.Configuration is a ConfigurationManager, which implements IConfigurationBuilder explicitly, so Add() is not visible on it until we assign it to an IConfigurationBuilder variable first.

Adding the source also loads it immediately. ConfigurationManager updates its view as each source arrives, so values from the database are readable on the very next line rather than after the host is built.

Let’s create a ConfigurationProviders folder inside our Models folder to group these two classes, and start with the provider itself:

public class EFConfigurationProvider(string? connectionString) : ConfigurationProvider
{
    private readonly string? _connectionString = connectionString;

    public override void Load()
    {
        using var dbContext = new ConfigurationDbContext(_connectionString);

        dbContext.Database.EnsureCreated();

        Data = dbContext.ConfigurationEntities.Any()
            ? dbContext.ConfigurationEntities.ToDictionary(c => c.Key, c => c.Value, StringComparer.OrdinalIgnoreCase)
            : CreateAndSaveDefaultValues(dbContext);
    }

    private static Dictionary<string, string?> CreateAndSaveDefaultValues(ConfigurationDbContext dbContext)
    {
        var configValues = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
        {
            { "Pages:HomePage:WelcomeMessage", "Welcome to the ProjectConfigurationDemo Home Page" },
            { "Pages:HomePage:ShowWelcomeMessage", "true" },
            { "Pages:HomePage:Color", "black" },
            { "Pages:HomePage:UseRandomTitleColor", "true" }
        };

        dbContext.ConfigurationEntities.AddRange(
            [.. configValues.Select(kvp => new ConfigurationEntity { Key = kvp.Key, Value = kvp.Value })]);

        dbContext.SaveChanges();

        return configValues;
    }
}

The class does one job. Load() creates a context, makes sure the database is there, and then either reads the rows into Data or seeds four default values and returns those.

Both branches pass StringComparer.OrdinalIgnoreCase, and that is not decoration. Configuration keys are case-insensitive, and ConfigurationProvider serves every lookup straight out of Data, so the dictionary’s comparer is the lookup’s case sensitivity. A seeding branch that ignores case and a reading branch that does not gives us the worst failure shape there is: the demo passes on the first run, and Configuration["pages:homepage:color"] silently returns null on every run after.

Next comes the source, in the same folder:

public class EFConfigurationSource(string? connectionString) : IConfigurationSource
{
    private readonly string? _connectionString = connectionString;

    public IConfigurationProvider Build(IConfigurationBuilder builder)
        => new EFConfigurationProvider(_connectionString);
}

We only implement Build(), which hands the connection string on to a new provider. The whole class is a description of a source that has not been read yet, which is why it is so small.

The two classes together touch eight members of the framework, and it is worth seeing which ones actually need our code:

MemberDeclared onWhat we writeWhen ASP.NET Core calls it
Build(IConfigurationBuilder)IConfigurationSourceReturn a new instance of our providerOnce, when the configuration root is built
Load()ConfigurationProviderRead the source and assign DataOnce at startup, and again on every Reload()
DataConfigurationProviderThe key-value pairs, as IDictionary<string, string?>Read on every configuration lookup
TryGet(string, out string?)ConfigurationProviderNothing, the base reads DataOn every lookup for a single key
Set(string, string?)ConfigurationProviderNothing, the base writes DataWhen code assigns Configuration["key"]
GetChildKeys(IEnumerable<string>, string?)ConfigurationProviderNothing, the base derives them from DataWhen binding a section or enumerating children
GetReloadToken()ConfigurationProviderNothing, the base returns its own tokenWhen something watches for configuration changes
OnReload()ConfigurationProviderCall it after re-reading, if the source can changeNever automatically, we raise it

Only the first three rows need our code; the base class derives the rest from Data.

Now let’s register the source. This is the step that trips people up, because writing builder.Configuration.Add(new EFConfigurationSource(connectionString)) does not compile, and the compiler’s suggestion points somewhere unrelated:

error CS1929: 'ConfigurationManager' does not contain a definition for 'Add' and the best
extension method overload 'ApplicationModelConventionExtensions.Add(IList<IApplicationModelConvention>,
IControllerModelConvention)' requires a receiver of type
'System.Collections.Generic.IList<Microsoft.AspNetCore.Mvc.ApplicationModels.IApplicationModelConvention>'

The fix is the one-line assignment to an IConfigurationBuilder variable, and it reads best as an extension method on ConfigurationManager, in the Models folder:

public static class EntityConfigurationExtensions
{
    public static ConfigurationManager AddEntityConfiguration(this ConfigurationManager manager)
    {
        var connectionString = manager.GetConnectionString("sqlConnection");

        IConfigurationBuilder configBuilder = manager;
        configBuilder.Add(new EFConfigurationSource(connectionString));

        return manager;
    }
}

manager.GetConnectionString("sqlConnection") works on the first line because the JSON sources are already loaded by the time we get here. There is no need to build an interim configuration root to read our own connection string, which is what this sample used to do and what reading configuration during startup explains in more detail.

That leaves one line in Program.cs:

builder.Configuration.AddEntityConfiguration();

Finally, let’s clean up the appsettings.json file a bit:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "ConnectionStrings": {
    "sqlConnection": "Server=(localdb)\\MSSQLLocalDB;Database=CodeMazeCommerce;Trusted_Connection=True;TrustServerCertificate=True"
  },
  "AllowedHosts": "*"
}

We’ve removed the “Pages” section to make sure it’s being read from the database. Those values still reach the same options class as before, because binding these values to a strongly typed class happens above the provider and does not care which one supplied them.

Running the Application

Before running the application, let’s ask the configuration where its values come from, with one temporary line in Program.cs above builder.Build():

Console.WriteLine(builder.Configuration.GetDebugView());

GetDebugView() prints every configuration key, its value, and the provider that supplied it. It prints the whole set, environment variables included, so it belongs in a scratch line rather than in a log, and this is the part of its output that matters to us:

Pages:
  HomePage:
    Color=black (EFConfigurationProvider)
    ShowWelcomeMessage=true (EFConfigurationProvider)
    UseRandomTitleColor=true (EFConfigurationProvider)
    WelcomeMessage=Welcome to the ProjectConfigurationDemo Home Page (EFConfigurationProvider)

Four keys, all of them served by our own provider, and no appsettings.json anywhere in sight for that section.

If we inspect the database, we’ll see it’s populated:

database

And the application still works exactly as it did before:

Home Page green

You can refresh the page a few times to make sure the color of the title still changes. The second run is the interesting one: the database is seeded by then, so the reading branch serves every value, which is exactly where a case-sensitive dictionary would have quietly broken things.

When Should We Write a Custom Configuration Provider?

Rarely, and the honest answer is that most reasons to want one are better served by something else.

A custom provider earns its place when configuration genuinely lives somewhere the built-in providers cannot reach and the whole app should read it through IConfiguration without knowing that. A settings table an operations team edits is the standard case.

It is the wrong tool when values change while the app runs. Load() fires at startup; nothing polls the database afterwards, so a provider built this way serves a snapshot until the process restarts or something calls Reload().

It is also the wrong tool for secrets. A provider that reads a connection string still needs a connection string to reach its own database, and that one comes from somewhere else, which is the problem the next part of this series solves.

For per-user or per-request data, use a service and a repository. Configuration is application-wide state, and modelling anything narrower as configuration fights the abstraction.

Configuration is one of the first things that gets complicated on a real project, and the Ultimate ASP.NET Core Web API course builds the whole application this series samples, wiring included.

Conclusion

In this article, we’ve seen what a custom configuration provider is and how to implement one that reads its values from a database. Two classes and one registration line are the whole mechanism, and the base class does everything else on top of the Data dictionary we fill in Load().

In the next part, we’re going to learn how to protect our sensitive configuration values, starting with the connection string this provider needs before it can read anything at all.

You can find other parts of this series on the ASP.NET Core Web API page.

Tested with .NET 10.0.10 and EF Core 10.0.11.