MOFAKH.COM
← Back to profile
Azure Queue Storage

Configuration and the Options Pattern

Aug 28, 202616 min readWritten

A connection string does not belong in code. Getting it out of code and into a typed C# class sounds trivial, but it involves three separate .NET mechanisms that are easy to confuse — configuration binding, the service container, and the IOptions wrapper. This part takes all three apart.

Part 2 produced a running API with four empty folders and a six-line Program.cs. This part fills the Settings folder and the first piece of Models.

Nothing in this part touches Azure. It is entirely about a .NET concept — how configuration values travel from a JSON file into a strongly typed C# class that controllers can use. This is the Options Pattern, and it is one of the most frequently used features in ASP.NET Core. It is also one of the most frequently misunderstood, because it involves three distinct mechanisms that look like they should be one.

The Problem Being Solved

Consider what a naive first version of queue code might look like:

csharp
var client = new QueueClient("UseDevelopmentStorage=true", "job-queue");

This works. It is also wrong in several ways.

The connection string is baked into the compiled binary. Moving from local development to a real Azure account means editing C# code and recompiling. The same binary cannot run in two environments.

Secrets end up in source control. A production Azure connection string contains an account key. Hardcoding it means committing a credential to a Git repository, permanently, in the history.

The queue name is a magic string. Typing "job-queue" in one file and "job_queue" in another produces a bug that compiles cleanly and fails at runtime. The compiler cannot help, because as far as it is concerned both are just valid strings.

Nothing is discoverable. Finding every place the queue name appears means grepping the codebase and hoping nothing was missed.

The fix is to move these values out of code and into configuration, then load them into a typed class that the compiler understands.

Where Configuration Lives

ASP.NET Core reads configuration from multiple sources at startup and layers them on top of each other. The order matters — later sources override earlier ones:

Configuration sources, lowest priority first
appsettings.json
base values
overridden by
appsettings.{Environment}.json
per-environment
overridden by
User Secrets
local dev only
overridden by
Environment variables
deployment

This layering is what makes one binary run in many environments. appsettings.json holds defaults that are safe to commit. appsettings.Development.json holds local overrides. In production, environment variables supply the real connection string — never a committed file.

WebApplication.CreateBuilder(args) — the first line of Program.cs — sets all of this up automatically. By the time the next line runs, configuration is already loaded and merged.

This series uses appsettings.json only, since the Azurite connection string is not a secret. The mechanism is identical regardless of source.

Adding the Configuration Values

Open appsettings.json and add an AzureStorage section:

json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "AzureStorage": {
    "ConnectionString": "UseDevelopmentStorage=true",
    "QueueNames": {
      "JobExport": "job-queue"
    }
  }
}

Two values now live outside the code.

ConnectionString is UseDevelopmentStorage=true — the Azurite shorthand from Part 1. Moving to real Azure means changing this one line and nothing else.

QueueNames is a nested object rather than a flat string. That shape is deliberate, and the next section explains why.

Naming the section. "AzureStorage" is an arbitrary label. Nothing in .NET requires that name — it simply needs to match the string passed to GetSection() later in Program.cs. Descriptive names group related settings and keep large configuration files navigable.

Killing Magic Strings with an Enum

The QueueNames object exists to solve the magic string problem properly.

A single queue is easy. Real applications have several — one for exports, one for emails, one for report generation. Referring to them as raw strings scattered through the codebase invites typos that the compiler cannot catch.

The fix is an enum. Create Models/EQueueNames.cs:

csharp
namespace JobQueueApi.Models;
 
public enum EQueueNames
{
    JobExport
}

The E prefix. Prefixing enum type names with E is a convention found in many C# codebases. It makes the type instantly identifiable at a call site — EQueueNames.JobExport reads unambiguously as an enum member. This is a convention, not a language rule; plenty of codebases omit it.

The enum becomes the key for looking up a queue name:

csharp
settings.QueueNames[EQueueNames.JobExport]   // returns "job-queue"

What this buys:

  • Compile-time safety. EQueueNames.JobExprot fails to compile. "job-exprot" does not.
  • Discoverability. IntelliSense lists every available queue after typing EQueueNames..
  • Safe renames. Changing the actual queue name means editing one line of JSON. No code changes anywhere.
  • A single source of truth. Every queue in the system is enumerated in one file.

Adding a second queue later requires two small edits — one enum member and one JSON key:

csharp
public enum EQueueNames
{
    JobExport,
    EmailNotification
}
json
"QueueNames": {
  "JobExport": "job-queue",
  "EmailNotification": "email-queue"
}

The Settings Class

Configuration values need a typed home. Create Settings/AzureStorageSettings.cs:

csharp
using JobQueueApi.Models;
 
namespace JobQueueApi.Settings;
 
public class AzureStorageSettings
{
    public string ConnectionString { get; set; } = string.Empty;
 
    public Dictionary<EQueueNames, string> QueueNames { get; set; } = new();
}

Breaking this down.

The class is a plain C# class. No base class, no attributes, no interface. .NET calls this a POCO — Plain Old CLR Object. The Options Pattern imposes no requirements on the shape of the class.

Property names match JSON keys exactly. The property ConnectionString binds to the JSON key "ConnectionString". This matching is how binding works, and it is covered in detail in the next section.

= string.Empty and = new() are default initialisers. Without them, ConnectionString would be null and QueueNames would be null if binding failed or a key was missing. Defaulting to empty values means code that reads them gets a harmless empty string or empty dictionary rather than a NullReferenceException. With <Nullable>enable</Nullable> set in the .csproj (Part 2), omitting these initialisers produces compiler warnings.

Dictionary<EQueueNames, string> maps each enum member to its real queue name. The enum is the key, the actual Azure queue name is the value.

How Binding Actually Works

This is the part that feels like magic until it is understood.

The .NET configuration binder takes a section of configuration and populates a class from it. The rules are mechanical.

Property Name Matching

The binder walks each public settable property on the target class and looks for a configuration key with the same name.

Diagram
JSON key                  →  C# property
"ConnectionString"        →  public string ConnectionString
"QueueNames"              →  public Dictionary<...> QueueNames

The match is case-insensitive. "connectionString" and "CONNECTIONSTRING" both bind to ConnectionString. Matching the C# casing exactly is the convention regardless.

A JSON key with no matching property is silently ignored — no error, no warning. A property with no matching JSON key keeps its default value. This silence is the single biggest source of Options Pattern confusion, and it is why the default initialisers above matter.

Type Conversion

Configuration values are always strings in their raw form. The binder converts them to the target property type — parsing "30" into an int, "true" into a bool, "00:01:00" into a TimeSpan. A value that cannot be converted throws an exception at startup, which is the right time to find out.

Dictionary Binding with an Enum Key

The QueueNames dictionary deserves specific attention because two conversions happen simultaneously.

json
"QueueNames": {
  "JobExport": "job-queue"
}

For a Dictionary<TKey, TValue> property, the binder treats each nested JSON key as a dictionary key and each value as a dictionary value. Since TKey here is EQueueNames — an enum, not a string — the binder parses the JSON key "JobExport" into the enum member EQueueNames.JobExport.

The critical consequence:

The JSON key must exactly match an enum member name. If the enum member is JobExport and the JSON key is "JobsExport", binding fails for that entry. The dictionary silently ends up without it, and the failure only surfaces later as a KeyNotFoundException when the code tries to look up EQueueNames.JobExport.

This is a place to be careful. The enum member name and the JSON key are two separate strings that must stay in sync, and nothing enforces it at compile time.

Registering It — And the Mistake Everyone Makes

The settings class exists and the JSON values exist. They still need to be connected, and this is where the Options Pattern is most often misunderstood.

The Intuitive Approach That Fails

Part 2 established that builder.Services is the dependency injection container. Registering a class there looks like the obvious move:

csharp
builder.Services.AddSingleton<AzureStorageSettings>();

This compiles. The app starts. Injecting AzureStorageSettings into a controller works. And the values are completely empty:

csharp
settings.ConnectionString   // ""
settings.QueueNames         // empty dictionary

The reason is simple once seen plainly: AddSingleton<T>() has no connection to configuration whatsoever. It instructs the container to construct an instance of AzureStorageSettings by calling its parameterless constructor. That constructor sets ConnectionString to string.Empty and QueueNames to an empty dictionary — exactly as written in the class.

Nothing in that line mentions appsettings.json. Nothing mentions the "AzureStorage" section. There is no mechanism by which the container could know those values exist.

The failure is silent. No exception, no warning, no log. The first symptom appears much later, as a confusing error from the Azure SDK about an invalid connection string.

The Correct Approach

csharp
builder.Services.Configure<AzureStorageSettings>(
    builder.Configuration.GetSection("AzureStorage")
);

This line does two distinct things, which is why it succeeds where AddSingleton fails.

First, builder.Configuration.GetSection("AzureStorage") locates that section of merged configuration:

json
{
  "ConnectionString": "UseDevelopmentStorage=true",
  "QueueNames": { "JobExport": "job-queue" }
}

Second, Configure<AzureStorageSettings>(...) registers a binding: create an AzureStorageSettings, populate it from that section using the rules described above, and make the result available through the container as IOptions<AzureStorageSettings>.

The comparison side by side:

AddSingleton<T>()Configure<T>(section)
Creates an instanceYesYes
Reads appsettings.jsonNoYes
Binds JSON values to propertiesNoYes
Registered asTIOptions<T>
ResultEmpty objectPopulated object

The mental model worth keeping:

AddSingleton<T>() says "the container should know how to make one of these." Configure<T>(section) says "the container should know how to make one of these and fill it from configuration."

What IOptions Actually Is

Configure<T> registers the settings as IOptions<AzureStorageSettings>, not as AzureStorageSettings directly. Consuming it means asking for the wrapper and unwrapping it:

csharp
public JobsController(IOptions<AzureStorageSettings> azureStorageSettings)
{
    _azureStorageSettings = azureStorageSettings.Value;
}

The natural objection: why the extra layer? Why not receive AzureStorageSettings directly and skip .Value entirely?

The interface is genuinely small:

csharp
public interface IOptions<out TOptions> where TOptions : class
{
    TOptions Value { get; }
}

One property. That is the whole thing. It exists for three reasons.

It Signals Origin

A constructor parameter of type AzureStorageSettings says nothing about where the object came from. It might be bound from configuration, constructed manually, or registered empty. A parameter of type IOptions<AzureStorageSettings> says unambiguously: this came from the configuration system.

It Enables Lazy Evaluation

IOptions<T> does not bind at registration time. Binding happens the first time .Value is accessed, and the result is cached. Configuration sections that are never used are never bound.

It Provides a Family of Behaviours

IOptions<T> is the simplest of three related interfaces, and the difference between them matters in real applications:

InterfaceLifetimeReloads on config changeTypical use
IOptions<T>SingletonNoValues fixed at startup
IOptionsSnapshot<T>ScopedPer requestValues that change per request
IOptionsMonitor<T>SingletonYes, with change notificationsLong-running services

IOptions<T> is correct for this project. The connection string and queue names are fixed at startup and do not change while the app runs. Reading them once and caching is exactly the desired behaviour.

IOptionsMonitor<T> becomes relevant for a background service that should notice an edited appsettings.json without a restart. That is a real scenario, and it is the main reason the wrapper exists at all — it makes swapping to reload-aware behaviour a one-word change.

Unwrapping Once

The .Value call belongs in the constructor, not scattered through methods:

csharp
private readonly AzureStorageSettings _azureStorageSettings;
 
public JobsController(IOptions<AzureStorageSettings> azureStorageSettings)
{
    _azureStorageSettings = azureStorageSettings.Value;
}

The field is the plain settings class. Every method in the controller uses _azureStorageSettings without thinking about IOptions at all. The wrapper exists at the boundary and nowhere else.

This also keeps the helper class in Part 5 simple — it accepts a plain AzureStorageSettings parameter and has no knowledge that the Options Pattern exists.

The Alternative Without IOptions

For completeness, there is a way to inject the settings class directly, and it is worth understanding because it appears in real codebases:

csharp
var azureStorageSettings = builder.Configuration
    .GetSection("AzureStorage")
    .Get<AzureStorageSettings>();
 
builder.Services.AddSingleton(azureStorageSettings!);

.Get<T>() performs the binding immediately and returns a populated object. Registering that instance means controllers can accept AzureStorageSettings with no wrapper:

csharp
public JobsController(AzureStorageSettings azureStorageSettings)
{
    _azureStorageSettings = azureStorageSettings;
}

This works correctly. Note the crucial difference from the broken version earlier — AddSingleton(instance) registers an already populated object, while AddSingleton<T>() asks the container to construct an empty one. One character of syntax separates working code from silently broken code.

The trade-offs against IOptions<T>:

  • Binding happens eagerly at startup rather than lazily
  • Switching to reload-aware configuration later requires changing every consumer
  • It diverges from the pattern the framework and its documentation assume

IOptions<T> is the standard for a reason, and this series uses it. Recognising the alternative matters, since encountering it in an unfamiliar codebase is likely.

Wiring It Into Program.cs

Open Program.cs and add the registration:

csharp
using JobQueueApi.Settings;
 
var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddControllers();
 
builder.Services.Configure<AzureStorageSettings>(
    builder.Configuration.GetSection("AzureStorage")
);
 
var app = builder.Build();
 
app.MapControllers();
 
app.Run();

One using and one registration. The startup path is still small enough to read at a glance.

Registration order does not matter. Service registrations are declarations, not executions. Configure<T> could sit above AddControllers() with identical results. Nothing is constructed until something requests it. What does matter is that every registration happens before builder.Build(), which is covered next.

Why builder.Build() Is a Hard Boundary

csharp
var app = builder.Build();

This line seals the dependency injection container. After it, no further services can be registered.

The reason is correctness. The container's job is to construct objects and satisfy their dependencies. Allowing registrations after some objects have already been constructed would mean two requests could receive different versions of the same service depending on timing. Sealing the container guarantees that every consumer sees an identical, stable set of registrations.

This produces a clean two-phase startup:

Startup phases
Registration
before Build()
AddControllers
Configure<T>
builder.Build()
Resolution
after Build()
controllers created per request

Everything before Build() describes what the app can construct. Everything after uses those descriptions to actually construct things. Part 7 follows a single HTTP request through this machinery end to end.

Verifying

Nothing consumes the settings yet, so there is no runtime behaviour to observe. The compile check still matters:

bash
dotnet build
Diagram
Build succeeded.
    0 Warning(s)
    0 Error(s)

An error at this stage almost always falls into one of these:

The type or namespace name 'AzureStorageSettings' could not be found — the using JobQueueApi.Settings; line is missing from Program.cs, or the namespace in AzureStorageSettings.cs does not match the folder.

The type or namespace name 'EQueueNames' could not be foundAzureStorageSettings.cs is missing using JobQueueApi.Models;. The enum lives in a different namespace from the settings class.

'IServiceCollection' does not contain a definition for 'Configure' — unusual on a fresh Web API project, since Microsoft.Extensions.Options.ConfigurationExtensions comes in through the web SDK. It indicates a project template other than webapi.

Common Questions at This Stage

Why does AddSingleton<AzureStorageSettings>() fail silently instead of throwing?

Because from the container's perspective nothing went wrong. It was asked to construct an AzureStorageSettings and it did exactly that, successfully. The container has no way to know the resulting object was supposed to contain configuration values. Silent success is precisely what makes this bug hard to spot — the error surfaces far from its cause.

Can IOptions<T> be skipped by injecting IConfiguration directly?

Technically yes:

csharp
public JobsController(IConfiguration configuration)
{
    var connectionString = configuration["AzureStorage:ConnectionString"];
}

This works but throws away everything the Options Pattern provides. The key is a magic string, a typo returns null rather than a compile error, there is no type safety, and the shape of the configuration is invisible to anyone reading the class. Binding to a typed class once and injecting that class is strictly better.

Where should the real production connection string live?

Not in appsettings.json. Committing a production account key to source control is a security incident. The standard options are environment variables (set by the hosting platform), Azure Key Vault (a managed secret store with a configuration provider), or User Secrets for local development only. Because all of these are configuration providers, the C# code is identical in every case — only the source of the value changes.

Does adding a queue require code changes?

Adding a new queue requires two edits: one enum member and one JSON key. Changing the name of an existing queue requires only the JSON edit — the enum member is an internal identifier and never appears in Azure. That separation between internal identifier and external name is exactly what the enum-keyed dictionary provides.

What happens if a JSON key is misspelled?

Nothing, at startup. The binder ignores unmatched keys and the property keeps its default. The failure appears later, at the point of use — an empty connection string produces an Azure SDK error, and a missing dictionary entry produces a KeyNotFoundException. For settings where this matters, .NET supports validation on startup via .ValidateOnStart(), which turns a silent misconfiguration into a loud startup failure.

What Was Accomplished in This Part

  • ✅ Connection string and queue name moved out of code and into appsettings.json
  • EQueueNames enum created, eliminating magic strings for queue names
  • AzureStorageSettings class created as a typed home for configuration
  • ✅ Configuration binding rules understood, including the enum-keyed dictionary
  • ✅ The difference between AddSingleton<T>() and Configure<T>() understood, along with why the first one fails silently
  • IOptions<T> understood — what it is, why it wraps, and when its siblings apply
  • ✅ Settings registered in Program.cs with a clean build

What Comes Next

Part 4 designs the models. Two separate data shapes are needed and keeping them separate is a deliberate architectural decision, not an accident of file layout: one representing the message stored inside the queue, one representing the HTTP response the API returns.

That part also covers a question that trips up nearly everyone the first time — an enum is a number in C#, so how does EJobPushStatus.Queued arrive in the HTTP response as the string "Queued" rather than 0?