-
Notifications
You must be signed in to change notification settings - Fork 5.4k
Description
Background and motivation
System.Text.Json provides a family of callback interfaces — IJsonOnDeserialized, IJsonOnDeserializing, IJsonOnSerialized, IJsonOnSerializing — that allow types to run logic at well-defined points during serialization. The most commonly used of these is IJsonOnDeserialized, which lets an options type validate, transform, or augment its own state immediately after deserialization completes.
The configuration binding system in Microsoft.Extensions.Configuration has no equivalent mechanism. When an options class needs post-bind processing — applying defaults, computing derived values, validating invariants, converting raw bound values into richer representations — developers are forced to scatter that logic across IConfigureOptions<T>, IPostConfigureOptions<T>, IValidateOptions<T>, or ad-hoc OnConfigurationLoaded() methods called manually. This has several drawbacks:
- Indirection — The post-processing logic lives outside the options type, making it harder to understand the type as a self-contained unit.
- Fragility — Nothing enforces that the post-processing actually runs. Forgetting to register the right
IPostConfigureOptions<T>or call the manual method silently produces an incompletely initialized object. - Parity gap — Users who already use
IJsonOnDeserializedin their types reasonably expect the configuration binder to honor it (or provide an equivalent). It does neither, and this behavior is not documented.
A first-class callback interface would make post-bind logic self-contained, discoverable, and impossible to forget.
Related issues: #58384, #36010
API Proposal
namespace Microsoft.Extensions.Configuration;
/// <summary>
/// Specifies that the type should have its <see cref="OnBound"/> method called
/// after configuration binding has populated all eligible properties.
/// </summary>
public interface IConfigurationOnBound
{
/// <summary>
/// The method that is called after configuration binding completes.
/// </summary>
void OnBound();
}Optionally, a symmetric pre-bind interface could be added in the same pass:
namespace Microsoft.Extensions.Configuration;
/// <summary>
/// Specifies that the type should have its <see cref="OnBinding"/> method called
/// before configuration binding populates properties.
/// </summary>
public interface IConfigurationOnBinding
{
/// <summary>
/// The method that is called before configuration binding begins.
/// </summary>
void OnBinding();
}API Usage
Post-bind defaults and value conversion
public class CsvIngestionOptions : IConfigurationOnBound
{
/// <summary>
/// Raw configuration section for sparse JSON binding.
/// </summary>
[ConfigurationKeyName(nameof(DefaultFormat))]
public IConfigurationSection? DefaultFormatSection { get; set; }
/// <summary>
/// Processed CSV format options, assembled from the raw section
/// with defaults applied.
/// </summary>
[ConfigurationIgnore]
public CsvFormatOptions DefaultFormat { get; set; } = new();
public void OnBound()
{
DefaultFormatSection?.Bind(DefaultFormat);
DefaultFormat.EnsureEncodingDefined();
}
}Post-bind validation
public class RetryPolicyOptions : IConfigurationOnBound
{
public int MaxRetries { get; set; }
public int DelayMilliseconds { get; set; }
/// <summary>
/// Computed backoff schedule, derived from bound values.
/// </summary>
[ConfigurationIgnore]
public int[] BackoffSchedule { get; set; } = [];
public void OnBound()
{
if (MaxRetries < 0)
throw new InvalidOperationException("MaxRetries must be non-negative.");
BackoffSchedule = Enumerable.Range(0, MaxRetries)
.Select(i => DelayMilliseconds * (int)Math.Pow(2, i))
.ToArray();
}
}Pre- and post-bind lifecycle
public class FeatureOptions : IConfigurationOnBinding, IConfigurationOnBound
{
public string? RawFlags { get; set; }
[ConfigurationIgnore]
public IReadOnlyDictionary<string, bool> ParsedFlags { get; set; } = new Dictionary<string, bool>();
public void OnBinding()
{
// Reset mutable state before rebinding (e.g. during options reload).
RawFlags = null;
}
public void OnBound()
{
ParsedFlags = (RawFlags ?? "")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToDictionary(f => f, _ => true);
}
}Alternative Designs
-
Honor
IJsonOnDeserializeddirectly — The binder could check whether the bound type implementsIJsonOnDeserializedand callOnDeserialized(). This avoids introducing a new interface but creates a hard dependency from the configuration system onSystem.Text.Json, and conflates two distinct binding mechanisms. An options type might need different post-processing logic depending on whether it was populated from JSON deserialization or from configuration binding. -
Rely on
IPostConfigureOptions<T>/IValidateOptions<T>— These already exist in the options pattern and can run post-bind logic. However, they require external registration, live outside the options type, and don't apply when usingConfigurationBinder.Bind()/.Get<T>()directly (outside the DI options pipeline). The proposed interface works with any binding call site. -
Attribute-based callback — Instead of an interface, a
[ConfigurationOnBound(nameof(MyMethod))]attribute could designate an arbitrary method. This is more flexible but less discoverable, not statically verifiable, and inconsistent with theIJsonOnDeserializedpattern this proposal mirrors. Further, it can creation additional issues with code trimming and compatibility with platforms such as WASM. -
Event on
BinderOptions— A delegate likeBinderOptions.AfterBind = obj => { ... }could provide a per-call-site hook. This is useful for external post-processing but doesn't allow the type itself to encapsulate its own invariants.
Risks
- Behavioral consistency — The callback must fire in all binding paths:
ConfigurationBinder.Bind(),.Get<T>(),services.Configure<T>(), and the configuration binding source generator. Missing any path would create subtle inconsistencies. - Source generator support — The configuration binding source generator (#44493) would need to emit the interface check and callback invocation. This is straightforward — it already handles
ConfigurationKeyNameAttribute— but must be coordinated. - Recursive binding — If
OnBound()itself callsBind()on nested sections (as in theCsvIngestionOptionsexample), care must be taken to avoid infinite recursion. The binder should invokeOnBound()only once perBind()/Get()call on the root object, not on intermediate nested bindings triggered from withinOnBound()itself. Clear documentation of the invocation semantics is essential. - Exception behavior — If
OnBound()throws, the exception should propagate to the caller ofBind()/Get<T>(). This matches the behavior ofIJsonOnDeserializedinSystem.Text.Json, where exceptions inOnDeserialized()surface to the caller ofJsonSerializer.Deserialize(). - Overlap with
IPostConfigureOptions<T>— Documentation should clearly delineate when to use which:IConfigurationOnBoundfor self-contained type-level invariants;IPostConfigureOptions<T>for external, DI-driven, cross-cutting configuration logic.