VSLive! Blog

Industry Insights, Information, and Developer News

Blog archive

Stop Sprinkling Magic Strings Through Your Configuration Code

Here's a fun debugging exercise. Rename a section in appsettings.json from BlobStorage to AzureBlobStorage. Perfectly reasonable change. Build the app. No compiler errors. Run the tests. They pass. Deploy it. It starts up fine.

Except the code that reads that configuration still says this:

var endpoint = Configuration["BlobStorage:Endpoint"];
var containerName = Configuration["BlobStorage:ContainerName"];
var maxRetries = int.Parse(Configuration["BlobStorage:MaxRetries"]);

No compile error. No startup error. The app just quietly returns null for all three values and waits patiently for someone to hit the code path that actually uses them. And when it finally blows up with a NullReferenceException? Good luck tracing that back to a renamed JSON section.

The Magic String Problem
If you've been building ASP.NET Core applications for a while, you've probably written code like this:

public class BlobStorageService
{
    private readonly string _endpoint;
    private readonly string _containerName;

    public BlobStorageService(IConfiguration configuration)
    {
        _endpoint = configuration["BlobStorage:Endpoint"];
        _containerName = configuration["BlobStorage:ContainerName"];
    }
}

It works. It's in every tutorial. And it has some genuinely terrible properties.

There's no IntelliSense. You're typing string literals and hoping you spelled everything right. Rename the section in your JSON? The compiler won't tell you. Typo in the key name? The compiler won't tell you. Ask for a value that doesn't exist? You get null -- silently, at runtime, whenever that code happens to execute.

And the string literals multiply. If three services need the blob storage endpoint, that exact string "BlobStorage:Endpoint" appears in three places. Refactoring means find-and-replace and prayer.

Strongly-Typed Configuration
Here's the fix: create a class that represents your configuration section, and let ASP.NET Core bind the values for you.

Start with the JSON in appsettings.json:

{
  "BlobStorage": {
    "Endpoint": "https://myaccount.blob.core.windows.net",
    "ContainerName": "uploads",
    "MaxRetries": 3,
    "TimeoutSeconds": 30
  }
}

Create a class that mirrors that structure:

public class BlobStorageConfig
{
    public string Endpoint { get; set; } = string.Empty;
    public string ContainerName { get; set; } = string.Empty;
    public int MaxRetries { get; set; }
    public int TimeoutSeconds { get; set; }
}

Register it in Program.cs:

builder.Services.Configure<BlobStorageConfig>(
    builder.Configuration.GetSection("BlobStorage"));

Now inject it into your service:

public class BlobStorageService
{
    private readonly BlobStorageConfig _config;

    public BlobStorageService(IOptions<BlobStorageConfig> options)
    {
        _config = options.Value;
    }

    public void Upload(Stream content, string fileName)
    {
        // _config.Endpoint, _config.ContainerName --
        // IntelliSense, compile-time safety, no magic strings
        var client = new BlobServiceClient(new Uri(_config.Endpoint));
        var container = client.GetBlobContainerClient(_config.ContainerName);
        // ...
    }
}

That's the whole pattern. The magic strings are gone. You get IntelliSense when you type _config. and the compiler catches typos. The section name "BlobStorage" appears exactly once -- in the registration line in Program.cs.

But What About the Silent Failure Problem?
The strongly typed binding is a big improvement, but it doesn't solve the original problem by itself. If someone renames the JSON section, the Configure<T> call will silently bind to nothing. You'll get a BlobStorageConfig with all default values -- empty strings and zeros. No error. Just quiet, eventual failure.

This is where it gets good.

Remember DataAnnotations? If you read my earlier article on ASP.NET Core validation, you used attributes like [Required] and [EmailAddress] on model properties to validate user input. Turns out you can use those same attributes on configuration classes.

using System.ComponentModel.DataAnnotations;

public class BlobStorageConfig
{
    [Required]
    [Url]
    public string Endpoint { get; set; } = string.Empty;

    [Required]
    public string ContainerName { get; set; } = string.Empty;

    [Range(1, 10)]
    public int MaxRetries { get; set; }

    [Range(5, 120)]
    public int TimeoutSeconds { get; set; }
}

Same attributes, same namespace, completely different context. Instead of validating form input from a user, you're validating configuration input from appsettings.json.

Now update the registration in Program.cs to actually enforce those rules:

builder.Services
    .AddOptions<BlobStorageConfig>()
    .Bind(builder.Configuration.GetSection("BlobStorage"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

Two important additions here. ValidateDataAnnotations() tells the options system to run the DataAnnotation validators. And ValidateOnStart() -- this is the critical one -- tells it to run those validators immediately when the application starts, not when someone first requests IOptions<BlobStorageConfig>.

With this setup, if someone renames the JSON section or deletes a required value, your app crashes on startup with a clear error message. Not later when a customer hits a specific code path. Immediately. Before the app even starts accepting requests.

That's the difference between "we caught it in deployment" and "we caught it in production at the worst possible time."

What ValidateOnStart Actually Does
This is worth pausing on because the default behavior is surprising. Without ValidateOnStart(), the validation only runs the first time something asks for IOptions<BlobStorageConfig>. If no code path requests that configuration during startup, the invalid config sits there silently until someone triggers it.

With ValidateOnStart(), the framework creates an IHostedService that resolves your options immediately during startup. If validation fails, the app throws OptionsValidationException and won't start. You get the error in your deployment logs, not from your error monitoring after a customer hits the problem.

IOptions vs. IOptionsSnapshot vs. IOptionsMonitor
While we're here -- you've probably seen all three of these in documentation and wondered what the difference is. It's simpler than it looks.

IOptions<T> reads the configuration once at startup and caches it for the lifetime of the application. This is what you want 95% of the time. It's a singleton -- same values everywhere, forever.

IOptionsSnapshot<T> re-reads the configuration on every request (in a web app). If someone modifies appsettings.json while the app is running, the next request will see the updated values. It's scoped -- each request gets a fresh read. This is useful in development or if you have a scenario where config legitimately changes at runtime without a restart.

IOptionsMonitor<T> is like IOptionsSnapshot<T> but it's a singleton that actively watches for changes and notifies you via a callback. Use this when you need to react to configuration changes in a long-lived service, like reconfiguring a connection pool.

For most applications? Just use IOptions<T>. If you're not sure which one you need, that's the one.

Multiple Configuration Sections
Real apps have more than one configuration section. Here's what a more realistic Program.cs looks like:

builder.Services
    .AddOptions<BlobStorageConfig>()
    .Bind(builder.Configuration.GetSection("BlobStorage"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

builder.Services
    .AddOptions<EmailConfig>()
    .Bind(builder.Configuration.GetSection("Email"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

builder.Services
    .AddOptions<CosmosConfig>()
    .Bind(builder.Configuration.GetSection("CosmosDb"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

Each section gets its own class, its own validation, and its own fail-fast behavior. Services inject only the configuration they need:

public class NotificationService
{
    private readonly EmailConfig _emailConfig;

    public NotificationService(IOptions<EmailConfig> emailOptions)
    {
        _emailConfig = emailOptions.Value;
    }
}

This is also much better for testability. In a unit test, you don't need to construct an entire IConfiguration with JSON files and section hierarchies. You just create an IOptions<EmailConfig> with the values you want:

var emailConfig = Options.Create(new EmailConfig
{
    SmtpServer = "localhost",
    Port = 25,
    FromAddress = "[email protected]"
});

var service = new NotificationService(emailConfig);

Clean. No magic strings. No JSON files in your test project.

Custom Validation Beyond DataAnnotations
DataAnnotations handle the common cases -- required fields, ranges, URLs, email addresses. But sometimes you need validation logic that's more complex than an attribute can express.

The options system supports that too. You can add a Validate call with a lambda or a delegate:

builder.Services
    .AddOptions<BlobStorageConfig>()
    .Bind(builder.Configuration.GetSection("BlobStorage"))
    .ValidateDataAnnotations()
    .Validate(config =>
    {
        if (config.TimeoutSeconds < config.MaxRetries * 2)
        {
            return false;
        }
        return true;
    }, "TimeoutSeconds must be at least 2x MaxRetries " +
       "to allow for retry delays.")
    .ValidateOnStart();

The second parameter is the error message. You can chain multiple Validate calls for different rules. They all run, and all failures are reported together.

The Connection to the Builder Pattern
If this feels familiar, it should. In my last article, I showed you the Builder Pattern for constructing CosmosConfig objects -- especially useful in console apps, .NET tools, and tests where you don't have the ASP.NET Core configuration system.

The IOptions<T> pattern is the other side of that coin. Inside ASP.NET Core, the framework does the heavy lifting of reading JSON, binding values, and injecting the result. Outside ASP.NET Core -- that's where the builder pattern picks up. Same goal (a properly configured, validated object), different mechanisms.

If you're building an app that has both an ASP.NET Core host and standalone tools or integration tests, you might use both: IOptions<T> for the web app, the builder for everything else.

When to Use This (And When It's Overkill)
The IOptions<T> pattern makes sense when your app has configuration sections with multiple values that get injected into services. Basically, any non-trivial ASP.NET Core application.

ValidateDataAnnotations() and ValidateOnStart() make sense when bad configuration values would cause runtime failures. If a missing connection string means your app silently fails in production, validate it at startup.

It's overkill when you have one or two configuration values that are simple strings. Reading Configuration["ApiKey"] directly is fine for that. Not everything needs a class.

The threshold for me is about the same as the builder pattern -- once you have three or four related values that travel together, they deserve a class. And once they deserve a class, they deserve validation.

The Code
If you want to see this pattern in action, it's baked into how my Benday.CosmosDb library handles configuration. The CosmosRepositoryOptions<T> class is designed to work with IOptions<T>, and it pairs with the CosmosConfigBuilder from last month's article for non-ASP.NET Core scenarios.

About the Author

Benjamin Day is a consultant, trainer and author specializing in software development, project management and leadership.

Posted by Benjamin Day on 04/28/2026


Keep Up-to-Date with Visual Studio Live!

Email Address*Country*
Please type the letters/numbers you see above.