Skip to content

Commit 86bf1cd

Browse files
committed
feat: extend support for Configuration Change Callbacks OptionsBinding
1 parent 04f2c1b commit 86bf1cd

18 files changed

+1606
-160
lines changed

CLAUDE.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ services.AddDependencyRegistrationsFromDomain(
298298
4. `public const string Name`
299299
5. Auto-inferred from class name
300300
- Supports validation: `ValidateDataAnnotations`, `ValidateOnStart`, `ErrorOnMissingKeys` (fail-fast for missing sections), Custom validators (`IValidateOptions<T>`)
301+
- **Configuration change callbacks**: Auto-generated IHostedService for OnChange notifications with Monitor lifetime - perfect for feature flags and runtime configuration updates
301302
- **Named options support**: Multiple configurations of the same options type with different names (e.g., Primary/Secondary email servers)
302303
- Supports lifetime selection: Singleton (IOptions), Scoped (IOptionsSnapshot), Monitor (IOptionsMonitor)
303304
- Requires classes to be declared `partial`
@@ -361,6 +362,47 @@ services.Configure<EmailOptions>("Fallback", configuration.GetSection("Email:Fal
361362
var emailSnapshot = serviceProvider.GetRequiredService<IOptionsSnapshot<EmailOptions>>();
362363
var primaryEmail = emailSnapshot.Get("Primary");
363364
var secondaryEmail = emailSnapshot.Get("Secondary");
365+
366+
// Input with OnChange callback (requires Monitor lifetime):
367+
[OptionsBinding("Features", Lifetime = OptionsLifetime.Monitor, OnChange = nameof(OnFeaturesChanged))]
368+
public partial class FeaturesOptions
369+
{
370+
public bool EnableNewUI { get; set; }
371+
public bool EnableBetaFeatures { get; set; }
372+
373+
internal static void OnFeaturesChanged(FeaturesOptions options, string? name)
374+
{
375+
Console.WriteLine($"[OnChange] EnableNewUI: {options.EnableNewUI}");
376+
Console.WriteLine($"[OnChange] EnableBetaFeatures: {options.EnableBetaFeatures}");
377+
}
378+
}
379+
380+
// Output with OnChange callback (auto-generated IHostedService):
381+
// Generates internal IHostedService class:
382+
internal sealed class FeaturesOptionsMonitorService : IHostedService, IDisposable
383+
{
384+
private readonly IOptionsMonitor<FeaturesOptions> _monitor;
385+
private IDisposable? _changeToken;
386+
387+
public FeaturesOptionsMonitorService(IOptionsMonitor<FeaturesOptions> monitor) => _monitor = monitor;
388+
389+
public Task StartAsync(CancellationToken cancellationToken)
390+
{
391+
_changeToken = _monitor.OnChange(FeaturesOptions.OnFeaturesChanged);
392+
return Task.CompletedTask;
393+
}
394+
395+
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
396+
397+
public void Dispose() => _changeToken?.Dispose();
398+
}
399+
400+
// Generates registration code:
401+
services.AddHostedService<FeaturesOptionsMonitorService>();
402+
services.AddSingleton<IOptionsChangeTokenSource<FeaturesOptions>>(
403+
new ConfigurationChangeTokenSource<FeaturesOptions>(
404+
configuration.GetSection("Features")));
405+
services.Configure<FeaturesOptions>(configuration.GetSection("Features"));
364406
```
365407

366408
**Smart Naming:**
@@ -391,6 +433,10 @@ services.AddOptionsFromDomain(configuration, "DataAccess", "Infrastructure");
391433
- `ATCOPT001` - Options class must be partial (Error)
392434
- `ATCOPT002` - Section name cannot be null or empty (Error)
393435
- `ATCOPT003` - Const section name cannot be null or empty (Error)
436+
- `ATCOPT004` - OnChange requires Monitor lifetime (Error)
437+
- `ATCOPT005` - OnChange not supported with named options (Error)
438+
- `ATCOPT006` - OnChange callback method not found (Error)
439+
- `ATCOPT007` - OnChange callback has invalid signature (Error)
394440

395441
### MappingGenerator
396442

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ services.AddOptionsFromApp(configuration);
319319
- **🧠 Automatic Section Name Inference**: Smart resolution from explicit names, const fields (`SectionName`, `NameTitle`, `Name`), or auto-inferred from class names
320320
- **🔒 Built-in Validation**: Integrated DataAnnotations validation (`ValidateDataAnnotations`) and startup validation (`ValidateOnStart`)
321321
- **🎯 Custom Validation**: Support for `IValidateOptions<T>` for complex business rules beyond DataAnnotations
322+
- **🔔 Configuration Change Callbacks**: Auto-generated IHostedService for OnChange notifications with Monitor lifetime - perfect for feature flags and runtime config updates
322323
- **📛 Named Options**: Multiple configurations of the same options type with different names (e.g., Primary/Secondary email servers)
323324
- **🎯 Explicit Section Paths**: Support for nested sections like `"App:Database"` or `"Services:Email"`
324325
- **📦 Multiple Options Classes**: Register multiple configuration sections in a single assembly with one method call
@@ -385,6 +386,21 @@ public partial class FeatureOptions
385386
public bool EnableNewFeature { get; set; }
386387
}
387388

389+
// Configuration change callbacks - auto-generated IHostedService
390+
[OptionsBinding("Features", Lifetime = OptionsLifetime.Monitor, OnChange = nameof(OnFeaturesChanged))]
391+
public partial class FeaturesOptions
392+
{
393+
public bool EnableNewUI { get; set; }
394+
public bool EnableBetaFeatures { get; set; }
395+
396+
// Called automatically when configuration changes (requires reloadOnChange: true)
397+
internal static void OnFeaturesChanged(FeaturesOptions options, string? name)
398+
{
399+
Console.WriteLine($"[OnChange] EnableNewUI: {options.EnableNewUI}");
400+
Console.WriteLine($"[OnChange] EnableBetaFeatures: {options.EnableBetaFeatures}");
401+
}
402+
}
403+
388404
// Usage in your services:
389405
public class MyService
390406
{
@@ -401,6 +417,10 @@ public class MyService
401417
| ATCOPT001 | Options class must be declared as partial |
402418
| ATCOPT002 | Section name cannot be null or empty |
403419
| ATCOPT003 | Invalid options binding configuration |
420+
| ATCOPT004 | OnChange requires Monitor lifetime |
421+
| ATCOPT005 | OnChange not supported with named options |
422+
| ATCOPT006 | OnChange callback method not found |
423+
| ATCOPT007 | OnChange callback has invalid signature |
404424

405425
---
406426

docs/OptionsBindingGenerators-FeatureRoadmap.md

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,13 @@ This roadmap is based on comprehensive analysis of:
5757
- **Custom validation** - `IValidateOptions<T>` for complex business rules beyond DataAnnotations
5858
- **Named options** - Multiple configurations of the same options type with different names
5959
- **Error on missing keys** - `ErrorOnMissingKeys` fail-fast validation when configuration sections are missing
60+
- **Configuration change callbacks** - `OnChange` callbacks for Monitor lifetime (auto-generates IHostedService)
6061
- **Lifetime selection** - Singleton (`IOptions`), Scoped (`IOptionsSnapshot`), Monitor (`IOptionsMonitor`)
6162
- **Multi-project support** - Assembly-specific extension methods with smart naming
6263
- **Transitive registration** - 4 overloads for automatic/selective assembly registration
6364
- **Partial class requirement** - Enforced at compile time
6465
- **Native AOT compatible** - Zero reflection, compile-time generation
65-
- **Compile-time diagnostics** - Validate partial class, section names
66+
- **Compile-time diagnostics** - Validate partial class, section names, OnChange callbacks (ATCOPT001-007)
6667

6768
---
6869

@@ -74,7 +75,7 @@ This roadmap is based on comprehensive analysis of:
7475
|| [Named Options Support](#2-named-options-support) | 🔴 High |
7576
|| [Post-Configuration Support](#3-post-configuration-support) | 🟡 Medium-High |
7677
|| [Error on Missing Configuration Keys](#4-error-on-missing-configuration-keys) | 🔴 High |
77-
| | [Configuration Change Callbacks](#5-configuration-change-callbacks) | 🟡 Medium |
78+
| | [Configuration Change Callbacks](#5-configuration-change-callbacks) | 🟡 Medium |
7879
|| [Bind Configuration Subsections to Properties](#6-bind-configuration-subsections-to-properties) | 🟡 Medium |
7980
|| [ConfigureAll Support](#7-configureall-support) | 🟢 Low-Medium |
8081
|| [Options Snapshots for Specific Sections](#8-options-snapshots-for-specific-sections) | 🟢 Low-Medium |
@@ -237,6 +238,7 @@ public class DataService
237238
- ⚠️ Named options do NOT support validation chain (ValidateDataAnnotations, ValidateOnStart, Validator)
238239

239240
**Testing**:
241+
240242
- ✅ 8 comprehensive unit tests covering all scenarios
241243
- ✅ Sample project with EmailOptions demonstrating Primary/Secondary/Fallback servers
242244
- ✅ PetStore.Api sample with NotificationOptions (Email/SMS/Push channels)
@@ -350,11 +352,13 @@ services.AddOptions<DatabaseOptions>()
350352
- ⚠️ Named options do NOT support ErrorOnMissingKeys (named options use simpler Configure pattern)
351353

352354
**Testing**:
355+
353356
- ✅ 11 comprehensive unit tests covering all scenarios
354357
- ✅ Sample project updated: DatabaseOptions demonstrates ErrorOnMissingKeys
355358
- ✅ PetStore.Api sample: PetStoreOptions uses ErrorOnMissingKeys for critical configuration
356359

357360
**Best Practices**:
361+
358362
- Always combine with `ValidateOnStart = true` to catch missing configuration at startup
359363
- Use for production-critical configuration (databases, external services, API keys)
360364
- Avoid for optional configuration with reasonable defaults
@@ -364,7 +368,7 @@ services.AddOptions<DatabaseOptions>()
364368
### 5. Configuration Change Callbacks
365369

366370
**Priority**: 🟡 **Medium**
367-
**Status**: ❌ Not Implemented
371+
**Status**: **Implemented**
368372
**Inspiration**: `IOptionsMonitor<T>.OnChange()` pattern
369373

370374
**Description**: Support registering callbacks that execute when configuration changes are detected.
@@ -383,24 +387,70 @@ public partial class FeaturesOptions
383387
public int MaxUploadSizeMB { get; set; } = 10;
384388

385389
// Change callback - signature: static void OnChange(TOptions options, string? name)
386-
private static void OnFeaturesChanged(FeaturesOptions options, string? name)
390+
internal static void OnFeaturesChanged(FeaturesOptions options, string? name)
387391
{
388392
Console.WriteLine($"Features configuration changed: EnableNewUI={options.EnableNewUI}");
389393
// Clear caches, notify components, etc.
390394
}
391395
}
392396

393397
// Generated code:
394-
var monitor = services.BuildServiceProvider().GetRequiredService<IOptionsMonitor<FeaturesOptions>>();
395-
monitor.OnChange((options, name) => FeaturesOptions.OnFeaturesChanged(options, name));
398+
services.AddOptions<FeaturesOptions>()
399+
.Bind(configuration.GetSection("Features"));
400+
401+
services.AddHostedService<FeaturesOptionsChangeListener>();
402+
403+
// Generated hosted service
404+
internal sealed class FeaturesOptionsChangeListener : IHostedService
405+
{
406+
private readonly IOptionsMonitor<FeaturesOptions> _monitor;
407+
private IDisposable? _changeToken;
408+
409+
public FeaturesOptionsChangeListener(IOptionsMonitor<FeaturesOptions> monitor)
410+
{
411+
_monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
412+
}
413+
414+
public Task StartAsync(CancellationToken cancellationToken)
415+
{
416+
_changeToken = _monitor.OnChange((options, name) =>
417+
FeaturesOptions.OnFeaturesChanged(options, name));
418+
return Task.CompletedTask;
419+
}
420+
421+
public Task StopAsync(CancellationToken cancellationToken)
422+
{
423+
_changeToken?.Dispose();
424+
return Task.CompletedTask;
425+
}
426+
}
396427
```
397428

398-
**Implementation Notes**:
429+
**Implementation Details**:
430+
431+
- ✅ Added `OnChange` property to `[OptionsBinding]` attribute
432+
- ✅ Generator creates `IHostedService` that registers the callback via `IOptionsMonitor<T>.OnChange()`
433+
- ✅ Hosted service is automatically registered when application starts
434+
- ✅ Callback signature: `static void MethodName(TOptions options, string? name)`
435+
- ✅ Callback method can be `internal` or `public` (not `private`)
436+
- ✅ Properly disposes change token in `StopAsync` to prevent memory leaks
437+
- ✅ Only applicable when `Lifetime = OptionsLifetime.Monitor`
438+
- ⚠️ Cannot be used with named options
439+
- ✅ Comprehensive compile-time validation with 4 diagnostic codes (ATCOPT004-007)
440+
- **Limitation**: Only works with file-based configuration providers (appsettings.json with reloadOnChange: true)
441+
442+
**Diagnostics**:
443+
444+
- **ATCOPT004**: OnChange callback requires Monitor lifetime
445+
- **ATCOPT005**: OnChange callback not supported with named options
446+
- **ATCOPT006**: OnChange callback method not found
447+
- **ATCOPT007**: OnChange callback method has invalid signature
448+
449+
**Testing**:
399450

400-
- Only applicable when `Lifetime = OptionsLifetime.Monitor`
401-
- Callback signature: `static void OnChange(TOptions options, string? name)`
402-
- Useful for feature flags, dynamic configuration
403-
- **Limitation**: Only works with file-based configuration providers (appsettings.json)
451+
- ✅ 20 comprehensive unit tests covering all scenarios and error cases
452+
- ✅ Sample project updated: LoggingOptions demonstrates OnChange callbacks
453+
- ✅ PetStore.Api sample: FeaturesOptions uses OnChange for feature flag changes
404454

405455
---
406456

0 commit comments

Comments
 (0)