') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); GitHub - codingdroplets/dotnet-options-pattern-configuration: ASP.NET Core Options Pattern demo: IOptions, IOptionsSnapshot, IOptionsMonitor, Named Options, data annotation validation and hot-reload in .NET 10 Web API. · GitHub
Skip to content

Repository files navigation

dotnet-options-pattern-configuration

Strongly-typed configuration in ASP.NET Core using IOptions, IOptionsSnapshot, IOptionsMonitor, and Named Options — with validation, hot-reload, and change notifications.

Visit CodingDropletsYouTubePatreonBuy Me a CoffeeGitHub


🚀 Support the Channel — Join on Patreon

If this sample saved you time, consider joining our Patreon community. You'll get exclusive .NET tutorials, premium code samples, and early access to new content — all for the price of a coffee.

👉 Join CodingDroplets on Patreon

Prefer a one-time tip? Buy us a coffee ☕


🎯 What You'll Learn

  • How the Options Pattern solves raw IConfiguration access anti-patterns
  • The difference between IOptions, IOptionsSnapshot, and IOptionsMonitor — and when to use each
  • How to use Named Options to configure multiple independent instances of the same class
  • How to add data annotation validation ([Required], [Range], [EmailAddress]) and fail-fast at startup with ValidateOnStart()
  • How to enable hot-reload of configuration without restarting the application
  • How to subscribe to runtime change notifications via IOptionsMonitor.OnChange
  • How to unit test each options interface without a running host

🗺️ Architecture Overview

appsettings.json / appsettings.Development.json
│
▼
IConfiguration (file watcher enabled)
│
├─── Smtp section ──────► IOptions<SmtpOptions> ── Singleton (frozen at startup)
│ │
│ ▼
│ EmailService (Singleton)
│ GET /api/smtp
│
├─── Cache:L1 section ──► IOptionsSnapshot<CacheOptions>("L1") ┐
│ ├─ Scoped, reloads per request
├─── Cache:L2 section ──► IOptionsSnapshot<CacheOptions>("L2") ┘
│ │
│ ▼
│ CacheInfoService (Scoped)
│ GET /api/cache | /api/cache/l1 | /api/cache/l2
│
└─── FeatureFlags section ► IOptionsMonitor<FeatureFlagOptions> ── Singleton + live change events
│
▼
FeatureFlagService (Singleton)
GET /api/featureflags | /api/featureflags/{flag}

📋 Options Interface Comparison

InterfaceLifetimePicks up config reload?When to use
IOptions<T>Singleton❌ NeverSettings that never change at runtime (DB connection strings, JWT secrets)
IOptionsSnapshot<T>Scoped✅ Next requestPer-request settings, named options, feature config that can reload between requests
IOptionsMonitor<T>Singleton✅ ImmediatelySingleton services needing live config, change event callbacks, feature flags
IOptionsFactory<T>Transient✅ AlwaysManual resolution; rarely used directly

📁 Project Structure

dotnet-options-pattern-configuration/
├── dotnet-options-pattern-configuration.sln
│
├── OptionsPatternDemo/ # ASP.NET Core Web API (.NET 10)
│ ├── Controllers/
│ │ ├── SmtpController.cs # GET /api/smtp → IOptions demo
│ │ ├── CacheController.cs # GET /api/cache → IOptionsSnapshot + Named Options
│ │ └── FeatureFlagsController.cs # GET /api/featureflags → IOptionsMonitor demo
│ ├── Options/
│ │ ├── SmtpOptions.cs # Strongly-typed SMTP config with [Required]/[Range]
│ │ ├── CacheOptions.cs # Cache policy config (L1/L2 named instances)
│ │ └── FeatureFlagOptions.cs # Boolean feature flags (hot-reloadable)
│ ├── Services/
│ │ ├── EmailService.cs # Uses IOptions<SmtpOptions>
│ │ ├── CacheInfoService.cs # Uses IOptionsSnapshot<CacheOptions>
│ │ └── FeatureFlagService.cs # Uses IOptionsMonitor<FeatureFlagOptions>
│ ├── Properties/
│ │ └── launchSettings.json # Swagger opens on launch
│ ├── appsettings.json # Production config
│ ├── appsettings.Development.json # Dev overrides (hot-reload enabled)
│ └── Program.cs # DI registrations + Swagger setup
│
└── OptionsPatternDemo.Tests/ # xUnit test project
├── SmtpOptionsTests.cs # Validation + EmailService unit tests (9 tests)
├── CacheOptionsTests.cs # Validation + named options tests (5 tests)
└── FeatureFlagTests.cs # IOptionsMonitor unit tests (2 tests)

🛠️ Prerequisites

RequirementVersion
.NET SDK10.0+
IDEVisual Studio 2022 v17.12+ / JetBrains Rider / VS Code
OSWindows / macOS / Linux

⚡ Quick Start

# 1. Clone the repository
git clone https://github.com/codingdroplets/dotnet-options-pattern-configuration.git
cd dotnet-options-pattern-configuration
# 2. Build the solution
dotnet build -c Release
# 3. Run the APIcd OptionsPatternDemo
dotnet run
# 4. Open Swagger UI# http://localhost:5289/swagger

Visual Studio users: press F5 — the browser will open automatically at /swagger.


🔧 How It Works

1. Define a strongly-typed options class

publicsealedclassSmtpOptions{publicconststringSectionName="Smtp";[Required]publicstringHost{get;set;}=string.Empty;[Range(1,65535)]publicintPort{get;set;}=587;[Required][EmailAddress]publicstringSenderEmail{get;set;}=string.Empty;publicboolEnableSsl{get;set;}=true;}

2. Bind and validate in Program.cs

builder.Services.AddOptions<SmtpOptions>().Bind(builder.Configuration.GetSection(SmtpOptions.SectionName)).ValidateDataAnnotations()// Validates [Required], [Range], [EmailAddress] etc..ValidateOnStart();// Fails fast at startup — not lazily on first use

3. Use IOptions<T> in a Singleton service (frozen at startup)

publicsealedclassEmailService:IEmailService{privatereadonlySmtpOptions_smtp;publicEmailService(IOptions<SmtpOptions>options){_smtp=options.Value;// Captured once — never changes}}

4. Use Named Options + IOptionsSnapshot<T> in a Scoped service

// Register two named instancesbuilder.Services.AddOptions<CacheOptions>("L1").Bind(builder.Configuration.GetSection("Cache:L1"));builder.Services.AddOptions<CacheOptions>("L2").Bind(builder.Configuration.GetSection("Cache:L2"));// Consume in a Scoped servicepublicsealedclassCacheInfoService:ICacheInfoService{privatereadonlyIOptionsSnapshot<CacheOptions>_cacheOptions;publicCacheInfoService(IOptionsSnapshot<CacheOptions>cacheOptions){_cacheOptions=cacheOptions;}publicCachePolicyInfoGetCachePolicy(stringname){varopts=_cacheOptions.Get(name);// "L1" or "L2"returnnewCachePolicyInfo(name,opts.Provider,opts.DefaultTtlSeconds,opts.Enabled,opts.MaxItems);}}

5. Use IOptionsMonitor<T> for live reload in a Singleton

publicsealedclassFeatureFlagService:IFeatureFlagService,IDisposable{privatereadonlyIOptionsMonitor<FeatureFlagOptions>_monitor;privatereadonlyIDisposable?_changeListener;publicFeatureFlagService(IOptionsMonitor<FeatureFlagOptions>monitor,ILogger<FeatureFlagService>logger){_monitor=monitor;// Fires whenever appsettings.json changes on disk_changeListener=_monitor.OnChange((opts,_)=>logger.LogInformation("Feature flags reloaded: BetaApi={BetaApi}",opts.EnableBetaApi));}publicFeatureFlagSnapshotGetFlags(){varflags=_monitor.CurrentValue;// Always latest config — no restart neededreturnnewFeatureFlagSnapshot(flags.EnableDarkMode,flags.EnableBetaApi,flags.EnableAnalytics,flags.EnableNewDashboard);}publicvoidDispose()=>_changeListener?.Dispose();}

📡 API Endpoints

MethodEndpointDescriptionStatus
GET/api/smtpReturns SMTP config via IOptions<T> (singleton snapshot)200 OK
GET/api/cacheReturns both L1 + L2 cache policies200 OK
GET/api/cache/l1Returns L1 (InMemory) cache policy via named options200 OK
GET/api/cache/l2Returns L2 (Redis) cache policy via named options200 OK
GET/api/featureflagsReturns all feature flags via IOptionsMonitor<T>200 OK
GET/api/featureflags/{flag}Returns a single feature flag by name200 OK / 404 Not Found

🧪 Running Tests

dotnet test -c Release
Test ClassTestsWhat's Covered
SmtpOptionsTests9Valid config passes, missing Host fails, invalid email fails, port boundary validation, EmailService unit test
CacheOptionsTests5Valid config passes, missing Provider fails, TTL boundary validation, L1/L2 named options resolution
FeatureFlagTests2All flags disabled, all flags enabled — IOptionsMonitor with fake monitor
Total16All passing ✅

🤔 Key Concepts

Why not just use IConfiguration directly?

// ❌ Anti-pattern — magic strings, no type safety, no validationvarhost=_configuration["Smtp:Host"];varport=int.Parse(_configuration["Smtp:Port"]!);// ✅ Options Pattern — strongly typed, validated, testablevarhost=_smtp.Host;varport=_smtp.Port;

ValidateOnStart vs lazy validation

By default, options are validated the first time .Value is accessed. ValidateOnStart() moves that check to app startup so misconfiguration is caught immediately — before any request is served.

Why IOptionsSnapshot cannot be injected into Singletons

IOptionsSnapshot<T> is registered as Scoped. Injecting a Scoped dependency into a Singleton creates a captive dependency — the snapshot is frozen at the time the Singleton is first resolved and will never update.

Service LifetimeCompatible Interfaces
SingletonIOptions<T>, IOptionsMonitor<T>
ScopedIOptions<T>, IOptionsSnapshot<T>, IOptionsMonitor<T>
TransientAll

🏷️ Technologies Used

  • .NET 10 — target framework
  • ASP.NET Core Web API — controller-based REST API
  • Microsoft.Extensions.Options — IOptions / IOptionsSnapshot / IOptionsMonitor
  • Swashbuckle.AspNetCore 6.x — Swagger / OpenAPI documentation
  • Data Annotations[Required], [Range], [EmailAddress] for options validation
  • xUnit — unit testing framework
  • Microsoft.Extensions.Options.DataAnnotationsValidateDataAnnotations() + ValidateOnStart()

📚 References


📄 License

This project is licensed under the MIT License. See LICENSE for details.


🔗 Connect with CodingDroplets

PlatformLink
🌐 Websitehttps://codingdroplets.com/
📺 YouTubehttps://www.youtube.com/@CodingDroplets
🎁 Patreonhttps://www.patreon.com/CodingDroplets
☕ Buy Me a Coffeehttps://buymeacoffee.com/codingdroplets
💻 GitHubhttp://github.com/codingdroplets/

Want more samples like this?Support us on Patreon or buy us a coffee ☕ — every bit helps keep the content coming!

About

ASP.NET Core Options Pattern demo: IOptions, IOptionsSnapshot, IOptionsMonitor, Named Options, data annotation validation and hot-reload in .NET 10 Web API.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages