Skip to content

Refactor: Migrate from stringly-typed IConfiguration to .NET Options Pattern #38

@sirkirby

Description

@sirkirby

RFC: Migrate from Stringly-Typed IConfiguration to .NET Options Pattern

Status: Proposed
Created: 2025-10-27
Author: System Analysis


Problem Statement

Currently, Ten Second Tom uses string-based configuration lookups throughout the codebase, which is an anti-pattern in modern .NET applications. We inject IConfiguration and use string keys (even with constants) to retrieve values, losing compile-time safety and violating idiomatic .NET practices.

Current Anti-Pattern Example

public class CreateDailyEntryHandler
{
    private readonly IConfiguration _configuration;
    
    public CreateDailyEntryHandler(IConfiguration configuration)
    {
        _configuration = configuration;
    }
    
    public async Task Handle()
    {
        // ❌ String-based lookup - no compile-time safety
        var apiKey = _configuration[ConfigurationKeys.LlmApiKeyKey];
        var provider = _configuration[ConfigurationKeys.LlmProviderKey];
        
        // ❌ Manual parsing required
        if (!Enum.TryParse<LlmProvider>(provider, out var llmProvider))
        {
            // Error handling...
        }
    }
}

Issues:

  • ❌ No compile-time safety (typos caught at runtime)
  • ❌ No IntelliSense support
  • ❌ No validation at startup
  • ❌ Scattered parsing logic across codebase
  • ❌ Not idiomatic .NET (violates framework conventions)
  • ❌ Difficult to test (mocking IConfiguration is cumbersome)

Current State Analysis

Scope: ~45 string-based configuration lookups across 10 files:

  • ConfigurationHelpers.cs (2 lookups)
  • GenerateCommand.cs (2 lookups)
  • ServiceCollectionExtensions.cs (7 lookups)
  • ConfigurationChecker.cs (10 lookups)
  • TemplateMigrationService.cs (1 lookup)
  • GenerateOutputCommandHandler.cs (1 lookup)
  • CreateWeeklyReviewHandler.cs (1 lookup)
  • ConfigCommandHandler.cs (19 lookups)
  • CreateVoiceNoteEntryHandler.cs (1 lookup)
  • CreateDailyEntryHandler.cs (1 lookup)

Configuration Sections:

  1. LLM (Provider, ApiKey, Model, MaxInputTokens)
  2. SSH (KeyPath, KeySource, AgentSocketPath, KeyDisplayName)
  3. Audio (STT provider, recording, preprocessing settings)
  4. Storage (MemoryDirectory, paths)
  5. Optional (LogLevel, RetentionDays, EnableTelemetry)

Proposed Solution: .NET Options Pattern

The Options Pattern is the idiomatic .NET way to handle strongly-typed configuration.

Example After Refactor

// 1. Define strongly-typed options class
public sealed class LlmOptions 
{
    public const string SectionName = "TenSecondTom:Llm";
    
    public required LlmProvider Provider { get; init; }
    public required string ApiKey { get; init; }
    public string? Model { get; init; }
    public int MaxInputTokens { get; init; } = 50000;
}

// 2. Register in DI (Program.cs or DependencyInjection.cs)
services.Configure<LlmOptions>(configuration.GetSection(LlmOptions.SectionName));
services.AddSingleton<IValidateOptions<LlmOptions>, LlmOptionsValidator>();

// 3. Inject strongly-typed options
public class CreateDailyEntryHandler
{
    private readonly LlmOptions _llmOptions;
    
    public CreateDailyEntryHandler(IOptions<LlmOptions> llmOptions)
    {
        _llmOptions = llmOptions.Value;
    }
    
    public async Task Handle()
    {
        // ✅ Strongly typed - compile-time safety
        var apiKey = _llmOptions.ApiKey;
        var provider = _llmOptions.Provider; // Already the enum!
    }
}

Implementation Plan

Phase 1: Create Options Classes (Week 1)

Create strongly-typed options classes in src/Shared/Options/:

// LlmOptions.cs
public sealed class LlmOptions 
{
    public const string SectionName = "TenSecondTom:Llm";
    public required LlmProvider Provider { get; init; }
    public required string ApiKey { get; init; }
    public string? Model { get; init; }
    public int MaxInputTokens { get; init; } = 50000;
}

// SshOptions.cs
public sealed class SshOptions
{
    public const string SectionName = "TenSecondTom:Ssh";
    public string? KeyPath { get; init; }
    public SshKeySource? KeySource { get; init; }
    public string? AgentSocketPath { get; init; }
    public string? KeyDisplayName { get; init; }
}

// AudioOptions.cs (maps to existing AudioConfiguration)
public sealed class AudioOptions
{
    public const string SectionName = "TenSecondTom:Audio";
    // ... existing AudioConfiguration properties
}

// StorageOptions.cs
public sealed class StorageOptions
{
    public const string SectionName = "TenSecondTom:Storage";
    public required string MemoryDirectory { get; init; }
    public bool CreateIfMissing { get; init; } = true;
}

// AppOptions.cs
public sealed class AppOptions
{
    public const string SectionName = "TenSecondTom:Optional";
    public LogLevel LogLevel { get; init; } = LogLevel.Information;
    public int RetentionDays { get; init; } = 30;
    public bool EnableTelemetry { get; init; } = false;
}

Phase 2: Add Validation (Week 1)

Create validators using IValidateOptions<T>:

public sealed class LlmOptionsValidator : IValidateOptions<LlmOptions>
{
    public ValidateOptionsResult Validate(string? name, LlmOptions options)
    {
        if (string.IsNullOrWhiteSpace(options.ApiKey))
            return ValidateOptionsResult.Fail("LLM API key is required");
            
        if (!Enum.IsDefined(options.Provider))
            return ValidateOptionsResult.Fail($"Invalid LLM provider: {options.Provider}");
        
        if (options.MaxInputTokens <= 0)
            return ValidateOptionsResult.Fail("MaxInputTokens must be positive");
            
        return ValidateOptionsResult.Success;
    }
}

Phase 3: Register Options in DI (Week 1)

Update ServiceCollectionExtensions.cs:

public static class OptionsRegistration
{
    public static IServiceCollection AddTenSecondTomOptions(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // Register options
        services.Configure<LlmOptions>(configuration.GetSection(LlmOptions.SectionName));
        services.Configure<SshOptions>(configuration.GetSection(SshOptions.SectionName));
        services.Configure<AudioOptions>(configuration.GetSection(AudioOptions.SectionName));
        services.Configure<StorageOptions>(configuration.GetSection(StorageOptions.SectionName));
        services.Configure<AppOptions>(configuration.GetSection(AppOptions.SectionName));
        
        // Register validators
        services.AddSingleton<IValidateOptions<LlmOptions>, LlmOptionsValidator>();
        services.AddSingleton<IValidateOptions<SshOptions>, SshOptionsValidator>();
        services.AddSingleton<IValidateOptions<StorageOptions>, StorageOptionsValidator>();
        
        return services;
    }
}

Phase 4: Migrate Services (Weeks 2-3)

Migrate incrementally by feature slice (Vertical Slice Architecture):

Priority Order:

  1. Today feature (CreateDailyEntryHandler, CreateVoiceNoteEntryHandler) - 2 files
  2. ThisWeek feature (CreateWeeklyReviewHandler) - 1 file
  3. Generate feature (GenerateCommand, GenerateOutputCommandHandler) - 2 files
  4. Setup feature (ConfigCommandHandler) - 1 file (19 lookups!)
  5. Templates feature (TemplateMigrationService) - 1 file
  6. Infrastructure (ConfigurationChecker, ServiceCollectionExtensions) - 2 files
  7. ConfigurationHelpers - Keep as extension methods for backward compatibility

Per-slice migration steps:

  1. Update constructor to inject IOptions<T> or IOptionsSnapshot<T>
  2. Replace _configuration[key] with _options.Property
  3. Remove manual parsing logic
  4. Update tests to mock IOptions<T> (easier than IConfiguration)
  5. Run tests, verify behavior unchanged

Phase 5: Update Tests (Weeks 2-3)

Update unit tests to use Options pattern:

// Before
var config = new ConfigurationBuilder()
    .AddInMemoryCollection(new Dictionary<string, string>
    {
        ["TenSecondTom:Llm:Provider"] = "OpenAI",
        ["TenSecondTom:Llm:ApiKey"] = "test-key"
    })
    .Build();
var handler = new Handler(config);

// After (much cleaner!)
var options = Options.Create(new LlmOptions 
{ 
    Provider = LlmProvider.OpenAI,
    ApiKey = "test-key"
});
var handler = new Handler(options);

Phase 6: Documentation & Cleanup (Week 3)

  1. Update docs/CONFIGURATION.md with Options pattern examples
  2. Update constitution to mandate Options pattern for new code
  3. Remove or deprecate ConfigurationKeys.cs constants (keep only for backward compatibility)
  4. Add ADR (Architecture Decision Record) documenting the migration

Benefits

Type Safety

  • ✅ Compile-time checks for property names and types
  • ✅ IntelliSense support in IDE
  • ✅ Refactoring tools work correctly

Validation

  • ✅ Configuration validated at startup (fail-fast)
  • ✅ Clear error messages for misconfigurations
  • ✅ Prevents runtime errors from invalid config

Testability

  • ✅ Easy to mock IOptions<T> in tests
  • ✅ No need to build complex IConfiguration objects
  • ✅ Tests are more readable

Maintainability

  • ✅ Configuration schema documented in code (single source of truth)
  • ✅ No scattered parsing logic
  • ✅ Easier to refactor configuration structure

Idiomatic .NET

  • ✅ Follows Microsoft best practices
  • ✅ Aligns with ASP.NET Core conventions
  • ✅ Consistent with modern .NET applications

Risks & Considerations

Breaking Changes

  • Mitigation: Keep ConfigurationHelpers extension methods for backward compatibility during transition
  • Strategy: Migrate incrementally, one feature at a time

Test Updates Required

  • Impact: ~45 test files may need updates
  • Mitigation: Update tests as we migrate each feature slice

Learning Curve

  • Impact: Team needs to understand Options pattern
  • Mitigation: Provide examples and documentation, start with simple features

Configuration File Compatibility

  • Impact: None - JSON structure remains unchanged
  • Note: Options pattern reads the same configuration keys

Success Criteria

  • All 5 Options classes created with validation
  • All 10 affected files migrated to Options pattern
  • Zero string-based configuration lookups in feature code
  • All 1,436 tests pass
  • Documentation updated
  • ADR created

Timeline

  • Week 1: Create Options classes, validators, DI registration
  • Week 2-3: Migrate services incrementally (one slice at a time)
  • Week 3: Update tests, documentation, cleanup

Total Estimated Effort: 2-3 weeks (with test-first approach)

Related Documentation

Decision

Status: PROPOSED - Awaiting approval

Recommended Action: Approve and schedule for implementation in next sprint


Labels: refactor, tech-debt, idiomatic-csharp
Priority: Medium (improves quality, not blocking features)
Effort: 2-3 weeks

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestrefactorCode refactoring and improvementstech-debtTechnical debt that needs addressing

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions