Most .NET production failures trace back to three causes: scattered configuration, unsafe null handling, and poorly wired dependency injection. These aren’t edge cases — they’re the root of many runtime crashes. If you’ve ever seen your ASP.NET Core app crash on startup with a missing connection string, this article is for you.
We’ll start simple (Options Pattern basics) and move toward advanced patterns (validation, DI choices, testing, and environment configs). Whether you’re fixing your first configuration bug or architecting enterprise systems, there’s something here for you.
Short on time? Skip ahead to the Dependency Injection section or Testing section.
Beginner Fix: Stop Using IConfiguration Directly
Let me show you the code that’s probably sitting in your ASP.NET Core project right now:
public class DatabaseService
{
private readonly IConfiguration _configuration;
public DatabaseService(IConfiguration configuration)
{
_configuration = configuration;
}
public async Task<User> GetUserAsync(int id)
{
var connectionString = _configuration["ConnectionStrings:Database"];
// What happens if this is null? 💥
using var connection = new SqlConnection(connectionString);
// ... rest of method
}
}
Why This Breaks in Production:
- No type safety: Typo in the key? Silent null return
- No validation: Missing section in config? Runtime crash
- Scattered config access: Every service needs to know configuration structure
- Impossible to test: How do you mock
IConfiguration["some:nested:key"]?
The Options Pattern Solution
Instead of scattered string-based lookups, create strongly-typed classes:
public class DatabaseSettings
{
public const string SectionName = "Database";
public string ConnectionString { get; set; } = string.Empty;
public int CommandTimeout { get; set; } = 30;
public bool EnableRetry { get; set; } = true;
public int MaxRetryCount { get; set; } = 3;
}
Configure it in Program.cs:
var builder = WebApplication.CreateBuilder(args);
// Bind configuration sections to strongly-typed classes
builder.Services.Configure<DatabaseSettings>(
builder.Configuration.GetSection(DatabaseSettings.SectionName));
// Register your services
builder.Services.AddScoped<DatabaseService>();
var app = builder.Build();
Now your service becomes clean and testable:
public class DatabaseService
{
private readonly DatabaseSettings _settings;
// Clean, typed dependency - no IConfiguration needed!
public DatabaseService(IOptions<DatabaseSettings> options)
{
_settings = options.Value;
}
public async Task<User> GetUserAsync(int id)
{
// Type-safe, IntelliSense-friendly
using var connection = new SqlConnection(_settings.ConnectionString);
connection.CommandTimeout = _settings.CommandTimeout;
// ... rest of method
}
}
💡 If you’re just starting out, this Options Pattern alone eliminates 80% of configuration bugs. For bulletproof validation and advanced patterns, keep reading.
Intermediate: Null Safety & Validation
The Options pattern gives you type safety, but what happens when someone deploys with missing configuration? Professional applications validate configuration at startup — not at runtime.
DataAnnotations Validation (Recommended)
Add validation attributes to your settings classes:
public class DatabaseSettings
{
public const string SectionName = "Database";
[Required]
[MinLength(10, ErrorMessage = "Connection string too short")]
public string ConnectionString { get; set; } = string.Empty;
[Range(1, 300)]
public int CommandTimeout { get; set; } = 30;
public bool EnableRetry { get; set; } = true;
[Range(1, 10)]
public int MaxRetryCount { get; set; } = 3;
}
Enable validation in Program.cs:
builder.Services.Configure<DatabaseSettings>(
builder.Configuration.GetSection(DatabaseSettings.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart(); // Fail fast at application startup
Constructor Guards for Critical Settings
For mission-critical configuration, add runtime validation:
public class PaymentService
{
private readonly PaymentSettings _settings;
public PaymentService(IOptions<PaymentSettings> options)
{
_settings = options.Value;
// Validate critical settings in constructor
if (string.IsNullOrEmpty(_settings.ApiKey))
throw new InvalidOperationException("Payment API Key is not configured");
if (string.IsNullOrEmpty(_settings.WebhookSecret))
throw new InvalidOperationException("Payment webhook secret is not configured");
}
}
Key takeaway: Validation prevents bad deployments and gives immediate feedback when configuration is wrong.
Advanced: Dependency Injection Choices
Not all configuration needs are the same. Choose the right Options interface for your use case:
IOptions<T> – Singleton (Most Common)
- Lifetime: Loaded once at startup
- Use When: Configuration never changes (DB connections, API keys)
- Example:
// Configuration read once at startup
public class DatabaseService
{
public DatabaseService(IOptions<DatabaseSettings> options)
{
// Settings never change during app lifetime
}
}
IOptionsSnapshot<T> – Scoped (Per Request)
- Lifetime: Reloaded for each request
- Use When: Config might differ per request (feature flags, user prefs)
- Example:
// Configuration can change between requests
public class FeatureService
{
private readonly IOptionsSnapshot<FeatureSettings> _options;
public FeatureService(IOptionsSnapshot<FeatureSettings> options)
{
_options = options;
}
public bool IsFeatureEnabled(string feature)
{
// Fresh settings for each request
return _options.Value.EnabledFeatures.Contains(feature);
}
}
IOptionsMonitor<T> – Monitored (Real-Time Updates)
- Lifetime: Updates instantly on config change
- Use When: Need live reload without app restart (cache, logging levels)
- Example:
// Reacts to configuration file changes
public class CacheService
{
private readonly IOptionsMonitor<CacheSettings> _options;
public CacheService(IOptionsMonitor<CacheSettings> options)
{
_options = options;
// React to configuration changes without restart
_options.OnChange(settings => ReconfigureCache(settings));
}
}
💡 Key takeaway: Use IOptions<T> for 90% of cases. Reach for Snapshot/Monitor only when config must change dynamically.
Advanced: Environment-Specific Configuration
Professional .NET applications handle multiple environments seamlessly. ASP.NET Core automatically merges configuration files based on the ASPNETCORE_ENVIRONMENT variable:
appsettings.json (base settings):
{
"Database": {
"CommandTimeout": 30,
"EnableRetry": true,
"MaxRetryCount": 3
},
"Logging": {
"LogLevel": {
"Default": "Information"
}
}
}
appsettings.Development.json (dev overrides):
{
"Database": {
"ConnectionString": "Server=localhost;Database=MyApp_Dev;",
"CommandTimeout": 5
},
"Logging": {
"LogLevel": {
"Default": "Debug"
}
}
}
appsettings.Production.json (prod overrides):
{
"Database": {
"ConnectionString": "Server=prod-server;Database=MyApp_Prod;",
"CommandTimeout": 60,
"MaxRetryCount": 5
}
}
The framework automatically merges these — development settings override base settings, production overrides both. Your code doesn’t change, just the configuration values.
Key takeaway: Environment-specific config files let you deploy the same code with different settings across environments.
Advanced: Testing Configuration
Configuration is often left untested, but it’s one of the most failure-prone parts of your application. Here’s how to unit test services that depend on configuration:
Mocking Valid Configuration
[Test]
public async Task GetUserAsync_ValidId_ReturnsUser()
{
// Arrange
var mockSettings = Options.Create(new DatabaseSettings
{
ConnectionString = "test-connection",
CommandTimeout = 10
});
var service = new DatabaseService(mockSettings);
// Act & Assert
var result = await service.GetUserAsync(123);
Assert.NotNull(result);
}
Testing Invalid Configuration
[Test]
public void Constructor_InvalidSettings_ThrowsException()
{
// Arrange
var invalidSettings = Options.Create(new PaymentSettings
{
ApiKey = "", // Invalid!
WebhookSecret = "valid-secret"
});
// Act & Assert
Assert.Throws<InvalidOperationException>(() =>
new PaymentService(invalidSettings));
}
Key takeaway: Test both valid configuration scenarios and invalid ones to catch deployment issues early.
Complete Example: Email Service
Here’s everything working together in a production-ready email service:
// Settings with validation
public class EmailSettings
{
public const string SectionName = "Email";
[Required]
public string SmtpServer { get; set; } = string.Empty;
[Range(1, 65535)]
public int Port { get; set; } = 587;
[Required]
[EmailAddress]
public string FromAddress { get; set; } = string.Empty;
public bool EnableSsl { get; set; } = true;
}
// Service implementation
public class EmailService
{
private readonly EmailSettings _settings;
private readonly ILogger<EmailService> _logger;
public EmailService(IOptions<EmailSettings> options, ILogger<EmailService> logger)
{
_settings = options.Value;
_logger = logger;
// Additional validation for critical settings
if (string.IsNullOrEmpty(_settings.SmtpServer))
throw new InvalidOperationException("SMTP server is not configured");
}
public async Task SendEmailAsync(string to, string subject, string body)
{
_logger.LogInformation("Sending email to {To} via {SmtpServer}:{Port}",
to, _settings.SmtpServer, _settings.Port);
// Implementation using _settings properties
// All connection details, timeouts, SSL settings come from config
}
}
// Program.cs setup
var builder = WebApplication.CreateBuilder(args);
// Configure with full validation
builder.Services.Configure<EmailSettings>(
builder.Configuration.GetSection(EmailSettings.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
// Register service
builder.Services.AddScoped<EmailService>();
var app = builder.Build();
This is configuration done right — type-safe, validated, testable, and production-ready.
Production Health Checklist
Self-Audit: How Production-Ready Is Your Configuration?
Beginner Level
- Type Safety: All configuration uses strongly-typed classes
- No Direct Access: No direct
IConfigurationaccess in business services - Constants: Configuration keys are constants, not magic strings
Intermediate Level
- Validation: Required settings validated at startup
- Fail Fast: Application fails with clear error messages for bad config
- Proper DI: Services receive
IOptions<T>, notIConfiguration
Advanced Level
- Environment Support: Base configuration + environment-specific overrides
- Testing: Configuration can be mocked and validated in tests
- Operations: Support teams can understand configuration errors
The Professional Developer Mindset
Here’s what separates professional .NET developers when it comes to configuration:
They think about failure modes. What happens when config is missing? When it’s invalid? When environments change?
They design for testability. Can you unit test this service? Can you easily mock the configuration?
They plan for operations. Can support teams understand what’s misconfigured from error messages? Can you change settings without redeployment?
The Options pattern combined with proper validation and dependency injection isn’t just about cleaner code — it’s about building .NET applications that behave predictably under all conditions. This isn’t about perfect code, it’s about making your apps reliable in production.


















