Skip to content

feat(variants): allow in-memory objects - #615

Open
Степан (Stepami) wants to merge 11 commits into
microsoft:mainfrom
Stepami:feature/variants-params
Open

Степан (Stepami) wants to merge 11 commits into
microsoft:mainfrom
Stepami:feature/variants-params

Conversation

@Stepami

Copy link
Copy Markdown

Why this PR?

Implementing IFeatureDefinitionProvider makes it inconvinient to fill variant bound values if they don't come from configuration.

Similarly to feature filters I decided to introduce an object that can be used as an alternative ConfigurationValue. Custom IFeatureDefinitionProvider implementations can populate this property directly instead of constructing an IConfigurationSection instance.

Visible Changes

  • Microsoft.FeatureManagement.VariantDefinition.ConfigurationObject
  • Microsoft.FeatureManagement.Variant.ConfigurationObject

@Stepami

Copy link
Copy Markdown
Author

Hey, Zhiyuan Liang (@zhiyuanliang-ms)! could you take a look please?

@zhiyuanliang-ms

Copy link
Copy Markdown
Member

My understanding is that this enhancement is intended specifically for the custom feature definition provider scenario. Is that correct?

@Stepami

Степан (Stepami) commented Aug 26, 2026 •

Copy link
Copy Markdown
Author

My understanding is that this enhancement is intended specifically for the custom feature definition provider scenario. Is that correct?

yes it is. right now i am implementing a custom provider. i need to do something like this in order to put data from an external storage into the definition:

new VariantDefinition
{
    Name = "...",
    ConfigurationValue =
        new ConfigurationBuilder()
            .AddInMemoryCollection([new("Value", variantValue.ToString())])
            .Build()
            .GetSection("Value")
}

@Stepami

Copy link
Copy Markdown
Author

And the idea came to me from the FeatureFilterConfiguration class

@zhiyuanliang-ms

Copy link
Copy Markdown
Member

Hey, Степан (@Stepami)

We discussed this scenario internally, and we agree that supporting in-memory variant values is useful for custom IFeatureDefinitionProvider implementations.

One concern we have is that: for customers using the built-in ConfigurationFeatureDefinitionProvider, which we expect to be the common case, this property would not be populated and therefore would not provide any additional value.

Before adding a new public property, we would like to think through what the story should be for those customers as well. Ideally, this should feel like a generally useful part of the variant configuration model, rather than an API surface that only applies when a custom provider is used.

For example, one question we are considering is whether the built-in provider should also be able to populate an object representation from the configured variant value, or whether there is another API shape that gives both built-in and custom provider users a consistent way to consume variant configuration.

@zhiyuanliang-ms

Copy link
Copy Markdown
Member

I have been thinking about the following options:

  1. Have ConfigurationFeatureDefinitionProvider populate ConfigurationObject as well, for example by constructing a JsonNode from the IConfigurationSection.

I used to think this is the best option. But after a second look, I realize the conversion will not be lossless. For example, a numeric value could end up being reconstructed as a string.

  1. Today, Variant.Configuration is an IConfigurationSection, so customers using ConfigurationFeatureDefinitionProvider can naturally use the existing configuration binding APIs, such as Bind or Get<T>(), to convert the variant value into a typed object.

For example

Variant variant = await featureManager.GetVariantAsync("MyFeature");
MySettings settings = variant.Configuration.Get<MySettings>();

I am thinking about adding a new extension method to Variant. Something like:

Variant variant = await featureManager.GetVariantAsync("MyFeature");
MySettings settings = variant.GetConfiguration<MySettings>();

Or we can even add a new extension method to IVariantFeatureManager
like

MySettings settings = await featureManager.GetVariantConfigurationAsync<MySettings>("MyFeature");

This can unify the story that people who uses custom feature definition provider with in-memory object and built-in configuration feature defition provider.

@Stepami

Copy link
Copy Markdown
Author

hey Zhiyuan Liang (@zhiyuanliang-ms) !

I agree that ConfigurationFeatureDefinitionProvider could populate this property.

I'll take a few days to think this through

@Stepami

Copy link
Copy Markdown
Author

hey Zhiyuan Liang (@zhiyuanliang-ms)

I decided to do both populate the data supplied by the buil-in configuration provider and create the common API used to consume variant configuration through extension method.

ConfigurationObject

Now ConfigurationObject is filled with a read-only dictionary of relative configuration paths and string values inside the ConfigurationFeatureDefinitionProvider. I found this solution balanced as we don't lose the data as in JSON reconstruction and preserve original configuration structure. Relative paths preserve nested settings and array indices, while the empty key represents an inline scalar value. Case-insensitive lookup follows configuration conventions, and the read-only wrapper prevents consumers from modifying the shared dictionary.

GetConfiguration

Variant.GetConfiguration<T>() provides a common consumption API:

  • If ConfigurationObject is assignable to T, it returns that object directly. Custom providers can supply typed settings without serialization or binding, and reference identity is preserved.
  • Otherwise, it delegates to Configuration.Get<T>(). Built-in-provider consumers can request typed settings without handling the dictionary themselves. An incompatible object does not prevent using the existing configuration representation.
  • If the variant or fallback configuration is absent, it returns default(T). Callers can request nullable value types, such as int?, when they need to distinguish absence from zero.

T is unconstrained because variant values can be both complex objects and inline scalars. Pattern matching supports reference types and boxed value types.

I placed the method on Variant as an extension so callers can consume an already evaluated variant and retain access to its name, without expanding IVariantFeatureManager or changing custom implementations.

@Stepami

Copy link
Copy Markdown
Author

hey Zhiyuan Liang (@zhiyuanliang-ms)! have you seen the update describe above?

@zhiyuanliang-ms

Copy link
Copy Markdown
Member

hey, Степан (@Stepami)

I am looking into it.

};
}

private static IReadOnlyDictionary<string, string> CreateConfigurationObject(IConfigurationSection section)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m concerned about populating ConfigurationObject with a flattened IReadOnlyDictionary<string, string>.
For example, the following configuration

{
  "configuration_value": {
    "Layout": {
      "Width": 100,
      "Theme": "dark"
    },
    "Regions": [
      {
        "Name": "header"
      },
      {
        "Name": "footer"
      }
    ]
  }
}

will be converted to

new Dictionary<string, string>
{
    ["Layout:Width"] = "100",
    ["Layout:Theme"] = "dark",
    ["Regions:0:Name"] = "header",
    ["Regions:1:Name"] = "footer"
};

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reverted object population from config

@zhiyuanliang-ms

Zhiyuan Liang (zhiyuanliang-ms) commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

I am currently leaning to keep the two representations provider-specific rather than trying to populate both. For a custom IFeatureDefinitionProvider that supplies an in-memory object, Configuration is very likely to be null and For ConfigurationFeatureDefinitionProvider, Configuration should have value and ConfigurationObject can just be null.
We have not found a representation that can perfectly convert an arbitrary IConfigurationSection to Object.

To unify the customer experience, we can offer Variant.GetConfiguration<T>() as the common consumption API, returning the object when present and otherwise binding Configuration.

Variant variant = await featureManager.GetVariantAsync("MyFeature");
MySettings settings = variant.GetConfiguration<MySettings>();

The method name can be either GetConfiguration or GetValue

@zhiyuanliang-ms

Copy link
Copy Markdown
Member

@Stepami

Copy link
Copy Markdown
Author

I am currently leaning to keep the two representations provider-specific rather than trying to populate both. For a custom IFeatureDefinitionProvider that supplies an in-memory object, Configuration is very likely to be null and For ConfigurationFeatureDefinitionProvider, Configuration should have value and ConfigurationObject can just be null. We have not found a representation that can perfectly convert an arbitrary IConfigurationSection to Object.

To unify the customer experience, we can offer Variant.GetConfiguration<T>() as the common consumption API, returning the object when present and otherwise binding Configuration.

Variant variant = await featureManager.GetVariantAsync("MyFeature");
MySettings settings = variant.GetConfiguration<MySettings>();

The method name can be either GetConfiguration or GetValue

hey Zhiyuan Liang (@zhiyuanliang-ms) ! i can keep the two representations provider-specific. that's what i originally suggested. the extension method variant.GetConfiguration<> is already there

@zhiyuanliang-ms

Copy link
Copy Markdown
Member

Hey, Степан (@Stepami)

I discussed this PR with Jimmy Campbell (@jimmyca15) yesterday. We have not reached a conclusion yet, but I wanted to share some of our thinking.

One concern with the current GetConfiguration<T>() approach is that, when ConfigurationObject is not populated, every consumer needs to call variant.GetConfiguration<T>(), which binds the same configuration repeatedly (deserialization cost).

We are considering whether the feature manager could know the expected configuration type for each variant and bind the configuration earlier, ideally when the feature definition is created. Since feature definitions are cached, the typed configuration could then be cached as part of the variant definition instead of being rebound on every consumption.

The unresolved question is how to associate a variant configuration with its expected type. The configuration provider does not naturally have that type information when it creates the feature definition.

@Stepami

Copy link
Copy Markdown
Author

hey Zhiyuan Liang (@zhiyuanliang-ms) !

Summary

I made the change that avoids repeated configuration binding in GetConfiguration<T>() without adding public registration APIs or eagerly populating ConfigurationObject.

Each provider-created variant definition with a "configuration_value" receives an internal VariantConfigurationCache.

The cache stores entries by exact System.Type:

Variant definition generation
└── VariantConfigurationCache
    ├── typeof(ConfigurationA) → bound ConfigurationA
    ├── typeof(ConfigurationB) → bound ConfigurationB
    └── ...

The holder is transferred from VariantDefinition to runtime Variant instances without exposing it publicly.

Motivation

The provider owns feature-definition lifetime and reload handling, but it does not know the type requested by the caller. GetConfiguration<T>() knows that type, but previously rebound the same configuration on every call.

The provider already caches FeatureDefinition instances and replaces them after configuration reloads. Attaching the typed cache to that object graph gives it the same natural lifetime:

  • Runtime variants created from the same definition share results.
  • A configuration reload produces a new definition and therefore a new cache.
  • Existing variants retain their original definition generation, which gives them snapshot-like behavior.
  • There is no global cache requiring explicit eviction or potentially retaining providers indefinitely.

Under the hood

  • The provider creates an internal cache for each variant definition containing configuration.
  • Entries are keyed by exact System.Type.
  • ConcurrentDictionary<Type, Lazy<object>> provides thread-safe, single-flight binding.
  • Runtime variants created from the same definition share cached results.
  • Reloaded definitions receive a new cache.
  • Reference checks prevent stale cache use when Variant.Configuration or VariantDefinition.ConfigurationValue is replaced.
  • ConfigurationObject retains precedence.
  • Manual/custom variants retain the existing uncached fallback.
  • Null results are cached; failed bindings are removed so later calls can retry.

The warm path is reduced to a dictionary lookup, Lazy.Value, and a cast—without another binder invocation or new delegate/Lazy allocation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants