From 71d711500439d3e0671216c5dc0829dddaf26550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D0=BD=D0=B8=D0=BD=20=D0=A1=D1=82=D0=B5=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Thu, 14 May 2026 15:30:20 +0300 Subject: [PATCH 01/13] feat(VariantBasedInjection): Keyed DI PoC VariantServiceProvider uses sp to get keyed services BREAKING CHANGE: behavioural: keyed services registration needed unless we override descriptors for backward comp --- .../FeatureManagementBuilderExtensions.cs | 28 +++--- .../Microsoft.FeatureManagement.csproj | 1 + .../VariantServiceProvider.cs | 32 ++----- .../FeatureManagementTest.cs | 87 +++++++++++++++++-- 4 files changed, 104 insertions(+), 44 deletions(-) diff --git a/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs b/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs index f8635a79..b2e23b0c 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs @@ -5,7 +5,6 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.FeatureManagement.FeatureFilters; using System; -using System.Collections.Generic; using System.Linq; namespace Microsoft.FeatureManagement @@ -58,20 +57,19 @@ public static IFeatureManagementBuilder WithVariantService(this IFeatu throw new InvalidOperationException($"A variant service of {typeof(TService).FullName} has already been added."); } - if (builder.Services.Any(descriptor => descriptor.ServiceType == typeof(IFeatureManager) && descriptor.Lifetime == ServiceLifetime.Scoped)) - { - builder.Services.AddScoped>(sp => new VariantServiceProvider( - featureName, - sp.GetRequiredService(), - sp.GetRequiredService>())); - } - else - { - builder.Services.AddSingleton>(sp => new VariantServiceProvider( - featureName, - sp.GetRequiredService(), - sp.GetRequiredService>())); - } + var variantSpLifetime = builder.Services + .Any(descriptor => descriptor.ServiceType == typeof(IFeatureManager) && + descriptor.Lifetime == ServiceLifetime.Scoped) + ? ServiceLifetime.Scoped + : ServiceLifetime.Singleton; + builder.Services.Add( + ServiceDescriptor.Describe( + typeof(IVariantServiceProvider), + sp => new VariantServiceProvider( + featureName, + sp.GetRequiredService(), + sp), + variantSpLifetime)); return builder; } diff --git a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj index 170eafa8..9eb0e8ff 100644 --- a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj +++ b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj @@ -44,6 +44,7 @@ + diff --git a/src/Microsoft.FeatureManagement/VariantServiceProvider.cs b/src/Microsoft.FeatureManagement/VariantServiceProvider.cs index d4b3f514..6e261b9b 100644 --- a/src/Microsoft.FeatureManagement/VariantServiceProvider.cs +++ b/src/Microsoft.FeatureManagement/VariantServiceProvider.cs @@ -1,11 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // + +using Microsoft.Extensions.DependencyInjection; using System; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -16,7 +16,7 @@ namespace Microsoft.FeatureManagement /// internal class VariantServiceProvider : IVariantServiceProvider where TService : class { - private readonly IEnumerable _services; + private readonly IServiceProvider _serviceProvider; private readonly IVariantFeatureManager _featureManager; private readonly string _featureName; private readonly ConcurrentDictionary _variantServiceCache; @@ -26,15 +26,15 @@ internal class VariantServiceProvider : IVariantServiceProvider /// The feature flag that should be used to determine which variant of the service should be used. /// The feature manager to get the assigned variant of the feature flag. - /// Implementation variants of TService. + /// Access to Implementation variants of TService. /// Thrown if is null. /// Thrown if is null. - /// Thrown if is null. - public VariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IEnumerable services) + /// Thrown if is null. + public VariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IServiceProvider keyedServiceProvider) { _featureName = featureName ?? throw new ArgumentNullException(nameof(featureName)); _featureManager = featureManager ?? throw new ArgumentNullException(nameof(featureManager)); - _services = services ?? throw new ArgumentNullException(nameof(services)); + _serviceProvider = keyedServiceProvider ?? throw new ArgumentNullException(nameof(keyedServiceProvider)); _variantServiceCache = new ConcurrentDictionary(); } @@ -55,26 +55,10 @@ public async ValueTask GetServiceAsync(CancellationToken cancellationT { implementation = _variantServiceCache.GetOrAdd( variant.Name, - (_) => _services.FirstOrDefault( - service => IsMatchingVariantName( - service.GetType(), - variant.Name)) - ); + key => _serviceProvider.GetKeyedService(key)); } return implementation; } - - private bool IsMatchingVariantName(Type implementationType, string variantName) - { - string implementationName = ((VariantServiceAliasAttribute)Attribute.GetCustomAttribute(implementationType, typeof(VariantServiceAliasAttribute)))?.Alias; - - if (implementationName == null) - { - implementationName = implementationType.Name; - } - - return string.Equals(implementationName, variantName, StringComparison.OrdinalIgnoreCase); - } } } diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs index a70e6a0d..89094925 100644 --- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs +++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs @@ -524,12 +524,12 @@ public async Task MergesFeatureFlagsFromDifferentConfigurationSources() * Feature1: true * Feature2: true * FeatureA: true - * + * * appsettings2.json * Feature1: true * Feature2: false * FeatureB: true - * + * * appsettings3.json * Feature1: false * Feature2: false @@ -2166,9 +2166,9 @@ public async Task VariantBasedInjection() IServiceCollection services = new ServiceCollection(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(sp => new AlgorithmOmega("OMEGA")); + services.AddKeyedSingleton(nameof(AlgorithmBeta)); + services.AddKeyedSingleton(nameof(AlgorithmSigma)); + services.AddKeyedSingleton("Omega", (sp, _) => new AlgorithmOmega("OMEGA")); services.AddSingleton(configuration) .AddFeatureManagement() @@ -2234,6 +2234,83 @@ public async Task VariantBasedInjection() ); } + [Fact] + public async Task VariantBasedInjectionScoped() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .Build(); + + IServiceCollection services = new ServiceCollection(); + + services.AddKeyedScoped(nameof(AlgorithmBeta)); + services.AddKeyedScoped(nameof(AlgorithmSigma)); + services.AddKeyedScoped("Omega", (sp, _) => new AlgorithmOmega("OMEGA")); + + services.AddSingleton(configuration) + .AddScopedFeatureManagement() + .AddFeatureFilter() + .WithVariantService(Features.VariantImplementationFeature); + + var targetingContextAccessor = new OnDemandTargetingContextAccessor(); + + services.AddSingleton(targetingContextAccessor); + + var serviceProvider = services.BuildServiceProvider().CreateScope().ServiceProvider; + + IVariantFeatureManager featureManager = serviceProvider.GetRequiredService(); + + IVariantServiceProvider featuredAlgorithm = serviceProvider.GetRequiredService>(); + + targetingContextAccessor.Current = new TargetingContext + { + UserId = "Guest" + }; + + IAlgorithm algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None); + + Assert.Null(algorithm); + + targetingContextAccessor.Current = new TargetingContext + { + UserId = "UserSigma" + }; + + algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None); + + Assert.Null(algorithm); + + targetingContextAccessor.Current = new TargetingContext + { + UserId = "UserBeta" + }; + + algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None); + + Assert.NotNull(algorithm); + Assert.Equal("Beta", algorithm.Style); + + targetingContextAccessor.Current = new TargetingContext + { + UserId = "UserOmega" + }; + + algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None); + + Assert.NotNull(algorithm); + Assert.Equal("OMEGA", algorithm.Style); + + services = new ServiceCollection(); + + Assert.Throws(() => + { + services.AddFeatureManagement() + .WithVariantService("DummyFeature1") + .WithVariantService("DummyFeature2"); + } + ); + } + [Fact] public async Task VariantFeatureFlagWithContextualFeatureFilter() { From 57303b407d79dd828c7bd5fd2fc2029ff0e41c10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D0=BD=D0=B8=D0=BD=20=D0=A1=D1=82=D0=B5=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Thu, 14 May 2026 18:00:39 +0300 Subject: [PATCH 02/13] refactor(VariantServiceProvider): ctor param name rename keyedServiceProvider to serviceProvider for clarity --- src/Microsoft.FeatureManagement/VariantServiceProvider.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.FeatureManagement/VariantServiceProvider.cs b/src/Microsoft.FeatureManagement/VariantServiceProvider.cs index 6e261b9b..06016123 100644 --- a/src/Microsoft.FeatureManagement/VariantServiceProvider.cs +++ b/src/Microsoft.FeatureManagement/VariantServiceProvider.cs @@ -26,15 +26,15 @@ internal class VariantServiceProvider : IVariantServiceProvider /// The feature flag that should be used to determine which variant of the service should be used. /// The feature manager to get the assigned variant of the feature flag. - /// Access to Implementation variants of TService. + /// Access to Implementation variants of TService. /// Thrown if is null. /// Thrown if is null. - /// Thrown if is null. - public VariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IServiceProvider keyedServiceProvider) + /// Thrown if is null. + public VariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IServiceProvider serviceProvider) { _featureName = featureName ?? throw new ArgumentNullException(nameof(featureName)); _featureManager = featureManager ?? throw new ArgumentNullException(nameof(featureManager)); - _serviceProvider = keyedServiceProvider ?? throw new ArgumentNullException(nameof(keyedServiceProvider)); + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); _variantServiceCache = new ConcurrentDictionary(); } From e5d1f2a2469799edfb7509302bc88b37898c19f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D0=BD=D0=B8=D0=BD=20=D0=A1=D1=82=D0=B5=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Tue, 19 May 2026 15:26:13 +0300 Subject: [PATCH 03/13] feat(VariantBasedInjection): LazyVariantServiceProvider keyed di impl in a separate class Closes #564 --- .../LazyVariantServiceProvider.cs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/Microsoft.FeatureManagement/LazyVariantServiceProvider.cs diff --git a/src/Microsoft.FeatureManagement/LazyVariantServiceProvider.cs b/src/Microsoft.FeatureManagement/LazyVariantServiceProvider.cs new file mode 100644 index 00000000..9b254623 --- /dev/null +++ b/src/Microsoft.FeatureManagement/LazyVariantServiceProvider.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement +{ + /// + /// Used to get different implementations of TService depending on the assigned variant from a specific variant feature flag.
+ /// Only requested implementation is loaded into memory in contrary to . + ///
+ internal class LazyVariantServiceProvider : IVariantServiceProvider where TService : class + { + private readonly IServiceProvider _serviceProvider; + private readonly IVariantFeatureManager _featureManager; + private readonly string _featureName; + private readonly ConcurrentDictionary _variantServiceCache; + + /// + /// Creates a variant service provider. + /// + /// The feature flag that should be used to determine which variant of the service should be used. + /// The feature manager to get the assigned variant of the feature flag. + /// Access to Implementation variants of TService. + /// Thrown if is null. + /// Thrown if is null. + /// Thrown if is null. + public LazyVariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IServiceProvider serviceProvider) + { + _featureName = featureName ?? throw new ArgumentNullException(nameof(featureName)); + _featureManager = featureManager ?? throw new ArgumentNullException(nameof(featureManager)); + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _variantServiceCache = new ConcurrentDictionary(); + } + + /// + /// Gets implementation of TService according to the assigned variant from the feature flag. + /// + /// The cancellation token to cancel the operation. + /// An implementation matched with the assigned variant. If there is no matched implementation, it will return null. + public async ValueTask GetServiceAsync(CancellationToken cancellationToken) + { + Debug.Assert(_featureName != null); + + Variant variant = await _featureManager.GetVariantAsync(_featureName, cancellationToken); + + TService implementation = null; + + if (variant != null) + { + implementation = _variantServiceCache.GetOrAdd( + variant.Name, + key => _serviceProvider.GetKeyedService(key)); + } + + return implementation; + } + } +} From fe4441feebec5a9a54f71a1996a9ddae9f602f95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D0=BD=D0=B8=D0=BD=20=D0=A1=D1=82=D0=B5=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Tue, 19 May 2026 15:28:13 +0300 Subject: [PATCH 04/13] revert(VariantBasedInjection): VariantServiceProvider keyed di impl separate class --- .../FeatureManagementBuilderExtensions.cs | 28 ++++++++-------- .../VariantServiceProvider.cs | 32 ++++++++++++++----- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs b/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs index b2e23b0c..f8635a79 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.FeatureManagement.FeatureFilters; using System; +using System.Collections.Generic; using System.Linq; namespace Microsoft.FeatureManagement @@ -57,19 +58,20 @@ public static IFeatureManagementBuilder WithVariantService(this IFeatu throw new InvalidOperationException($"A variant service of {typeof(TService).FullName} has already been added."); } - var variantSpLifetime = builder.Services - .Any(descriptor => descriptor.ServiceType == typeof(IFeatureManager) && - descriptor.Lifetime == ServiceLifetime.Scoped) - ? ServiceLifetime.Scoped - : ServiceLifetime.Singleton; - builder.Services.Add( - ServiceDescriptor.Describe( - typeof(IVariantServiceProvider), - sp => new VariantServiceProvider( - featureName, - sp.GetRequiredService(), - sp), - variantSpLifetime)); + if (builder.Services.Any(descriptor => descriptor.ServiceType == typeof(IFeatureManager) && descriptor.Lifetime == ServiceLifetime.Scoped)) + { + builder.Services.AddScoped>(sp => new VariantServiceProvider( + featureName, + sp.GetRequiredService(), + sp.GetRequiredService>())); + } + else + { + builder.Services.AddSingleton>(sp => new VariantServiceProvider( + featureName, + sp.GetRequiredService(), + sp.GetRequiredService>())); + } return builder; } diff --git a/src/Microsoft.FeatureManagement/VariantServiceProvider.cs b/src/Microsoft.FeatureManagement/VariantServiceProvider.cs index 06016123..d4b3f514 100644 --- a/src/Microsoft.FeatureManagement/VariantServiceProvider.cs +++ b/src/Microsoft.FeatureManagement/VariantServiceProvider.cs @@ -1,11 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // - -using Microsoft.Extensions.DependencyInjection; using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -16,7 +16,7 @@ namespace Microsoft.FeatureManagement /// internal class VariantServiceProvider : IVariantServiceProvider where TService : class { - private readonly IServiceProvider _serviceProvider; + private readonly IEnumerable _services; private readonly IVariantFeatureManager _featureManager; private readonly string _featureName; private readonly ConcurrentDictionary _variantServiceCache; @@ -26,15 +26,15 @@ internal class VariantServiceProvider : IVariantServiceProvider /// The feature flag that should be used to determine which variant of the service should be used. /// The feature manager to get the assigned variant of the feature flag. - /// Access to Implementation variants of TService. + /// Implementation variants of TService. /// Thrown if is null. /// Thrown if is null. - /// Thrown if is null. - public VariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IServiceProvider serviceProvider) + /// Thrown if is null. + public VariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IEnumerable services) { _featureName = featureName ?? throw new ArgumentNullException(nameof(featureName)); _featureManager = featureManager ?? throw new ArgumentNullException(nameof(featureManager)); - _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _services = services ?? throw new ArgumentNullException(nameof(services)); _variantServiceCache = new ConcurrentDictionary(); } @@ -55,10 +55,26 @@ public async ValueTask GetServiceAsync(CancellationToken cancellationT { implementation = _variantServiceCache.GetOrAdd( variant.Name, - key => _serviceProvider.GetKeyedService(key)); + (_) => _services.FirstOrDefault( + service => IsMatchingVariantName( + service.GetType(), + variant.Name)) + ); } return implementation; } + + private bool IsMatchingVariantName(Type implementationType, string variantName) + { + string implementationName = ((VariantServiceAliasAttribute)Attribute.GetCustomAttribute(implementationType, typeof(VariantServiceAliasAttribute)))?.Alias; + + if (implementationName == null) + { + implementationName = implementationType.Name; + } + + return string.Equals(implementationName, variantName, StringComparison.OrdinalIgnoreCase); + } } } From 4b96e4aff3f97ebdaf756eb62262022ea25d30c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D0=BD=D0=B8=D0=BD=20=D0=A1=D1=82=D0=B5=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Tue, 19 May 2026 15:33:28 +0300 Subject: [PATCH 05/13] feat(VariantBasedInjection): Lazy variant DI Extension method impl && tests Closes #564 --- .../FeatureManagementBuilderExtensions.cs | 38 +++++++++++++++++++ .../FeatureManagementTest.cs | 10 ++--- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs b/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs index f8635a79..6af39b4d 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs @@ -75,5 +75,43 @@ public static IFeatureManagementBuilder WithVariantService(this IFeatu return builder; } + + /// + /// Adds a to the feature management system. + /// + /// The used to customize feature management functionality. + /// The feature flag that should be used to determine which variant of the service should be used. The will return different implementations of TService according to the assigned variant. + /// A that can be used to customize feature management functionality. + /// Thrown if feature name parameter is null. + /// Thrown if a variant service of the type has already been added. + public static IFeatureManagementBuilder WithLazyVariantService(this IFeatureManagementBuilder builder, string featureName) where TService : class + { + if (string.IsNullOrEmpty(featureName)) + { + throw new ArgumentNullException(nameof(featureName)); + } + + if (builder.Services.Any(descriptor => descriptor.ServiceType == typeof(IVariantServiceProvider))) + { + throw new InvalidOperationException($"A variant service of {typeof(TService).FullName} has already been added."); + } + + if (builder.Services.Any(descriptor => descriptor.ServiceType == typeof(IFeatureManager) && descriptor.Lifetime == ServiceLifetime.Scoped)) + { + builder.Services.AddScoped>(sp => new LazyVariantServiceProvider( + featureName, + sp.GetRequiredService(), + sp)); + } + else + { + builder.Services.AddSingleton>(sp => new LazyVariantServiceProvider( + featureName, + sp.GetRequiredService(), + sp)); + } + + return builder; + } } } diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs index 89094925..cf1f9974 100644 --- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs +++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs @@ -2166,9 +2166,9 @@ public async Task VariantBasedInjection() IServiceCollection services = new ServiceCollection(); - services.AddKeyedSingleton(nameof(AlgorithmBeta)); - services.AddKeyedSingleton(nameof(AlgorithmSigma)); - services.AddKeyedSingleton("Omega", (sp, _) => new AlgorithmOmega("OMEGA")); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => new AlgorithmOmega("OMEGA")); services.AddSingleton(configuration) .AddFeatureManagement() @@ -2235,7 +2235,7 @@ public async Task VariantBasedInjection() } [Fact] - public async Task VariantBasedInjectionScoped() + public async Task LazyVariantBasedInjectionScoped() { IConfiguration configuration = new ConfigurationBuilder() .AddJsonFile("appsettings.json") @@ -2250,7 +2250,7 @@ public async Task VariantBasedInjectionScoped() services.AddSingleton(configuration) .AddScopedFeatureManagement() .AddFeatureFilter() - .WithVariantService(Features.VariantImplementationFeature); + .WithLazyVariantService(Features.VariantImplementationFeature); var targetingContextAccessor = new OnDemandTargetingContextAccessor(); From e2a1fb835470ee88f52527adb8ec6bdb9ce2859c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D0=BD=D0=B8=D0=BD=20=D0=A1=D1=82=D0=B5=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Tue, 19 May 2026 15:34:56 +0300 Subject: [PATCH 06/13] docs(VariantServiceProvider): xml doc Added notes about impls handling to highlight difference with lazy one --- src/Microsoft.FeatureManagement/VariantServiceProvider.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.FeatureManagement/VariantServiceProvider.cs b/src/Microsoft.FeatureManagement/VariantServiceProvider.cs index d4b3f514..6ee1b7ca 100644 --- a/src/Microsoft.FeatureManagement/VariantServiceProvider.cs +++ b/src/Microsoft.FeatureManagement/VariantServiceProvider.cs @@ -12,7 +12,8 @@ namespace Microsoft.FeatureManagement { /// - /// Used to get different implementations of TService depending on the assigned variant from a specific variant feature flag. + /// Used to get different implementations of TService depending on the assigned variant from a specific variant feature flag.
+ /// All implementations are loaded into memory by default. ///
internal class VariantServiceProvider : IVariantServiceProvider where TService : class { From 49411a92ebb40b6cfd739bdb85e7214f6cc6e9db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D0=BD=D0=B8=D0=BD=20=D0=A1=D1=82=D0=B5=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Wed, 20 May 2026 13:01:39 +0300 Subject: [PATCH 07/13] refactor(Microsoft.FeatureManagement): deps use DI package as transitive --- .../Microsoft.FeatureManagement.csproj | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj index 9eb0e8ff..c0a5eb89 100644 --- a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj +++ b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj @@ -16,7 +16,7 @@ true false ..\..\build\Microsoft.FeatureManagement.snk - 8.0 @@ -44,7 +44,6 @@ - From ee44bd296847322fd33518cdbb683daf70c3b857 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D0=BD=D0=B8=D0=BD=20=D0=A1=D1=82=D0=B5=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Tue, 26 May 2026 16:44:44 +0300 Subject: [PATCH 08/13] refactor(FeatureManagementBuilderExtensions): WithVariantService - introduced new WithVariantService overload which replaced WithLazyVariantService ext method - added type check for sp to be keyed in case of lazy variant sp Closes #564 --- .../FeatureManagementBuilderExtensions.cs | 62 ++++++++----------- .../LazyVariantServiceProvider.cs | 4 +- .../FeatureManagementTest.cs | 2 +- 3 files changed, 28 insertions(+), 40 deletions(-) diff --git a/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs b/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs index 6af39b4d..de9eeef1 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs @@ -48,43 +48,23 @@ public static IFeatureManagementBuilder WithTargeting(this IFeatureManagement /// Thrown if a variant service of the type has already been added. public static IFeatureManagementBuilder WithVariantService(this IFeatureManagementBuilder builder, string featureName) where TService : class { - if (string.IsNullOrEmpty(featureName)) - { - throw new ArgumentNullException(nameof(featureName)); - } - - if (builder.Services.Any(descriptor => descriptor.ServiceType == typeof(IVariantServiceProvider))) - { - throw new InvalidOperationException($"A variant service of {typeof(TService).FullName} has already been added."); - } - - if (builder.Services.Any(descriptor => descriptor.ServiceType == typeof(IFeatureManager) && descriptor.Lifetime == ServiceLifetime.Scoped)) - { - builder.Services.AddScoped>(sp => new VariantServiceProvider( - featureName, - sp.GetRequiredService(), - sp.GetRequiredService>())); - } - else - { - builder.Services.AddSingleton>(sp => new VariantServiceProvider( - featureName, - sp.GetRequiredService(), - sp.GetRequiredService>())); - } - - return builder; + return builder.WithVariantService(featureName, useKeyedService: false); } /// - /// Adds a to the feature management system. + /// Adds a to the feature management system. /// /// The used to customize feature management functionality. - /// The feature flag that should be used to determine which variant of the service should be used. The will return different implementations of TService according to the assigned variant. + /// The feature flag that should be used to determine which variant of the service should be used. The will return different implementations of TService according to the assigned variant. + /// Determines which implementation of will be used. If true , otherwise . /// A that can be used to customize feature management functionality. /// Thrown if feature name parameter is null. /// Thrown if a variant service of the type has already been added. - public static IFeatureManagementBuilder WithLazyVariantService(this IFeatureManagementBuilder builder, string featureName) where TService : class + public static IFeatureManagementBuilder WithVariantService( + this IFeatureManagementBuilder builder, + string featureName, + bool useKeyedService) + where TService : class { if (string.IsNullOrEmpty(featureName)) { @@ -96,19 +76,27 @@ public static IFeatureManagementBuilder WithLazyVariantService(this IF throw new InvalidOperationException($"A variant service of {typeof(TService).FullName} has already been added."); } + Func> variantSpFactory = sp => + { + var featureManager = sp.GetRequiredService(); + if (!useKeyedService) + return new VariantServiceProvider(featureName, featureManager, sp.GetRequiredService>()); + + if (sp is IKeyedServiceProvider keyedServiceProvider) + { + return new LazyVariantServiceProvider(featureName, featureManager, keyedServiceProvider); + } + + throw new InvalidOperationException("ServiceProvider does not support keyed services. Call 'WithVariantService' method with 'useKeyedService' set to 'false'."); + }; + if (builder.Services.Any(descriptor => descriptor.ServiceType == typeof(IFeatureManager) && descriptor.Lifetime == ServiceLifetime.Scoped)) { - builder.Services.AddScoped>(sp => new LazyVariantServiceProvider( - featureName, - sp.GetRequiredService(), - sp)); + builder.Services.AddScoped(variantSpFactory); } else { - builder.Services.AddSingleton>(sp => new LazyVariantServiceProvider( - featureName, - sp.GetRequiredService(), - sp)); + builder.Services.AddSingleton(variantSpFactory); } return builder; diff --git a/src/Microsoft.FeatureManagement/LazyVariantServiceProvider.cs b/src/Microsoft.FeatureManagement/LazyVariantServiceProvider.cs index 9b254623..8015e94e 100644 --- a/src/Microsoft.FeatureManagement/LazyVariantServiceProvider.cs +++ b/src/Microsoft.FeatureManagement/LazyVariantServiceProvider.cs @@ -17,7 +17,7 @@ namespace Microsoft.FeatureManagement /// internal class LazyVariantServiceProvider : IVariantServiceProvider where TService : class { - private readonly IServiceProvider _serviceProvider; + private readonly IKeyedServiceProvider _serviceProvider; private readonly IVariantFeatureManager _featureManager; private readonly string _featureName; private readonly ConcurrentDictionary _variantServiceCache; @@ -31,7 +31,7 @@ internal class LazyVariantServiceProvider : IVariantServiceProviderThrown if is null. /// Thrown if is null. /// Thrown if is null. - public LazyVariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IServiceProvider serviceProvider) + public LazyVariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IKeyedServiceProvider serviceProvider) { _featureName = featureName ?? throw new ArgumentNullException(nameof(featureName)); _featureManager = featureManager ?? throw new ArgumentNullException(nameof(featureManager)); diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs index cf1f9974..478ba41d 100644 --- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs +++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs @@ -2250,7 +2250,7 @@ public async Task LazyVariantBasedInjectionScoped() services.AddSingleton(configuration) .AddScopedFeatureManagement() .AddFeatureFilter() - .WithLazyVariantService(Features.VariantImplementationFeature); + .WithVariantService(Features.VariantImplementationFeature, useKeyedService: true); var targetingContextAccessor = new OnDemandTargetingContextAccessor(); From 374a9f548d084d2d2608e0c312ece127314d85eb Mon Sep 17 00:00:00 2001 From: Stepami Date: Thu, 28 May 2026 21:12:11 +0300 Subject: [PATCH 09/13] refactor(VariantBaseInjection): Keyed DI - removed LazyVariantServiceProvider class - combined keyed di and fallback in single class Closes #564 --- examples/VariantServiceDemo/Program.cs | 7 +- .../FeatureManagementBuilderExtensions.cs | 28 +--- .../LazyVariantServiceProvider.cs | 65 -------- .../VariantServiceProvider.cs | 38 +++-- .../FeatureManagementTest.cs | 156 +++++++++++++++++- 5 files changed, 186 insertions(+), 108 deletions(-) delete mode 100644 src/Microsoft.FeatureManagement/LazyVariantServiceProvider.cs diff --git a/examples/VariantServiceDemo/Program.cs b/examples/VariantServiceDemo/Program.cs index a866d02d..de43ffb9 100644 --- a/examples/VariantServiceDemo/Program.cs +++ b/examples/VariantServiceDemo/Program.cs @@ -19,10 +19,11 @@ builder.Services.AddApplicationInsightsTelemetry(); // -// Add variant implementations of ICalculator -builder.Services.AddSingleton(); +// Add variant implementations of ICalculator using keyed services so that only the +// implementation matching the assigned variant is instantiated on demand. +builder.Services.AddKeyedSingleton("DefaultCalculator"); -builder.Services.AddSingleton(); +builder.Services.AddKeyedSingleton("RemoteCalculator"); // // Enter feature management diff --git a/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs b/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs index de9eeef1..5d387a30 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs @@ -47,24 +47,6 @@ public static IFeatureManagementBuilder WithTargeting(this IFeatureManagement /// Thrown if feature name parameter is null. /// Thrown if a variant service of the type has already been added. public static IFeatureManagementBuilder WithVariantService(this IFeatureManagementBuilder builder, string featureName) where TService : class - { - return builder.WithVariantService(featureName, useKeyedService: false); - } - - /// - /// Adds a to the feature management system. - /// - /// The used to customize feature management functionality. - /// The feature flag that should be used to determine which variant of the service should be used. The will return different implementations of TService according to the assigned variant. - /// Determines which implementation of will be used. If true , otherwise . - /// A that can be used to customize feature management functionality. - /// Thrown if feature name parameter is null. - /// Thrown if a variant service of the type has already been added. - public static IFeatureManagementBuilder WithVariantService( - this IFeatureManagementBuilder builder, - string featureName, - bool useKeyedService) - where TService : class { if (string.IsNullOrEmpty(featureName)) { @@ -79,15 +61,7 @@ public static IFeatureManagementBuilder WithVariantService( Func> variantSpFactory = sp => { var featureManager = sp.GetRequiredService(); - if (!useKeyedService) - return new VariantServiceProvider(featureName, featureManager, sp.GetRequiredService>()); - - if (sp is IKeyedServiceProvider keyedServiceProvider) - { - return new LazyVariantServiceProvider(featureName, featureManager, keyedServiceProvider); - } - - throw new InvalidOperationException("ServiceProvider does not support keyed services. Call 'WithVariantService' method with 'useKeyedService' set to 'false'."); + return new VariantServiceProvider(featureName, featureManager, sp); }; if (builder.Services.Any(descriptor => descriptor.ServiceType == typeof(IFeatureManager) && descriptor.Lifetime == ServiceLifetime.Scoped)) diff --git a/src/Microsoft.FeatureManagement/LazyVariantServiceProvider.cs b/src/Microsoft.FeatureManagement/LazyVariantServiceProvider.cs deleted file mode 100644 index 8015e94e..00000000 --- a/src/Microsoft.FeatureManagement/LazyVariantServiceProvider.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// - -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.FeatureManagement -{ - /// - /// Used to get different implementations of TService depending on the assigned variant from a specific variant feature flag.
- /// Only requested implementation is loaded into memory in contrary to . - ///
- internal class LazyVariantServiceProvider : IVariantServiceProvider where TService : class - { - private readonly IKeyedServiceProvider _serviceProvider; - private readonly IVariantFeatureManager _featureManager; - private readonly string _featureName; - private readonly ConcurrentDictionary _variantServiceCache; - - /// - /// Creates a variant service provider. - /// - /// The feature flag that should be used to determine which variant of the service should be used. - /// The feature manager to get the assigned variant of the feature flag. - /// Access to Implementation variants of TService. - /// Thrown if is null. - /// Thrown if is null. - /// Thrown if is null. - public LazyVariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IKeyedServiceProvider serviceProvider) - { - _featureName = featureName ?? throw new ArgumentNullException(nameof(featureName)); - _featureManager = featureManager ?? throw new ArgumentNullException(nameof(featureManager)); - _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - _variantServiceCache = new ConcurrentDictionary(); - } - - /// - /// Gets implementation of TService according to the assigned variant from the feature flag. - /// - /// The cancellation token to cancel the operation. - /// An implementation matched with the assigned variant. If there is no matched implementation, it will return null. - public async ValueTask GetServiceAsync(CancellationToken cancellationToken) - { - Debug.Assert(_featureName != null); - - Variant variant = await _featureManager.GetVariantAsync(_featureName, cancellationToken); - - TService implementation = null; - - if (variant != null) - { - implementation = _variantServiceCache.GetOrAdd( - variant.Name, - key => _serviceProvider.GetKeyedService(key)); - } - - return implementation; - } - } -} diff --git a/src/Microsoft.FeatureManagement/VariantServiceProvider.cs b/src/Microsoft.FeatureManagement/VariantServiceProvider.cs index 6ee1b7ca..8f740618 100644 --- a/src/Microsoft.FeatureManagement/VariantServiceProvider.cs +++ b/src/Microsoft.FeatureManagement/VariantServiceProvider.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using Microsoft.Extensions.DependencyInjection; using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -12,12 +13,11 @@ namespace Microsoft.FeatureManagement { /// - /// Used to get different implementations of TService depending on the assigned variant from a specific variant feature flag.
- /// All implementations are loaded into memory by default. + /// Used to get different implementations of TService depending on the assigned variant from a specific variant feature flag. ///
internal class VariantServiceProvider : IVariantServiceProvider where TService : class { - private readonly IEnumerable _services; + private readonly IServiceProvider _serviceProvider; private readonly IVariantFeatureManager _featureManager; private readonly string _featureName; private readonly ConcurrentDictionary _variantServiceCache; @@ -27,15 +27,15 @@ internal class VariantServiceProvider : IVariantServiceProvider /// The feature flag that should be used to determine which variant of the service should be used. /// The feature manager to get the assigned variant of the feature flag. - /// Implementation variants of TService. + /// The service provider used to resolve implementation variants of TService. If it implements , keyed resolution is used to enable lazy instantiation; otherwise all registered implementations are enumerated. /// Thrown if is null. /// Thrown if is null. - /// Thrown if is null. - public VariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IEnumerable services) + /// Thrown if is null. + public VariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IServiceProvider serviceProvider) { _featureName = featureName ?? throw new ArgumentNullException(nameof(featureName)); _featureManager = featureManager ?? throw new ArgumentNullException(nameof(featureManager)); - _services = services ?? throw new ArgumentNullException(nameof(services)); + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); _variantServiceCache = new ConcurrentDictionary(); } @@ -56,16 +56,30 @@ public async ValueTask GetServiceAsync(CancellationToken cancellationT { implementation = _variantServiceCache.GetOrAdd( variant.Name, - (_) => _services.FirstOrDefault( - service => IsMatchingVariantName( - service.GetType(), - variant.Name)) - ); + (variantName) => ResolveVariantService(variantName)); } return implementation; } + private TService ResolveVariantService(string variantName) + { + if (_serviceProvider is IKeyedServiceProvider) + { + TService keyedService = _serviceProvider.GetKeyedService(variantName); + + if (keyedService != null) + { + return keyedService; + } + } + + IEnumerable services = _serviceProvider.GetRequiredService>(); + + return services.FirstOrDefault( + service => IsMatchingVariantName(service.GetType(), variantName)); + } + private bool IsMatchingVariantName(Type implementationType, string variantName) { string implementationName = ((VariantServiceAliasAttribute)Attribute.GetCustomAttribute(implementationType, typeof(VariantServiceAliasAttribute)))?.Alias; diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs index 478ba41d..082cdc2e 100644 --- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs +++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs @@ -2250,7 +2250,7 @@ public async Task LazyVariantBasedInjectionScoped() services.AddSingleton(configuration) .AddScopedFeatureManagement() .AddFeatureFilter() - .WithVariantService(Features.VariantImplementationFeature, useKeyedService: true); + .WithVariantService(Features.VariantImplementationFeature); var targetingContextAccessor = new OnDemandTargetingContextAccessor(); @@ -2311,6 +2311,160 @@ public async Task LazyVariantBasedInjectionScoped() ); } + [Fact] + public async Task VariantServiceProviderResolvesKeyedService() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .Build(); + + IServiceCollection services = new ServiceCollection(); + + services.AddKeyedSingleton("AlgorithmBeta"); + services.AddKeyedSingleton("Sigma"); + services.AddKeyedSingleton("Omega", (sp, _) => new AlgorithmOmega("OMEGA")); + + services.AddSingleton(configuration) + .AddFeatureManagement() + .AddFeatureFilter() + .WithVariantService(Features.VariantImplementationFeature); + + var targetingContextAccessor = new OnDemandTargetingContextAccessor(); + + services.AddSingleton(targetingContextAccessor); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IVariantServiceProvider featuredAlgorithm = + serviceProvider.GetRequiredService>(); + + targetingContextAccessor.Current = new TargetingContext { UserId = "UserBeta" }; + IAlgorithm algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None); + Assert.NotNull(algorithm); + Assert.Equal("Beta", algorithm.Style); + + targetingContextAccessor.Current = new TargetingContext { UserId = "UserSigma" }; + algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None); + Assert.NotNull(algorithm); + Assert.Equal("Sigma", algorithm.Style); + + targetingContextAccessor.Current = new TargetingContext { UserId = "UserOmega" }; + algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None); + Assert.NotNull(algorithm); + Assert.Equal("OMEGA", algorithm.Style); + } + + [Fact] + public async Task VariantServiceProviderKeyedServiceIsLazilyInstantiated() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .Build(); + + IServiceCollection services = new ServiceCollection(); + + int betaInstantiationCount = 0; + int sigmaInstantiationCount = 0; + int omegaInstantiationCount = 0; + + services.AddKeyedSingleton("AlgorithmBeta", (sp, _) => + { + betaInstantiationCount++; + return new AlgorithmBeta(); + }); + services.AddKeyedSingleton("Sigma", (sp, _) => + { + sigmaInstantiationCount++; + return new AlgorithmSigma(); + }); + services.AddKeyedSingleton("Omega", (sp, _) => + { + omegaInstantiationCount++; + return new AlgorithmOmega("OMEGA"); + }); + + services.AddSingleton(configuration) + .AddFeatureManagement() + .AddFeatureFilter() + .WithVariantService(Features.VariantImplementationFeature); + + var targetingContextAccessor = new OnDemandTargetingContextAccessor(); + + services.AddSingleton(targetingContextAccessor); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IVariantServiceProvider featuredAlgorithm = + serviceProvider.GetRequiredService>(); + + // + // No variant resolved yet - nothing should be instantiated. + Assert.Equal(0, betaInstantiationCount); + Assert.Equal(0, sigmaInstantiationCount); + Assert.Equal(0, omegaInstantiationCount); + + // + // Resolve the Beta variant. Only AlgorithmBeta should be instantiated. + targetingContextAccessor.Current = new TargetingContext { UserId = "UserBeta" }; + IAlgorithm algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None); + Assert.Equal("Beta", algorithm.Style); + Assert.Equal(1, betaInstantiationCount); + Assert.Equal(0, sigmaInstantiationCount); + Assert.Equal(0, omegaInstantiationCount); + + // + // Resolving Beta again should reuse the cached instance - no new instantiation. + algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None); + Assert.Equal("Beta", algorithm.Style); + Assert.Equal(1, betaInstantiationCount); + Assert.Equal(0, sigmaInstantiationCount); + Assert.Equal(0, omegaInstantiationCount); + + // + // Resolve the Sigma variant. Only AlgorithmSigma should be instantiated additionally. + targetingContextAccessor.Current = new TargetingContext { UserId = "UserSigma" }; + algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None); + Assert.Equal("Sigma", algorithm.Style); + Assert.Equal(1, betaInstantiationCount); + Assert.Equal(1, sigmaInstantiationCount); + Assert.Equal(0, omegaInstantiationCount); + } + + [Fact] + public async Task VariantServiceProviderPrefersKeyedOverNonKeyed() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .Build(); + + IServiceCollection services = new ServiceCollection(); + + // + // Register both keyed and non-keyed implementations matching the same variant name. + // The keyed registration should take precedence. + services.AddSingleton(); + services.AddKeyedSingleton("AlgorithmBeta", (sp, _) => new AlgorithmOmega("KeyedBeta")); + + services.AddSingleton(configuration) + .AddFeatureManagement() + .AddFeatureFilter() + .WithVariantService(Features.VariantImplementationFeature); + + var targetingContextAccessor = new OnDemandTargetingContextAccessor(); + + services.AddSingleton(targetingContextAccessor); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IVariantServiceProvider featuredAlgorithm = + serviceProvider.GetRequiredService>(); + + targetingContextAccessor.Current = new TargetingContext { UserId = "UserBeta" }; + IAlgorithm algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None); + Assert.NotNull(algorithm); + Assert.Equal("KeyedBeta", algorithm.Style); + } + [Fact] public async Task VariantFeatureFlagWithContextualFeatureFilter() { From 401705338893e4572065bf076c5e45c2711f9c37 Mon Sep 17 00:00:00 2001 From: Stepami Date: Thu, 28 May 2026 21:17:57 +0300 Subject: [PATCH 10/13] refactor(VariantBasedInjection): VariantServiceProvider code readability improved Closes #564 --- .../VariantServiceProvider.cs | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/Microsoft.FeatureManagement/VariantServiceProvider.cs b/src/Microsoft.FeatureManagement/VariantServiceProvider.cs index 8f740618..d22e9034 100644 --- a/src/Microsoft.FeatureManagement/VariantServiceProvider.cs +++ b/src/Microsoft.FeatureManagement/VariantServiceProvider.cs @@ -50,34 +50,36 @@ public async ValueTask GetServiceAsync(CancellationToken cancellationT Variant variant = await _featureManager.GetVariantAsync(_featureName, cancellationToken); - TService implementation = null; + return variant != null ? _variantServiceCache.GetOrAdd(variant.Name, ResolveVariantService) : null; + } - if (variant != null) + private TService ResolveVariantService(string variantName) + { + if (TryGetKeyedVariantService(variantName, out var keyedVariantService)) { - implementation = _variantServiceCache.GetOrAdd( - variant.Name, - (variantName) => ResolveVariantService(variantName)); + return keyedVariantService; } - return implementation; + return GetVariantServiceFallback(variantName); } - private TService ResolveVariantService(string variantName) + private bool TryGetKeyedVariantService(string variantName, out TService keyedService) { - if (_serviceProvider is IKeyedServiceProvider) + if (_serviceProvider is IKeyedServiceProvider keyedServiceProvider) { - TService keyedService = _serviceProvider.GetKeyedService(variantName); - - if (keyedService != null) - { - return keyedService; - } + keyedService = keyedServiceProvider.GetKeyedService(variantName); + return keyedService != null; } - IEnumerable services = _serviceProvider.GetRequiredService>(); + keyedService = null; + return false; + } - return services.FirstOrDefault( - service => IsMatchingVariantName(service.GetType(), variantName)); + private TService GetVariantServiceFallback(string variantName) + { + return _serviceProvider + .GetRequiredService>() + .FirstOrDefault(service => IsMatchingVariantName(service.GetType(), variantName)); } private bool IsMatchingVariantName(Type implementationType, string variantName) From 902d6acf2cad4518ffaa12f95b92d28959f3963d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D0=BD=D0=B8=D0=BD=20=D0=A1=D1=82=D0=B5=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Fri, 29 May 2026 16:16:33 +0300 Subject: [PATCH 11/13] feat(VariantBasedInjection): Status Based resolution When no allocated variant matches, the provider now resolves an implementation by the flag's enabled status Closes #604 --- .../FeatureManagementBuilderExtensions.cs | 16 ++- .../VariantServiceProvider.cs | 49 +++++-- .../VariantServiceProviderOptions.cs | 21 +++ .../FeatureManagementTest.cs | 133 ++++++++++++++++++ 4 files changed, 207 insertions(+), 12 deletions(-) create mode 100644 src/Microsoft.FeatureManagement/VariantServiceProviderOptions.cs diff --git a/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs b/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs index 5d387a30..2a29b1c5 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementBuilderExtensions.cs @@ -47,6 +47,20 @@ public static IFeatureManagementBuilder WithTargeting(this IFeatureManagement /// Thrown if feature name parameter is null. /// Thrown if a variant service of the type has already been added. public static IFeatureManagementBuilder WithVariantService(this IFeatureManagementBuilder builder, string featureName) where TService : class + { + return WithVariantService(builder, featureName, new VariantServiceProviderOptions()); + } + + /// + /// Adds a to the feature management system. + /// + /// The used to customize feature management functionality. + /// The feature flag that should be used to determine which variant of the service should be used. + /// Options used to configure the variant service provider. + /// A that can be used to customize feature management functionality. + /// Thrown if feature name parameter is null. + /// Thrown if a variant service of the type has already been added. + public static IFeatureManagementBuilder WithVariantService(this IFeatureManagementBuilder builder, string featureName, VariantServiceProviderOptions options) where TService : class { if (string.IsNullOrEmpty(featureName)) { @@ -61,7 +75,7 @@ public static IFeatureManagementBuilder WithVariantService(this IFeatu Func> variantSpFactory = sp => { var featureManager = sp.GetRequiredService(); - return new VariantServiceProvider(featureName, featureManager, sp); + return new VariantServiceProvider(featureName, featureManager, sp, options); }; if (builder.Services.Any(descriptor => descriptor.ServiceType == typeof(IFeatureManager) && descriptor.Lifetime == ServiceLifetime.Scoped)) diff --git a/src/Microsoft.FeatureManagement/VariantServiceProvider.cs b/src/Microsoft.FeatureManagement/VariantServiceProvider.cs index d22e9034..f2ddbe99 100644 --- a/src/Microsoft.FeatureManagement/VariantServiceProvider.cs +++ b/src/Microsoft.FeatureManagement/VariantServiceProvider.cs @@ -20,7 +20,8 @@ internal class VariantServiceProvider : IVariantServiceProvider _variantServiceCache; + private readonly VariantServiceProviderOptions _options; + private readonly ConcurrentDictionary _variantServiceCache; /// /// Creates a variant service provider. @@ -28,15 +29,17 @@ internal class VariantServiceProvider : IVariantServiceProviderThe feature flag that should be used to determine which variant of the service should be used. /// The feature manager to get the assigned variant of the feature flag. /// The service provider used to resolve implementation variants of TService. If it implements , keyed resolution is used to enable lazy instantiation; otherwise all registered implementations are enumerated. + /// Options used to configure the variant service provider. /// Thrown if is null. /// Thrown if is null. /// Thrown if is null. - public VariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IServiceProvider serviceProvider) + public VariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IServiceProvider serviceProvider, VariantServiceProviderOptions options) { _featureName = featureName ?? throw new ArgumentNullException(nameof(featureName)); _featureManager = featureManager ?? throw new ArgumentNullException(nameof(featureManager)); _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - _variantServiceCache = new ConcurrentDictionary(); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _variantServiceCache = new ConcurrentDictionary(); } /// @@ -48,22 +51,46 @@ public async ValueTask GetServiceAsync(CancellationToken cancellationT { Debug.Assert(_featureName != null); - Variant variant = await _featureManager.GetVariantAsync(_featureName, cancellationToken); + var variantService = await ResolveByVariantAsync(cancellationToken); + if (variantService != null) + { + return variantService; + } + + return await ResolveByStatusAsync(cancellationToken); + } + + private async Task ResolveByVariantAsync(CancellationToken cancellationToken) + { + var variant = await _featureManager.GetVariantAsync(_featureName, cancellationToken); return variant != null ? _variantServiceCache.GetOrAdd(variant.Name, ResolveVariantService) : null; } - private TService ResolveVariantService(string variantName) + private async Task ResolveByStatusAsync(CancellationToken cancellationToken) { - if (TryGetKeyedVariantService(variantName, out var keyedVariantService)) + var isEnabled = await _featureManager.IsEnabledAsync(_featureName, cancellationToken); + var statusKey = isEnabled ? _options.FallbackWhenEnabled : _options.FallbackWhenDisabled; + + return statusKey != null ? _variantServiceCache.GetOrAdd(statusKey, ResolveVariantService) : null; + } + + private TService ResolveVariantService(object variantKey) + { + if (TryGetKeyedVariantService(variantKey, out var keyedVariantService)) { return keyedVariantService; } - return GetVariantServiceFallback(variantName); + if (variantKey is string variantName) + { + return GetVariantServiceFallback(variantName); + } + + return null; } - private bool TryGetKeyedVariantService(string variantName, out TService keyedService) + private bool TryGetKeyedVariantService(object variantName, out TService keyedService) { if (_serviceProvider is IKeyedServiceProvider keyedServiceProvider) { @@ -82,16 +109,16 @@ private TService GetVariantServiceFallback(string variantName) .FirstOrDefault(service => IsMatchingVariantName(service.GetType(), variantName)); } - private bool IsMatchingVariantName(Type implementationType, string variantName) + private static bool IsMatchingVariantName(Type implementationType, string name) { - string implementationName = ((VariantServiceAliasAttribute)Attribute.GetCustomAttribute(implementationType, typeof(VariantServiceAliasAttribute)))?.Alias; + var implementationName = ((VariantServiceAliasAttribute)Attribute.GetCustomAttribute(implementationType, typeof(VariantServiceAliasAttribute)))?.Alias; if (implementationName == null) { implementationName = implementationType.Name; } - return string.Equals(implementationName, variantName, StringComparison.OrdinalIgnoreCase); + return string.Equals(implementationName, name, StringComparison.OrdinalIgnoreCase); } } } diff --git a/src/Microsoft.FeatureManagement/VariantServiceProviderOptions.cs b/src/Microsoft.FeatureManagement/VariantServiceProviderOptions.cs new file mode 100644 index 00000000..f29d5510 --- /dev/null +++ b/src/Microsoft.FeatureManagement/VariantServiceProviderOptions.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.FeatureManagement +{ + /// + /// Specifies the aliases used by a variant service provider to resolve an implementation based on the feature flag status when no allocated variant matches. + /// + public class VariantServiceProviderOptions + { + /// + /// The alias used to resolve the variant service when the feature flag is enabled and no allocated variant matches. + /// + public object FallbackWhenEnabled { get; set; } = true; + + /// + /// The alias used to resolve the variant service when the feature flag is disabled and no allocated variant matches. + /// + public object FallbackWhenDisabled { get; set; } = false; + } +} diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs index 082cdc2e..65a09e9a 100644 --- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs +++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs @@ -2465,6 +2465,139 @@ public async Task VariantServiceProviderPrefersKeyedOverNonKeyed() Assert.Equal("KeyedBeta", algorithm.Style); } + [Fact] + public async Task VariantServiceProviderFallsBackToDefaultStatusAlias() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .Build(); + + // + // OnTestFeature has no variants and is always enabled; OffTestFeature has none and is always disabled. + // The provider should fall back to the FallbackWhenEnabled / FallbackWhenDisabled respectively. + IServiceCollection services = new ServiceCollection(); + + services.AddKeyedSingleton(true, (sp, _) => new AlgorithmOmega("Enabled")); + services.AddKeyedSingleton(false, (sp, _) => new AlgorithmOmega("Disabled")); + + services.AddSingleton(configuration) + .AddFeatureManagement() + .WithVariantService(Features.OnTestFeature); + + IAlgorithm algorithm = await services.BuildServiceProvider() + .GetRequiredService>() + .GetServiceAsync(CancellationToken.None); + Assert.Equal("Enabled", algorithm.Style); + + services = new ServiceCollection(); + + services.AddKeyedSingleton(true, (sp, _) => new AlgorithmOmega("Enabled")); + services.AddKeyedSingleton(false, (sp, _) => new AlgorithmOmega("Disabled")); + + services.AddSingleton(configuration) + .AddFeatureManagement() + .WithVariantService(Features.OffTestFeature); + + algorithm = await services.BuildServiceProvider() + .GetRequiredService>() + .GetServiceAsync(CancellationToken.None); + Assert.Equal("Disabled", algorithm.Style); + } + + [Fact] + public async Task VariantServiceProviderFallsBackToStatusAlias() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .Build(); + + // + // OnTestFeature has no variants and is always enabled; OffTestFeature has none and is always disabled. + // The provider should fall back to the FallbackWhenEnabled / FallbackWhenDisabled respectively. + IServiceCollection services = new ServiceCollection(); + + services.AddKeyedSingleton("WhenEnabled", (sp, _) => new AlgorithmOmega("Enabled")); + services.AddKeyedSingleton("WhenDisabled", (sp, _) => new AlgorithmOmega("Disabled")); + + var options = new VariantServiceProviderOptions + { + FallbackWhenEnabled = "WhenEnabled", + FallbackWhenDisabled = "WhenDisabled" + }; + + services.AddSingleton(configuration) + .AddFeatureManagement() + .WithVariantService(Features.OnTestFeature, options); + + IAlgorithm algorithm = await services.BuildServiceProvider() + .GetRequiredService>() + .GetServiceAsync(CancellationToken.None); + Assert.Equal("Enabled", algorithm.Style); + + services = new ServiceCollection(); + + services.AddKeyedSingleton("WhenEnabled", (sp, _) => new AlgorithmOmega("Enabled")); + services.AddKeyedSingleton("WhenDisabled", (sp, _) => new AlgorithmOmega("Disabled")); + + services.AddSingleton(configuration) + .AddFeatureManagement() + .WithVariantService(Features.OffTestFeature, options); + + algorithm = await services.BuildServiceProvider() + .GetRequiredService>() + .GetServiceAsync(CancellationToken.None); + Assert.Equal("Disabled", algorithm.Style); + } + + [Fact] + public async Task VariantServiceProviderPrefersAllocatedVariantOverStatusAlias() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .Build(); + + IServiceCollection services = new ServiceCollection(); + + // + // Conflict scenario: the allocated variant name "AlgorithmBeta" is also configured as the FallbackWhenEnabled. + // The variant resolution path runs first, so the same key resolves to the registered service for both + // a targeted user (variant allocated) and a non-targeted user (status fallback). The variant takes precedence + // when both paths could match, and the cache slot is shared without contention. + services.AddKeyedSingleton("AlgorithmBeta"); + + services.AddSingleton(configuration) + .AddFeatureManagement() + .AddFeatureFilter() + .WithVariantService( + Features.VariantImplementationFeature, new VariantServiceProviderOptions + { + FallbackWhenEnabled = "AlgorithmBeta", + FallbackWhenDisabled = "AlgorithmBeta" + }); + + var targetingContextAccessor = new OnDemandTargetingContextAccessor(); + + services.AddSingleton(targetingContextAccessor); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IVariantServiceProvider featuredAlgorithm = + serviceProvider.GetRequiredService>(); + + // + // UserBeta is allocated the "AlgorithmBeta" variant; resolution succeeds via the variant path. + targetingContextAccessor.Current = new TargetingContext { UserId = "UserBeta" }; + IAlgorithm algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None); + Assert.Equal("Beta", algorithm.Style); + + // + // Guest is outside the targeting audience; no variant is allocated and the flag is disabled, + // so resolution falls back to FallbackWhenDisabled ("AlgorithmBeta") and resolves the same registration. + targetingContextAccessor.Current = new TargetingContext { UserId = "Guest" }; + algorithm = await featuredAlgorithm.GetServiceAsync(CancellationToken.None); + Assert.Equal("Beta", algorithm.Style); + } + [Fact] public async Task VariantFeatureFlagWithContextualFeatureFilter() { From 67c22d5c96617562c834dd764bafd07815d0ee32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D0=BD=D0=B8=D0=BD=20=D0=A1=D1=82=D0=B5=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Fri, 29 May 2026 16:27:13 +0300 Subject: [PATCH 12/13] test(VariantBasedInjection): Status Based Resolution - Test for case without keyed registration Closes #604 --- .../FeatureManagementTest.cs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs index 65a09e9a..c7c071ab 100644 --- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs +++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs @@ -2504,6 +2504,51 @@ public async Task VariantServiceProviderFallsBackToDefaultStatusAlias() Assert.Equal("Disabled", algorithm.Style); } + [Fact] + public async Task VariantServiceProviderStatusEagerResolution() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .Build(); + + // + // OnTestFeature has no variants and is always enabled; OffTestFeature has none and is always disabled. + // The provider should fall back to the FallbackWhenEnabled / FallbackWhenDisabled respectively. + IServiceCollection services = new ServiceCollection(); + + services.AddSingleton(); + services.AddSingleton(); + + var options = new VariantServiceProviderOptions + { + FallbackWhenEnabled = nameof(AlgorithmBeta), + FallbackWhenDisabled = nameof(AlgorithmSigma) + }; + + services.AddSingleton(configuration) + .AddFeatureManagement() + .WithVariantService(Features.OnTestFeature, options); + + IAlgorithm algorithm = await services.BuildServiceProvider() + .GetRequiredService>() + .GetServiceAsync(CancellationToken.None); + Assert.Equal("Beta", algorithm.Style); + + services = new ServiceCollection(); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(configuration) + .AddFeatureManagement() + .WithVariantService(Features.OffTestFeature, options); + + algorithm = await services.BuildServiceProvider() + .GetRequiredService>() + .GetServiceAsync(CancellationToken.None); + Assert.Equal("Sigma", algorithm.Style); + } + [Fact] public async Task VariantServiceProviderFallsBackToStatusAlias() { From a861d33dc08920e1c0c91630327f5e16e5b13c8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D0=BD=D0=B8=D0=BD=20=D0=A1=D1=82=D0=B5=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Fri, 29 May 2026 16:43:25 +0300 Subject: [PATCH 13/13] docs(VariantServiceProvider): ResolveVariantService - added comments Closes #564 --- src/Microsoft.FeatureManagement/VariantServiceProvider.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Microsoft.FeatureManagement/VariantServiceProvider.cs b/src/Microsoft.FeatureManagement/VariantServiceProvider.cs index f2ddbe99..e82b27ca 100644 --- a/src/Microsoft.FeatureManagement/VariantServiceProvider.cs +++ b/src/Microsoft.FeatureManagement/VariantServiceProvider.cs @@ -33,6 +33,7 @@ internal class VariantServiceProvider : IVariantServiceProviderThrown if is null. /// Thrown if is null. /// Thrown if is null. + /// Thrown if is null. public VariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IServiceProvider serviceProvider, VariantServiceProviderOptions options) { _featureName = featureName ?? throw new ArgumentNullException(nameof(featureName)); @@ -77,6 +78,8 @@ private async Task ResolveByStatusAsync(CancellationToken cancellation private TService ResolveVariantService(object variantKey) { + // + // Prefer keyed resolution when supported. This enables lazy instantiation of variant implementations. if (TryGetKeyedVariantService(variantKey, out var keyedVariantService)) { return keyedVariantService; @@ -84,6 +87,8 @@ private TService ResolveVariantService(object variantKey) if (variantKey is string variantName) { + // + // Fall back to scanning non-keyed registrations and matching by VariantServiceAliasAttribute or type name. return GetVariantServiceFallback(variantName); }