Configuration Guide
This guide covers configuration management in the BookStore application, including the Options pattern, validation, and best practices.
Overview
The BookStore application uses the Options pattern for strongly-typed configuration with:
- Type-safe access to configuration values
- Validation on startup to catch configuration errors early
- Data annotations for declarative validation
- Custom validation for complex business rules
Configuration Files
appsettings.json
Main configuration file for the application:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Marten": "Information",
"Wolverine": "Information",
"BookStore": "Information"
},
"Console": {
"FormatterName": "json",
"FormatterOptions": {
"IncludeScopes": true,
"TimestampFormat": "yyyy-MM-ddTHH:mm:ss.fffZ",
"UseUtcTimestamp": true,
"JsonWriterOptions": {
"Indented": false
}
}
}
},
"AllowedHosts": "*",
"Pagination": {
"DefaultPageSize": 20,
"MaxPageSize": 100
},
"Localization": {
"DefaultCulture": "en",
"SupportedCultures": ["pt", "en", "fr", "de", "es"]
}
}
appsettings.Development.json
Development-specific overrides:
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information"
}
}
}
Environment-Specific Configuration
Configuration files are loaded in order:
appsettings.json(base configuration)appsettings.{Environment}.json(environment-specific)- User secrets (Development only)
- Environment variables
- Command-line arguments
Options Pattern
Creating an Options Class
Options classes are strongly-typed representations of configuration sections.
Example: PaginationOptions
using System.ComponentModel.DataAnnotations;
namespace BookStore.ApiService.Models;
/// <summary>
/// Configuration options for pagination
/// </summary>
public sealed record PaginationOptions : IValidatableObject
{
/// <summary>
/// Configuration section name
/// </summary>
public const string SectionName = "Pagination";
/// <summary>
/// Default value for page size when not specified
/// </summary>
public const int DefaultPageSizeValue = 20;
/// <summary>
/// Default value for maximum number of items allowed per page
/// </summary>
public const int MaxPageSizeValue = 100;
/// <summary>
/// Default page size when not specified
/// </summary>
[Range(1, 1000, ErrorMessage = "DefaultPageSize must be between 1 and 1000")]
public int DefaultPageSize { get; init; } = DefaultPageSizeValue;
/// <summary>
/// Maximum number of items allowed per page
/// </summary>
[Range(1, 1000, ErrorMessage = "MaxPageSize must be between 1 and 1000")]
public int MaxPageSize { get; init; } = MaxPageSizeValue;
/// <summary>
/// Validates that DefaultPageSize is less than or equal to MaxPageSize
/// </summary>
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (DefaultPageSize > MaxPageSize)
{
yield return new ValidationResult(
$"DefaultPageSize ({DefaultPageSize}) cannot be greater than MaxPageSize ({MaxPageSize})",
[nameof(DefaultPageSize), nameof(MaxPageSize)]);
}
}
}
Key Features:
- Sealed record - Immutable with value-based equality
- SectionName constant - References the configuration section
- Default values - Provides fallbacks
- Data annotations - Declarative validation (
[Range],[Required], etc.) - IValidatableObject - Complex cross-property validation
Note
Record vs Class for Configuration
The BookStore project uses sealed record with explicit properties for configuration options, consistent with the use of records for DTOs, commands, and events throughout the codebase.
Current approach (explicit properties):
public sealed record PaginationOptions : IValidatableObject
{
[Range(1, 1000)]
public int DefaultPageSize { get; init; } = 20;
[Range(1, 1000)]
public int MaxPageSize { get; init; } = 100;
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (DefaultPageSize > MaxPageSize)
{
yield return new ValidationResult(
$"DefaultPageSize cannot be greater than MaxPageSize",
[nameof(DefaultPageSize), nameof(MaxPageSize)]);
}
}
}
Alternative approach (primary constructor with attributes):
You can add attributes to primary constructor parameters (since C# 11):
public sealed record PaginationOptions(
[property: Range(1, 1000)] int DefaultPageSize = 20,
[property: Range(1, 1000)] int MaxPageSize = 100)
{
// But IValidatableObject is harder to implement cleanly
}
Why the project uses explicit properties:
- Readability - Clearer and more familiar syntax
- IValidatableObject - Easier to implement complex validation
- XML documentation - Can add
<summary>tags to each property - Consistency - Matches the pattern used in DTOs (which also use explicit properties)
- Flexibility - Easier to add computed properties or additional logic
Trade-offs:
| Aspect | Explicit Properties | Primary Constructor |
|---|---|---|
| Conciseness | More verbose | Very concise |
| Attributes | Natural syntax | Requires [property:] target |
| XML docs | Easy (<summary> per property) |
Harder (on parameters) |
| IValidatableObject | Easy to implement | Awkward to implement |
| Computed properties | Natural | Requires separate declaration |
Both approaches work perfectly with the Options pattern and IConfiguration.Bind(). The choice is stylistic and based on your team's preferences.
Registering Options
Register options in Program.cs or extension methods:
services.AddOptions<PaginationOptions>()
.Bind(configuration.GetSection(PaginationOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
Explanation:
AddOptions<T>()- Registers the options typeBind()- Binds configuration section to the options classValidateDataAnnotations()- Enables data annotation validationValidateOnStart()- Validates configuration at application startup (fails fast)
Validation
Data Annotation Validation
Use standard validation attributes for simple validation:
public sealed record LocalizationOptions : IValidatableObject
{
[Required(ErrorMessage = "DefaultCulture is required")]
[MinLength(2, ErrorMessage = "DefaultCulture must be at least 2 characters")]
[ValidCulture]
public string DefaultCulture { get; init; } = "en-US";
[Required(ErrorMessage = "SupportedCultures is required")]
[MinLength(1, ErrorMessage = "At least one supported culture must be specified")]
[ValidCulture]
public string[] SupportedCultures { get; init; } = ["en-US"];
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (!SupportedCultures.Contains(DefaultCulture, StringComparer.OrdinalIgnoreCase))
{
yield return new ValidationResult(
$"DefaultCulture '{DefaultCulture}' must be included in SupportedCultures",
[nameof(DefaultCulture), nameof(SupportedCultures)]);
}
}
}
Common Validation Attributes:
[Required]- Value must be provided[Range(min, max)]- Numeric range validation[MinLength(n)]/[MaxLength(n)]- String/array length validation[RegularExpression(pattern)]- Pattern matching[EmailAddress],[Url],[Phone]- Format validation
Custom Validation Attributes
Create custom validation attributes for reusable validation logic:
using System.ComponentModel.DataAnnotations;
namespace BookStore.ApiService.Infrastructure;
/// <summary>
/// Validates that a string is a valid culture identifier
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)]
public sealed class ValidCultureAttribute : ValidationAttribute
{
public ValidCultureAttribute()
: base("The value '{0}' is not a valid culture identifier")
{
}
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
if (value is null)
{
return ValidationResult.Success;
}
if (value is string culture)
{
if (string.IsNullOrWhiteSpace(culture))
{
return new ValidationResult(
"Culture identifier cannot be empty",
[validationContext.MemberName!]);
}
if (!CultureCache.IsValidCultureName(culture))
{
return new ValidationResult(
FormatErrorMessage(validationContext.DisplayName),
[validationContext.MemberName!]);
}
return ValidationResult.Success;
}
if (value is IEnumerable<string> cultures)
{
var invalidCodes = CultureCache.GetInvalidCodes(cultures);
if (invalidCodes.Count > 0)
{
return new ValidationResult(
$"The following culture identifiers are invalid: {string.Join(", ", invalidCodes)}",
[validationContext.MemberName!]);
}
return ValidationResult.Success;
}
return new ValidationResult(
$"The value must be a string or IEnumerable<string>, but was {value.GetType().Name}",
[validationContext.MemberName!]);
}
}
Usage:
[ValidCulture]
public string DefaultCulture { get; set; } = "en-US";
[ValidCulture]
public string[] SupportedCultures { get; set; } = ["en-US"];
IValidatableObject
Implement IValidatableObject for complex validation that involves multiple properties:
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
// Cross-property validation
if (DefaultPageSize > MaxPageSize)
{
yield return new ValidationResult(
$"DefaultPageSize ({DefaultPageSize}) cannot be greater than MaxPageSize ({MaxPageSize})",
[nameof(DefaultPageSize), nameof(MaxPageSize)]);
}
// Business rule validation
if (MaxPageSize > 1000)
{
yield return new ValidationResult(
"MaxPageSize cannot exceed 1000 for performance reasons",
[nameof(MaxPageSize)]);
}
}
ValidateOnStart
Rule: Always use ValidateOnStart() for production applications.
services.AddOptions<PaginationOptions>()
.Bind(configuration.GetSection(PaginationOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart(); // ✅ Validates at startup
Benefits:
- ✅ Fail fast - Catches configuration errors at startup, not at runtime
- ✅ Clear error messages - Shows exactly what's wrong with the configuration
- ✅ Prevents deployment issues - Invalid configuration prevents the app from starting
Without ValidateOnStart:
services.AddOptions<PaginationOptions>()
.Bind(configuration.GetSection(PaginationOptions.SectionName))
.ValidateDataAnnotations();
// ❌ Validation only happens when options are first accessed
Accessing Configuration
Using IOptions
Inject IOptions<T> to access configuration:
public class BookEndpoints
{
static async Task<Ok<PagedListDto<BookDto>>> SearchBooks(
IQuerySession session,
IOptions<PaginationOptions> paginationOptions, // ✅ Inject IOptions<T>
PagedRequest request)
{
var paging = request.Normalize(paginationOptions.Value);
// Use paginationOptions.Value to access the configuration
...
}
}
IOptions vs IOptionsSnapshot vs IOptionsMonitor
| Type | Lifetime | Reloads Config | Use Case |
|---|---|---|---|
IOptions<T> |
Singleton | No | Static configuration that doesn't change |
IOptionsSnapshot<T> |
Scoped | Yes (per request) | Configuration that may change between requests |
IOptionsMonitor<T> |
Singleton | Yes (real-time) | Configuration that changes during runtime |
Recommendation: Use IOptions<T> for most cases. The BookStore application uses static configuration that doesn't change at runtime.
Configuration Best Practices
1. Use Strongly-Typed Options
✅ Correct:
public sealed record PaginationOptions
{
public int DefaultPageSize { get; init; } = 20;
public int MaxPageSize { get; init; } = 100;
}
// Usage
services.AddOptions<PaginationOptions>()
.Bind(configuration.GetSection("Pagination"));
❌ Incorrect:
// Direct configuration access - not type-safe
var defaultPageSize = configuration.GetValue<int>("Pagination:DefaultPageSize");
2. Always Validate Configuration
✅ Correct:
services.AddOptions<PaginationOptions>()
.Bind(configuration.GetSection(PaginationOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart(); // ✅ Validates at startup
❌ Incorrect:
services.AddOptions<PaginationOptions>()
.Bind(configuration.GetSection(PaginationOptions.SectionName));
// ❌ No validation - errors discovered at runtime
3. Provide Default Values
✅ Correct:
public sealed record PaginationOptions
{
public int DefaultPageSize { get; init; } = 20; // ✅ Default value
public int MaxPageSize { get; init; } = 100; // ✅ Default value
}
❌ Incorrect:
public sealed record PaginationOptions
{
public int DefaultPageSize { get; init; } // ❌ No default - could be 0
public int MaxPageSize { get; init; } // ❌ No default - could be 0
}
4. Use Constants for Section Names
✅ Correct:
public sealed record PaginationOptions
{
public const string SectionName = "Pagination"; // ✅ Constant
}
// Usage
services.AddOptions<PaginationOptions>()
.Bind(configuration.GetSection(PaginationOptions.SectionName));
❌ Incorrect:
// ❌ Magic string - prone to typos
services.AddOptions<PaginationOptions>()
.Bind(configuration.GetSection("Pagination"));
5. Document Configuration Options
✅ Correct:
/// <summary>
/// Configuration options for pagination
/// </summary>
/// <remarks>
/// Configure in appsettings.json:
/// <code>
/// {
/// "Pagination": {
/// "DefaultPageSize": 20,
/// "MaxPageSize": 100
/// }
/// }
/// </code>
/// </remarks>
public sealed class PaginationOptions
{
/// <summary>
/// Default page size when not specified
/// </summary>
[Range(1, 1000, ErrorMessage = "DefaultPageSize must be between 1 and 1000")]
public int DefaultPageSize { get; init; } = 20;
}
6. Use Sealed Classes
✅ Correct:
public sealed record PaginationOptions // ✅ Sealed
{
...
}
❌ Incorrect:
public record PaginationOptions // ❌ Not sealed - can be inherited
{
...
}
Reason: Options should be sealed to prevent inheritance and ensure immutability.
7. Use Init-Only Properties
✅ Correct:
public int DefaultPageSize { get; init; } = 20; // ✅ Init-only
❌ Incorrect:
public int DefaultPageSize { get; set; } = 20; // ❌ Mutable
Reason: Configuration should be immutable after binding.
Common Configuration Patterns
Pagination Configuration
{
"Pagination": {
"DefaultPageSize": 20,
"MaxPageSize": 100
}
}
public sealed class PaginationOptions : IValidatableObject
{
public const string SectionName = "Pagination";
[Range(1, 1000)]
public int DefaultPageSize { get; init; } = 20;
[Range(1, 1000)]
public int MaxPageSize { get; init; } = 100;
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (DefaultPageSize > MaxPageSize)
{
yield return new ValidationResult(
$"DefaultPageSize cannot be greater than MaxPageSize",
[nameof(DefaultPageSize), nameof(MaxPageSize)]);
}
}
}
Localization Configuration
{
"Localization": {
"DefaultCulture": "en",
"SupportedCultures": ["pt", "en", "fr", "de", "es"]
}
}
public class LocalizationOptions : IValidatableObject
{
public const string SectionName = "Localization";
[Required]
[MinLength(2)]
[ValidCulture]
public string DefaultCulture { get; set; } = "en-US";
[Required]
[MinLength(1)]
[ValidCulture]
public string[] SupportedCultures { get; set; } = ["en-US"];
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (!SupportedCultures.Contains(DefaultCulture, StringComparer.OrdinalIgnoreCase))
{
yield return new ValidationResult(
$"DefaultCulture must be included in SupportedCultures",
[nameof(DefaultCulture), nameof(SupportedCultures)]);
}
}
}
Logging Configuration
Structured Logging
The BookStore application uses structured JSON logging:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Marten": "Information",
"Wolverine": "Information",
"BookStore": "Information"
},
"Console": {
"FormatterName": "json",
"FormatterOptions": {
"IncludeScopes": true,
"TimestampFormat": "yyyy-MM-ddTHH:mm:ss.fffZ",
"UseUtcTimestamp": true,
"JsonWriterOptions": {
"Indented": false
}
}
}
}
}
Key Settings:
- FormatterName:
"json"for structured logging - TimestampFormat: ISO 8601 format with milliseconds
- UseUtcTimestamp: Always use UTC (consistent with time standards)
- IncludeScopes: Include logging scopes for correlation
Environment Variables
Override configuration using environment variables:
# Format: {SectionName}__{PropertyName}
export Pagination__DefaultPageSize=50
export Pagination__MaxPageSize=200
export Localization__DefaultCulture=pt
# Run the application
dotnet run
Naming Convention:
- Use double underscore (
__) to separate section and property names - Case-insensitive (but use PascalCase for consistency)
User Secrets (Development)
Store sensitive configuration in user secrets during development:
# Initialize user secrets
dotnet user-secrets init --project src/BookStore.ApiService
# Set a secret
dotnet user-secrets set "ConnectionStrings:bookstore" "Host=localhost;Database=bookstore;Username=postgres;Password=secret"
# List secrets
dotnet user-secrets list --project src/BookStore.ApiService
Warning
User secrets are for development only. Use environment variables, Azure Key Vault, or other secure storage for production.
Troubleshooting
Configuration Not Loading
Symptom: Options have default values instead of configured values.
Solution: Check the section name matches exactly:
// ✅ Correct - matches "Pagination" in appsettings.json
.Bind(configuration.GetSection("Pagination"))
// ❌ Incorrect - case mismatch
.Bind(configuration.GetSection("pagination"))
Validation Errors at Startup
Symptom: Application fails to start with validation error.
Solution: Check the error message and fix the configuration:
System.ComponentModel.DataAnnotations.ValidationException:
DefaultPageSize (150) cannot be greater than MaxPageSize (100)
Fix in appsettings.json:
{
"Pagination": {
"DefaultPageSize": 20, // ✅ Fixed - less than MaxPageSize
"MaxPageSize": 100
}
}
Options Always Null
Symptom: IOptions<T>.Value is null or has default values.
Solution: Ensure options are registered:
// ✅ Register options
services.AddOptions<PaginationOptions>()
.Bind(configuration.GetSection(PaginationOptions.SectionName));
Summary
Configuration Best Practices:
- ✅ Use strongly-typed options classes
- ✅ Always validate with
ValidateDataAnnotations()andValidateOnStart() - ✅ Provide sensible default values
- ✅ Use constants for section names
- ✅ Document configuration options with XML comments
- ✅ Use sealed classes for options
- ✅ Use init-only properties for immutability
- ✅ Implement
IValidatableObjectfor complex validation - ✅ Create custom validation attributes for reusable logic
- ✅ Use environment variables for environment-specific overrides
Configuration Loading Order:
appsettings.jsonappsettings.{Environment}.json- User secrets (Development)
- Environment variables
- Command-line arguments
These practices ensure:
- Type safety - Compile-time checking of configuration access
- Early error detection - Validation at startup prevents runtime errors
- Maintainability - Clear, documented configuration structure
- Testability - Easy to mock and test with different configurations