Skip to content

[API Proposal]: Add IConfigurationOnBound callback interface for post-bind processing #125113

@ondrejtucny

Description

@ondrejtucny

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 IJsonOnDeserialized in 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

  1. Honor IJsonOnDeserialized directly — The binder could check whether the bound type implements IJsonOnDeserialized and call OnDeserialized(). This avoids introducing a new interface but creates a hard dependency from the configuration system on System.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.

  2. 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 using ConfigurationBinder.Bind() / .Get<T>() directly (outside the DI options pipeline). The proposed interface works with any binding call site.

  3. 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 the IJsonOnDeserialized pattern this proposal mirrors. Further, it can creation additional issues with code trimming and compatibility with platforms such as WASM.

  4. Event on BinderOptions — A delegate like BinderOptions.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 calls Bind() on nested sections (as in the CsvIngestionOptions example), care must be taken to avoid infinite recursion. The binder should invoke OnBound() only once per Bind() / Get() call on the root object, not on intermediate nested bindings triggered from within OnBound() itself. Clear documentation of the invocation semantics is essential.
  • Exception behavior — If OnBound() throws, the exception should propagate to the caller of Bind() / Get<T>(). This matches the behavior of IJsonOnDeserialized in System.Text.Json, where exceptions in OnDeserialized() surface to the caller of JsonSerializer.Deserialize().
  • Overlap with IPostConfigureOptions<T> — Documentation should clearly delineate when to use which: IConfigurationOnBound for self-contained type-level invariants; IPostConfigureOptions<T> for external, DI-driven, cross-cutting configuration logic.

Metadata

Metadata

Assignees

No one assigned

    Labels

    api-suggestionEarly API idea and discussion, it is NOT ready for implementationarea-Extensions-ConfigurationuntriagedNew issue has not been triaged by the area owner

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions