More Than appsettings.json — A Practical Guide to Configuration Management in Windows Business Apps (Per-Environment Settings, Secrets, and Where to Write)
· Go Komura · C#, .NET, appsettings.json, IConfiguration, IOptions, Generic Host, Configuration Management, Windows Development, Technical Consulting
“I want the connection string to change per environment.” “I want to save per-user display settings.” “We’ve ended up writing the API key straight into appsettings.json.” Everyone eventually gets as far as dropping a single appsettings.json file and reading it through IConfiguration when managing configuration for a Windows business app. But beyond that point — per-environment settings, where to put writable settings, handling secrets, changing configuration at runtime — is an area that surprisingly often ends up in production without ever really being sorted out.
This article works through everything from IConfiguration and its provider-layering foundation, per-environment settings, receiving configuration as strongly-typed values via the IOptions pattern, where to put writable settings, handling secrets, the real-world limits of changing configuration at runtime, and finally a migration map from app.config/Settings.settings — organized in the order these decisions actually trip people up in practice.
1. The Bottom Line First
Decisions about configuration management start with deciding on a “location” for each “kind of setting.” Here’s a decision table covering the big picture first.
| Kind of setting | Example | First-choice location | Why |
|---|---|---|---|
| App defaults | Default log level, default UI parameters | appsettings.json |
Shipped with the build output; common values that don’t depend on the environment |
| Per-environment settings | Connection strings and API endpoints that differ between staging and production | appsettings.{Environment}.json + environment variables |
Lets you use the default override order as-is |
| Per-user settings | Last-opened folder, window position, personal display preferences | A custom file under %LOCALAPPDATA% (or %APPDATA%) |
Needs a location each user can write to individually |
| Per-machine settings (shared across all users) | A device’s COM port number, the license server’s address | A custom file under %ProgramData% |
Set once per machine by an administrator and shared across all users |
| Secrets | Connection-string passwords, API keys, tokens | A DPAPI-protected file (production), user-secrets (development only) | Never store in plaintext. Don’t repurpose the dev-only mechanism for production |
| Values that change at runtime | Feature flags, dynamic log level changes | appsettings.json (reloadOnChange) + IOptionsMonitor |
Reserve this for only the values you actually want to apply without a restart |
With that table as a foundation, here’s the bottom line up front.
- Configuration precedence follows one single rule: whichever provider was added later wins. By default, providers load in the order
appsettings.json→appsettings.{Environment}.json→ user secrets (development only) → environment variables → command-line arguments, and when the same key appears in more than one, the value read later overwrites the earlier one. Just remembering this order explains the majority of “I wrote it in the JSON file but it isn’t taking effect” cases. 1 - Use
Host.CreateApplicationBuilderand this whole layering is set up by default — you don’t need to build it yourself. Even desktop apps like WinForms/WPF get the same default benefits once they adopt the Generic Host. 23 - Switch environments with
DOTNET_ENVIRONMENT(orASPNETCORE_ENVIRONMENT). ForWebApplication-based apps,DOTNET_ENVIRONMENTtakes priority, and if neither is set, the default isProduction. For desktop apps and Windows services, you need to design upfront who passes this environment variable to the process, and how. 4 - Don’t receive settings as a plain DTO — receive them as types via the
IOptions<T>family. UseIOptions<T>if computing the value once at startup is enough, andIOptionsMonitor<T>if you want changes reflected without a restart. Use both carelessly without understanding the difference, and you’ll end up with both kinds of accident: values that change when you didn’t want them to, and values that don’t change when you did. 5 - Fail on misconfiguration at startup, not at runtime. Combine
ValidateDataAnnotations()withValidateOnStart(), and a configuration mistake becomes something you learn about immediately at startup via an exception — instead of discovering it via aNullReferenceExceptionat 2 a.m. in production. 6 - Never put secrets in
appsettings.jsonin plaintext. Use user-secrets during development and DPAPI (ProtectedData) or Credential Manager in production. The official documentation explicitly states that user-secrets isn’t encrypted and is a development-only mechanism. 78
2. The Foundation of .NET’s Configuration System — IConfiguration and Providers
.NET’s configuration system is built around loading multiple configuration providers layered behind the appearance of a single key-value store called IConfiguration. The advantage of this mechanism is that it can unify sources with very different characteristics — JSON, environment variables, command-line arguments, INI, XML, in-memory collections, and more — behind that same IConfiguration interface. 9
Providers stack up according to one simple rule: whichever one was added later wins. When the same key exists in multiple providers, the value from the most recently added provider takes effect. 1
Using Host.CreateApplicationBuilder(args) sets up providers in the following order by default (higher numbers take priority — that is, the later one wins). 2
appsettings.jsonappsettings.{Environment}.json- Secret Manager (development environment only)
- Environment variables
- Command-line arguments
This default ordering is the same whether you’re building a console app, a Worker Service, or a Windows Forms app. Here’s a minimal example.
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
// appsettings.json / appsettings.{Environment}.json / environment variables /
// command-line arguments are already loaded by default, in the priority order shown above
string? connectionString = builder.Configuration.GetConnectionString("Main");
using IHost host = builder.Build();
await host.RunAsync();
Even a desktop app like Windows Forms can use the exact same HostApplicationBuilder once you add the Microsoft.Extensions.Hosting package. As covered in this blog’s “What Is Generic Host?”, the Generic Host is a foundation that takes care of configuration, DI, and logging all together, and there’s no reason to give up the benefits of the configuration system just because you’re building a desktop app. For concrete patterns in apps that run background processing, also see “Using Generic Host + BackgroundService in a Desktop App”.
There’s a subtlety in how environment variables are handled. Host settings (the content root, environment name, and so on) are read from environment variables with the DOTNET_ prefix, but that prefix isn’t used for app settings (ordinary values read through IConfiguration). Environment variables you want read as app settings are folded into the default ordering, with no prefix, via AddEnvironmentVariables(). 10 If you want to use a custom prefix, add it explicitly, like builder.Configuration.AddEnvironmentVariables(prefix: "MyApp_").
using Microsoft.Extensions.Configuration;
// Added after the default providers, so it has the highest priority
builder.Configuration.AddEnvironmentVariables(prefix: "MyApp_");
3. Per-Environment Settings — appsettings.{Environment}.json
When you want connection strings or endpoints to differ by environment, use per-environment files such as appsettings.Development.json / appsettings.Staging.json / appsettings.Production.json. The important point is that these files are treated as diff files that “override” the base appsettings.json. You don’t need to rewrite every item — it’s enough to write only the values that change per environment. 1
The environment name is determined by the DOTNET_ENVIRONMENT or ASPNETCORE_ENVIRONMENT environment variable. When using WebApplication, the value of DOTNET_ENVIRONMENT takes priority over ASPNETCORE_ENVIRONMENT, and if neither is set, the default is Production. Windows environment variable names are case-insensitive, but Linux’s are case-sensitive, so if containerization is on the horizon, it’s safer to keep the casing consistent. 4
The catch here is that for desktop apps and Windows services, “how do you even pass the environment variable” becomes a job in itself. Development-time mechanisms like ASP.NET Core’s launchSettings.json are only used for local development, so you need to prepare a separate way to switch environments in production. Here are the three main approaches.
- Set it as a machine-wide environment variable. Persist it with something like
setx DOTNET_ENVIRONMENT Production /M, and it’s inherited by every process running on that machine. This doesn’t suit cases where you want apps for multiple environments to coexist on a single machine, though. - Pass the environment variable to the Windows service’s launch process. When you run something as a resident service, it starts in a different context from the interactive user’s session, so user-scoped environment variables aren’t inherited. See “How to Build and Operate Windows Services” for details on the execution account and session separation. A machine-wide environment variable, or turning the service executable into a thin wrapper batch file that
SETs the variable before launching the real binary, is the practical setup. - When launching via Task Scheduler, passing it as a command-line argument is more reliable. Task Scheduler’s “Action” tab has no UI for setting environment variables directly, so passing it as a command-line argument like
--environment Productionand reading it withAddCommandLine(args)leads to fewer mishaps. Task Scheduler’s own quirks around execution accounts and logon types are covered in “Task Scheduler Tasks Not Running, or Ending With 0x1”.
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
var options = new HostApplicationBuilderSettings
{
Args = args,
// For execution paths where the environment variable isn't passed through (Task Scheduler, etc.),
// also allow switching the environment via command-line arguments
};
HostApplicationBuilder builder = Host.CreateApplicationBuilder(options);
Console.WriteLine($"Current environment: {builder.Environment.EnvironmentName}");
4. The Options Pattern — Receiving Settings as Types
Reading configuration through string keys like IConfiguration["Key:SubKey"] has a weakness: you can’t catch a typo, and it gets hard to follow once the nesting gets deep. In practice, the basic approach is the options pattern — binding settings to a POCO and receiving them as a type.
public sealed class ExternalApiOptions
{
public const string SectionName = "ExternalApi";
public required string BaseUrl { get; set; }
public required string ApiKey { get; set; }
public int TimeoutSeconds { get; set; } = 30;
}
Registration and binding look like this.
using Microsoft.Extensions.DependencyInjection;
builder.Services
.AddOptions<ExternalApiOptions>()
.Bind(builder.Configuration.GetSection(ExternalApiOptions.SectionName));
There are three interfaces for receiving these values, each with different characteristics. 5
| Interface | Registration lifetime | Reflecting config changes | Primary use |
|---|---|---|---|
IOptions<T> |
Singleton | Not reflected (computed once at startup) | Settings assumed not to change. The simplest option |
IOptionsSnapshot<T> |
Scoped | Recomputed every time the scope is rebuilt | Contexts with a clear scope, such as a web request scope |
IOptionsMonitor<T> |
Singleton | Always gets the latest value, with change notifications (OnChange) |
Contexts like resident services where you want to detect changes on the spot |
In apps without an HTTP request scope — desktop apps and Windows services — using IOptionsSnapshot<T> behaves effectively the same as IOptions<T> unless you create the scope yourself. If you want to detect changes, just use IOptionsMonitor<T> — settling on that rule of thumb removes most of the hesitation.
You retrieve the setting values (BaseUrl, ApiKey, timeout) from IOptionsMonitor on every call, but you get HttpClient itself from IHttpClientFactory and reuse it. If you new up and Dispose an HttpClient on every call, you tear down and rebuild the internal socket/connection pool each time, which leads to ephemeral port exhaustion under high-frequency polling or batch processing. So the standard practice is: values can change, but leave connection reuse to IHttpClientFactory.
using Microsoft.Extensions.Options;
public sealed class ExternalApiClient(
IHttpClientFactory httpClientFactory,
IOptionsMonitor<ExternalApiOptions> optionsMonitor)
{
public async Task<string> FetchAsync(CancellationToken cancellationToken)
{
// Get the latest value on every call. Reflects updates if the settings file has changed
ExternalApiOptions current = optionsMonitor.CurrentValue;
// Get HttpClient itself via the factory, reusing the internal connection pool
HttpClient client = httpClientFactory.CreateClient(nameof(ExternalApiClient));
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(new Uri(current.BaseUrl), "status"));
request.Headers.Add("X-Api-Key", current.ApiKey);
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(current.TimeoutSeconds));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
using HttpResponseMessage response = await client.SendAsync(request, linkedCts.Token);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken);
}
}
On the caller side, register a named client, like builder.Services.AddHttpClient(nameof(ExternalApiClient));. Values that can change through configuration, like BaseUrl and ApiKey, get attached to the HttpRequestMessage on each request, while the cost of creating and disposing the HttpClient instance itself is left to the factory — that’s the division of responsibility.
When a configuration mistake shows up as a runtime exception, investigating it drags on. Combine DataAnnotations validation with ValidateOnStart(), and you can design things so that an app with broken configuration simply can’t start in the first place. 6
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.DependencyInjection;
public sealed class ExternalApiOptions
{
public const string SectionName = "ExternalApi";
[Required, Url]
public required string BaseUrl { get; set; }
[Required, MinLength(16)]
public required string ApiKey { get; set; }
[Range(1, 300)]
public int TimeoutSeconds { get; set; } = 30;
}
builder.Services
.AddOptions<ExternalApiOptions>()
.Bind(builder.Configuration.GetSection(ExternalApiOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart(); // Validation runs at host startup (during StartAsync/RunAsync), before hosted services start
Without ValidateOnStart(), validation is deferred until the first time that option is actually accessed. Validation itself runs at host startup (StartAsync/RunAsync), not immediately after Build(), so be careful with things like test code that only calls Build() without ever calling RunAsync() — validation hasn’t run yet at that point. To prevent the intermittent failure mode of “the app started fine, but it crashes the instant you open the screen that uses that setting,” make it a rule to add ValidateOnStart() to business-app configuration. 6
5. Where to Put Settings That Need to Be Writable
appsettings.json is a place to keep “read-only default values” — it is not the place for settings the app rewrites itself. Many business apps get installed under Program Files, where standard users have no write permission, so you’ll hit one of two failure modes: a runtime exception, or Windows’ file system virtualization making the visible content drift apart between users.
Split settings that need to be writable according to their nature, as follows. It’s safest to anchor them to the special folders obtainable via Environment.GetFolderPath. 11
| Location | How to get it | Use |
|---|---|---|
%LOCALAPPDATA%\CompanyName\AppName |
Environment.SpecialFolder.LocalApplicationData |
The default for per-user settings and data |
%APPDATA%\CompanyName\AppName (Roaming) |
Environment.SpecialFolder.ApplicationData |
Only for settings you want to follow the user in a roaming-profile environment |
%ProgramData%\CompanyName\AppName |
Environment.SpecialFolder.CommonApplicationData |
Per-machine settings shared by all users. Requires ACL design |
using System;
using System.IO;
using System.Text.Json;
public sealed class UserSettingsStore
{
private readonly string _filePath;
public UserSettingsStore()
{
string root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
string dir = Path.Combine(root, "KomuraSoft", "MyApp");
Directory.CreateDirectory(dir);
_filePath = Path.Combine(dir, "user-settings.json");
}
public UserSettings Load()
{
if (!File.Exists(_filePath))
{
return new UserSettings();
}
string json = File.ReadAllText(_filePath);
return JsonSerializer.Deserialize<UserSettings>(json) ?? new UserSettings();
}
public void Save(UserSettings settings)
{
string json = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true });
// Assumes the caller serializes concurrent writes within the same process
File.WriteAllText(_filePath, json);
}
}
public sealed class UserSettings
{
public string? LastOpenedFolder { get; set; }
public int WindowWidth { get; set; } = 1024;
public int WindowHeight { get; set; } = 768;
}
If you mix per-user and per-machine settings into a single file, you get accidents like “User A’s setting causes trouble for User B” in environments where multiple users share the same machine. For the per-user/per-machine split and understanding Windows’ profile structure itself, see “How Windows User Profiles Work”; for a general decision table on choosing a storage location, also see “Choosing a Storage Location for Windows App Data”.
6. Secrets — Connection Strings and API Keys
Writing a connection string’s password or an API key directly into appsettings.json is a practical risk even if you’ve added it to .gitignore. The plaintext leaks out through routes like distributing it over a shared folder, having someone send you the file during a support request, or an old, zombie settings file lingering in a backup.
During development, use Secret Manager (dotnet user-secrets). But this is a mechanism for the development experience, and it is not encrypted. Values are stored as plaintext JSON at %APPDATA%\Microsoft\UserSecrets\<UserSecretsId>\secrets.json, and the official documentation explicitly states it “must not be treated as a trusted store; it’s development-only.” Putting production secrets into user-secrets and distributing them is a mistake. 7
dotnet user-secrets init
dotnet user-secrets set "ExternalApi:ApiKey" "dev-only-value"
In production, use the DPAPI (Data Protection API) that Windows provides, encrypting the value tied to a user’s or machine’s credentials. System.Security.Cryptography.ProtectedData’s Protect/Unprotect are the wrapper methods, and using DataProtectionScope.CurrentUser means it can only be decrypted on the same machine, and only when logged in as the same user account. Design with the fact in mind that DPAPI is a Windows-only feature, and throws PlatformNotSupportedException on other platforms. 8
using System.Security.Cryptography;
using System.Text;
public static class SecretProtector
{
// Mixing in entropy that indicates the purpose makes it harder to confuse with data protected for other purposes
private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("MyApp.ExternalApi.ApiKey");
public static string Protect(string plainText)
{
byte[] plainBytes = Encoding.UTF8.GetBytes(plainText);
byte[] protectedBytes = ProtectedData.Protect(plainBytes, Entropy, DataProtectionScope.CurrentUser);
return Convert.ToBase64String(protectedBytes);
}
public static string Unprotect(string protectedBase64)
{
byte[] protectedBytes = Convert.FromBase64String(protectedBase64);
byte[] plainBytes = ProtectedData.Unprotect(protectedBytes, Entropy, DataProtectionScope.CurrentUser);
return Encoding.UTF8.GetString(plainBytes);
}
}
Implementation-level details — where to store the value once DPAPI has protected it, which scope to choose between CurrentUser and LocalMachine, and the pitfalls in business apps shared by multiple users on the same machine — are covered in depth in “Storing Sensitive Data in Windows Apps — Avoiding Plaintext Settings with DPAPI”, so refer to that when you implement this.
The line between what’s okay to leave in plaintext and what isn’t is simple. Judge it by whether leaking this value would require reissuing a password or API key, investigating unauthorized access, or reporting to a regulatory authority. A connection string’s hostname or port number usually causes little real harm if leaked, whereas the password or authentication token embedded within it is always something to protect. If you build your connection string by splitting it into “server information” and “credentials,” and only apply DPAPI protection to the latter, you can separate at the code level the part where plaintext causes little real harm from the part that needs protecting.
7. Changing Configuration at Runtime — The Reality of reloadOnChange
The default call to AddJsonFile (the one Host.CreateApplicationBuilder makes internally) sets reloadOnChange: true, so appsettings.json / appsettings.{Environment}.json are automatically reloaded when a file change is detected. Under the hood, PhysicalFileProvider watches for changes using a FileSystemWatcher. 12
This mechanism has a few practical limitations.
- Only values read via
IOptionsMonitor<T>(andIOptionsSnapshot<T>) get updated.IOptions<T>keeps holding on to its value from startup, so it stays stale even after you rewrite the file. Most inquiries along the lines of “I edited the settings file directly and it’s not taking effect” trace back to confusing these two. FileSystemWatchercan’t always reliably deliver change notifications on file systems like Docker containers or network shares. In those environments, setting theDOTNET_USE_POLLING_FILE_WATCHERenvironment variable totrueswitches to polling-based monitoring at a 4-second interval (the interval isn’t configurable). 13 For the general quirks ofFileSystemWatcheritself — missed events, buffer overflow, duplicate events — see “A Practical Guide to FileSystemWatcher”.- A single change to the settings file can trigger the change notification multiple times. If your app implements something like “redo an expensive operation whenever a change is detected,” you need to account for this — debounce rapid, closely-spaced firings, or compare a hash of the file’s contents so you only process substantive changes.
You don’t always need to aim for “apply configuration changes without a restart.” Lightweight values like log level or feature flags can safely be applied instantly via IOptionsMonitor, but values like a DB connection string or thread-pool size — where a change could conflict with resources that are already running the instant it takes effect — are safer to explicitly spec as “applied on restart.” Designing things so you can clearly tell the operations staff “this setting takes effect the moment you save it” versus “this setting requires a restart” matters more than whether you use reloadOnChange at all.
8. A Migration Map from app.config / Settings.settings
When migrating a .NET Framework-era app to .NET, the configuration mechanism also needs to be rebuilt. Here’s a mapping between the two. 14
| .NET Framework | .NET | Notes |
|---|---|---|
<appSettings> in App.config / Web.config |
appsettings.json + IConfiguration |
Hierarchical structure can be expressed naturally through JSON nesting |
ConfigurationManager.AppSettings["Key"] |
builder.Configuration["Key"] or IOptions<T> |
From string-keyed access to typed access |
ConfigurationManager.ConnectionStrings |
builder.Configuration.GetConnectionString("Name") |
The ConnectionStrings section convention is preserved |
Settings.settings (user scope) |
A custom JSON file under %LOCALAPPDATA% |
There’s no auto-generated mechanism like ApplicationSettingsBase. You serialize/save it yourself |
Settings.settings (application scope) |
appsettings.json |
Treat as read-only default values |
Encryption of <connectionStrings> (aspnet_regiis, etc.) |
DPAPI (ProtectedData) |
The encryption mechanism itself changes entirely. Needs to be rebuilt during migration |
There are two traps commonly stepped on during migration.
The first is that adding the System.Configuration.ConfigurationManager NuGet package lets your existing App.config-reading code keep working as-is. This is a valid move as a first stage of migration, but you shouldn’t leave it there — you should schedule the move to appsettings.json into your plan. Peripheral libraries like logging providers have almost universally moved to assuming appsettings.json too, so the case for keeping the old mechanism around purely to read App.config keeps getting weaker. 14
The second is Settings.settings’s user-scoped settings. .NET Framework had a mechanism where simply calling Properties.Settings.Default.Save() automatically saved per-user settings, but .NET doesn’t provide an equivalent auto-generated mechanism out of the box. During migration, you need to prepare your own storage class like the one shown in Chapter 5.
There’s another side to migration — dependent-library compatibility, whether COM interop is involved, rethinking your distribution method, and the overall picture of what to check beyond configuration management. That inventory perspective is summarized in “The .NET Framework → .NET Pre-Migration Checklist”, so we recommend reading through it once early in your migration project.
Summary
Configuration management in .NET is built from a combination of three mechanisms: layering providers through IConfiguration, receiving values type-safely through the IOptions family, and switching environments through environment variables. Up to that point, most apps can use it the same way. But the concerns unique to Windows business apps concentrate beyond that — where to put writable settings, protecting secrets, how far to allow configuration changes at runtime, and how to wind down assets from the app.config generation.
Take a step beyond “it’s all written in appsettings.json,” and separate the location and handling for each kind of setting. We hope this article’s decision table serves as a starting point for that. Inventorying an existing app’s configuration management, or figuring out a migration strategy away from app.config, often can’t reach the best answer without actually looking at the real settings files and deployment environment — so if you’re unsure, feel free to reach out.
Related Articles
- What Is Generic Host?
- Using Generic Host + BackgroundService in a Desktop App
- Task Scheduler Tasks Not Running, or Ending With 0x1
- How to Build and Operate Windows Services
- Choosing a Storage Location for Windows App Data
- How Windows User Profiles Work
- Storing Sensitive Data in Windows Apps — Avoiding Plaintext Settings with DPAPI
- A Practical Guide to FileSystemWatcher
- The .NET Framework → .NET Pre-Migration Checklist
Related Consulting Areas
KomuraSoft LLC handles technical consulting on configuration management design for Windows business apps, operational design for per-environment settings, and migration strategy from existing app.config assets.
References
-
Microsoft Learn, Configuration in .NET - Alternative hosting approach. On the priority order of configuration providers that
Host.CreateApplicationBuilderassembles by default (command-line arguments → environment variables → user secrets in development → appsettings.{Environment}.json → appsettings.json). ↩ ↩2 ↩3 -
Microsoft Learn, .NET Generic Host - Host builder settings. On the default ordering of the host configuration (
DOTNET_-prefixed environment variables, command-line arguments) and the app configuration (appsettings.json, appsettings.{Environment}.json, Secret Manager, environment variables, command-line arguments) thatHost.CreateApplicationBuilderloads. ↩ ↩2 -
Microsoft Learn, Use the .NET Generic Host in a Windows Forms app. On the steps for embedding the Generic Host in a Windows Forms app and using DI, configuration, and logging. ↩
-
Microsoft Learn, ASP.NET Core runtime environments - Environment variables that determine the runtime environment. On the relationship between
DOTNET_ENVIRONMENTandASPNETCORE_ENVIRONMENT, thatDOTNET_ENVIRONMENTtakes priority when usingWebApplication, that the default when neither is set isProduction, and that Windows environment variable names are case-insensitive while Linux’s are case-sensitive. ↩ ↩2 -
Microsoft Learn, Options pattern in .NET - Options interfaces. On the differences in lifetime, timing of reflecting configuration changes, and supported features among
IOptions<TOptions>,IOptionsSnapshot<TOptions>, andIOptionsMonitor<TOptions>. ↩ ↩2 -
Microsoft Learn, Options pattern in .NET - Options validation. On how to configure DataAnnotations validation via
ValidateDataAnnotations()and startup-time validation viaValidateOnStart()(orAddOptionsWithValidateOnStart). ↩ ↩2 ↩3 -
Microsoft Learn, Safe storage of app secrets in development in ASP.NET Core - Use the Secret Manager tool. On how Secret Manager stores secrets as plaintext at
%APPDATA%\Microsoft\UserSecrets\<user_secrets_id>\secrets.jsonwithout encrypting them, and that it’s development-only and shouldn’t be treated as a trusted store. ↩ ↩2 -
Microsoft Learn, ProtectedData Class. On the
ProtectedData.Protect/Unprotectmethods that wrap DPAPI (Data Protection API), the difference in scope betweenDataProtectionScope.CurrentUser/LocalMachine, and that it’s Windows-only and throwsPlatformNotSupportedExceptionon other platforms. ↩ ↩2 -
Microsoft Learn, Configuration providers in .NET. On the mechanism of configuration providers that unify sources with different characteristics — JSON, environment variables, command line, INI, XML, and more — behind
IConfiguration. ↩ -
Microsoft Learn, Configuration providers in .NET - Environment variable configuration provider. On how the default configuration loads
DOTNET_-prefixed environment variables and command-line arguments into the host configuration and app configuration, that this isn’t used for user configuration, and how to add a custom prefix. ↩ -
Microsoft Learn, Environment.GetFolderPath Method. On how to retrieve special folder paths via the
Environment.SpecialFolderenumeration and theGetFolderPathmethod. ↩ -
Microsoft Learn, Detect changes with change tokens in ASP.NET Core - Monitor for configuration changes. On the
reloadOnChangeparameter ofAddJsonFile, and the mechanism by whichPhysicalFileProviderinternally usesFileSystemWatcherto watch for changes to the settings file. ↩ -
Microsoft Learn, Options pattern in .NET - IOptionsMonitor. On how
IOptionsMonitor’s change notifications are limited to file-system-based configuration providers, and how theDOTNET_USE_POLLING_FILE_WATCHERenvironment variable can switch to 4-second-interval polling when change notifications aren’t reliable on Docker containers or network shares. ↩ -
Microsoft Learn, Modernize after upgrading to .NET from .NET Framework - App.config. On the migration steps from
App.configtoappsettings.json, maintaining compatibility via theSystem.Configuration.ConfigurationManagerNuGet package, and using theMicrosoft.Extensions.Configuration.Jsonpackage. ↩ ↩2
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Preventing Multiple Instances of a Windows App — Named Mutexes and Activating the Existing Window on a Second Launch
This article organizes the classic requirement for business Windows apps — 'don't let the same app launch twice' — around a named Mutex. ...
Safely Calling Win32 APIs from C# — A Practical P/Invoke Guide (DllImport / LibraryImport / CsWin32)
A practical rundown of what to watch for when calling Win32 APIs and native DLLs from C# via P/Invoke. Covers the differences between Dll...
How to Build and Operate Windows Services ── From Choosing Between Task Scheduler and Services to Turning a BackgroundService into a Windows Service
Should a background process become a Windows service, or is Task Scheduler enough? This guide organizes the practical design work for put...
Why Use the .NET Generic Host and BackgroundService in Desktop Apps
How to use the Generic Host and BackgroundService to organize startup, periodic processing, shutdown, logging, configuration, and DI in W...
Pinpointing "Slow" with PerfView and dotnet-trace — A Practical Introduction to .NET Performance Investigation
When a business app is "slow," "pegs the CPU," or "occasionally freezes," which tool should you reach for, and what should you look at? T...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Generic Host & App Architecture
Topic page for Generic Host, BackgroundService, DI, configuration, logging, and app lifetime design.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
Configuration management and settings-file design fall squarely within the scope of practical Windows app development consulting.
Technical Consulting & Design Review
Deciding on a migration strategy away from an existing app.config is the kind of judgment call that belongs in technical consulting involving a design review.
Frequently Asked Questions
Common questions about the topic of this article.
- Is it okay to put appsettings.json in the same folder as the exe?
- That's fine as long as it holds read-only default values. But if that folder gets installed under Program Files, you can't design the app to rewrite appsettings.json itself. Standard users don't have write permission under Program Files, so you'll either get a runtime exception or have Windows' file system virtualization silently show different content to different users. Split out any setting that needs to be writable into a separate file under %LOCALAPPDATA% or %ProgramData%.
- Should environment variables or appsettings.json take priority?
- Don't change the default precedence — stick with the built-in override order of appsettings.json → appsettings.{Environment}.json → environment variables → command-line arguments. When you're unsure, judge by the nature of the value: default values that are fine to ship inside the build output belong in appsettings.json, while values that vary per deployment target or container belong in environment variables. Splitting responsibility this way keeps things unambiguous later on.
- How should I split up my settings classes?
- The basic rule is to split options classes by feature or responsibility — a ConnectionOptions for connection strings, an ExternalApiOptions for external API integration, and so on — rather than dumping unrelated settings into one class. Splitting things this way also naturally divides the unit of validation and reloading for IOptionsSnapshot/IOptionsMonitor, and DataAnnotations validation stays self-contained per class.
- Should I migrate away from INI files or the registry?
- If you're rebuilding configuration management from scratch, we recommend standardizing on appsettings.json + IConfiguration. .NET also ships an INI configuration provider, so you can keep reading your existing INI files as-is while migrating gradually. The registry isn't part of .NET's standard configuration-provider territory, so if you have existing assets that depend on it, the practical approach is to write a read-only bridge and gradually shift things over to appsettings.json.
Author Profile
Profile page for the article author.
Go Komura
Representative of KomuraSoft LLC
Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.
Public links