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.
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?
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:
| Member | Declared on | What we write | When ASP.NET Core calls it |
|---|---|---|---|
Build(IConfigurationBuilder) | IConfigurationSource | Return a new instance of our provider | Once, when the configuration root is built |
Load() | ConfigurationProvider | Read the source and assign Data | Once at startup, and again on every Reload() |
Data | ConfigurationProvider | The key-value pairs, as IDictionary<string, string?> | Read on every configuration lookup |
TryGet(string, out string?) | ConfigurationProvider | Nothing, the base reads Data | On every lookup for a single key |
Set(string, string?) | ConfigurationProvider | Nothing, the base writes Data | When code assigns Configuration["key"] |
GetChildKeys(IEnumerable<string>, string?) | ConfigurationProvider | Nothing, the base derives them from Data | When binding a section or enumerating children |
GetReloadToken() | ConfigurationProvider | Nothing, the base returns its own token | When something watches for configuration changes |
OnReload() | ConfigurationProvider | Call it after re-reading, if the source can change | Never 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:
And the application still works exactly as it did before:
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.



Why remove DbContext?
Hi , when running migration i encounter this error
Unable to create an object of type ‘ConfigurationDbContext’. For the different patterns supported at design time,
Any idea why its occuring ?
Hi, good example. I have a similar case implementing consul with ConsulConfigurationSource : IConfigurationSource and ConsulConfigurationProvider : ConfigurationProvider. In my ConsulConfigurationProvider class I have a Data dictionary property (inherits from ConfigurationProvider). How can I access to Data dictionary in my app from IConfiguration Configuration? I can see it in Providers but I dont know how to access it.
See the screenshots
https://imgbox.com/NIll6h0a
https://imgbox.com/mDIIrYM2
If I try to get Configuration.Providers but does not contain a definition for âProvidersâ, I have tried to GetSection, GetValue and nothing works.. But I only want to get the Data in ConsulConfigurationProvider instance. I suposed that Configuration.GetSection or GetValue<"NameOfInstance"> it was only I need, but I don’t know how get this value.
Can you help me?
If I use repository pattern, should I create different DbContext for ReositoryManager and ConfigurationProvider?
Right now, I registering my RepositoryContext using AddDbContext() inside Startup.ConfigureServices() method.
If I use same DbContext (RepositoryContext), I need still need to registering it in AddDbContext() and instance it using new inside ConfigurationProvider. Is it the appropriate way?
Hey Chaerun,
It depends on your needs. You can keep your configuration data with your other data, or you can configure another db context to separate the configuration from the rest of the application data.
This way you set db context as a configuration provider, but that doesn’t mean you can’t use it for other stuff as well. The way it’s done might be a bit confusing, so I suggest you try creating two separate db contexts and let us know how it goes.
Hi Vladimir,
I decided to use a single DbContext… because, I want to make a Controller to update the configuration from API, then automatically reload it.
Thanks for the advice.