Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions examples/VariantServiceDemo/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@
builder.Services.AddApplicationInsightsTelemetry();

//
// Add variant implementations of ICalculator
builder.Services.AddSingleton<ICalculator, DefaultCalculator>();
// Add variant implementations of ICalculator using keyed services so that only the
// implementation matching the assigned variant is instantiated on demand.
builder.Services.AddKeyedSingleton<ICalculator, DefaultCalculator>("DefaultCalculator");

builder.Services.AddSingleton<ICalculator, RemoteCalculator>();
builder.Services.AddKeyedSingleton<ICalculator, RemoteCalculator>("RemoteCalculator");

//
// Enter feature management
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,14 @@ public static IFeatureManagementBuilder WithVariantService<TService>(this IFeatu
builder.Services.AddScoped<IVariantServiceProvider<TService>>(sp => new VariantServiceProvider<TService>(
featureName,
sp.GetRequiredService<IVariantFeatureManager>(),
sp.GetRequiredService<IEnumerable<TService>>()));
sp));
}
else
{
builder.Services.AddSingleton<IVariantServiceProvider<TService>>(sp => new VariantServiceProvider<TService>(
featureName,
sp.GetRequiredService<IVariantFeatureManager>(),
sp.GetRequiredService<IEnumerable<TService>>()));
sp));
}

return builder;
Expand Down
35 changes: 25 additions & 10 deletions src/Microsoft.FeatureManagement/VariantServiceProvider.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,7 +17,7 @@ namespace Microsoft.FeatureManagement
/// </summary>
internal class VariantServiceProvider<TService> : IVariantServiceProvider<TService> where TService : class
{
private readonly IEnumerable<TService> _services;
private readonly IServiceProvider _serviceProvider;
private readonly IVariantFeatureManager _featureManager;
private readonly string _featureName;
private readonly ConcurrentDictionary<string, TService> _variantServiceCache;
Expand All @@ -26,15 +27,15 @@ internal class VariantServiceProvider<TService> : IVariantServiceProvider<TServi
/// </summary>
/// <param name="featureName">The feature flag that should be used to determine which variant of the service should be used.</param>
/// <param name="featureManager">The feature manager to get the assigned variant of the feature flag.</param>
/// <param name="services">Implementation variants of TService.</param>
/// <param name="serviceProvider">The service provider used to resolve implementation variants of TService. If it implements <see cref="IKeyedServiceProvider"/>, keyed resolution is used to enable lazy instantiation; otherwise all registered implementations are enumerated.</param>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="featureName"/> is null.</exception>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="featureManager"/> is null.</exception>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="services"/> is null.</exception>
public VariantServiceProvider(string featureName, IVariantFeatureManager featureManager, IEnumerable<TService> services)
/// <exception cref="ArgumentNullException">Thrown if <paramref name="serviceProvider"/> is null.</exception>
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<string, TService>();
}

Expand All @@ -55,16 +56,30 @@ public async ValueTask<TService> 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)
Copy link
Copy Markdown

@Stepami Stepami May 27, 2026

Choose a reason for hiding this comment

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

it's behavioural BC. if there is keyed di compatible user he'll need to change his whole DI setup after update

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

i think you forgot that useKeyedService option

Copy link
Copy Markdown
Member Author

@zhiyuanliang-ms zhiyuanliang-ms May 28, 2026

Choose a reason for hiding this comment

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

Yes. I discussed with @jimmyca15. The discussion result is to not have useKeyedService toggle.

it's behavioural BC. if there is keyed di compatible user he'll need to change his whole DI setup after update

This is not a breaking change, because we have fallback logic, it will first try to get keyed service and if there is no keyed service match the variant name, it will still use the previous way (GetRequiredService<IEnumerable>()) to get all implementation and inspect them one by one.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This disrupts backward compatibility.

Let's say I am net8 variant sp user. Till now my services were registered like this:

services.AddSingleton<IFeature, Variant1>();
services.AddSingleton<IFeature, Variant2>();

After I get the update my setup won't work as variant sp will use keyed di because it's available. This will make me change DI setup to keyed just to have it working.

Am I wrong in BC definition?

{
TService keyedService = _serviceProvider.GetKeyedService<TService>(variantName);

if (keyedService != null)
{
return keyedService;
}
}

IEnumerable<TService> services = _serviceProvider.GetRequiredService<IEnumerable<TService>>();

return services.FirstOrDefault(
service => IsMatchingVariantName(service.GetType(), variantName));
}

private bool IsMatchingVariantName(Type implementationType, string variantName)
{
string implementationName = ((VariantServiceAliasAttribute)Attribute.GetCustomAttribute(implementationType, typeof(VariantServiceAliasAttribute)))?.Alias;
Expand Down
151 changes: 151 additions & 0 deletions tests/Tests.FeatureManagement/FeatureManagementTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2234,6 +2234,157 @@ public async Task VariantBasedInjection()
);
}

[Fact]
public async Task VariantServiceProviderResolvesKeyedService()
{
IConfiguration configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
.Build();

IServiceCollection services = new ServiceCollection();

services.AddKeyedSingleton<IAlgorithm, AlgorithmBeta>("AlgorithmBeta");
services.AddKeyedSingleton<IAlgorithm, AlgorithmSigma>("Sigma");
services.AddKeyedSingleton<IAlgorithm>("Omega", (sp, _) => new AlgorithmOmega("OMEGA"));

services.AddSingleton(configuration)
.AddFeatureManagement()
.AddFeatureFilter<TargetingFilter>()
.WithVariantService<IAlgorithm>(Features.VariantImplementationFeature);

var targetingContextAccessor = new OnDemandTargetingContextAccessor();

services.AddSingleton<ITargetingContextAccessor>(targetingContextAccessor);

ServiceProvider serviceProvider = services.BuildServiceProvider();

IVariantServiceProvider<IAlgorithm> featuredAlgorithm = serviceProvider.GetRequiredService<IVariantServiceProvider<IAlgorithm>>();

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<IAlgorithm>("AlgorithmBeta", (sp, _) =>
{
betaInstantiationCount++;
return new AlgorithmBeta();
});
services.AddKeyedSingleton<IAlgorithm>("Sigma", (sp, _) =>
{
sigmaInstantiationCount++;
return new AlgorithmSigma();
});
services.AddKeyedSingleton<IAlgorithm>("Omega", (sp, _) =>
{
omegaInstantiationCount++;
return new AlgorithmOmega("OMEGA");
});

services.AddSingleton(configuration)
.AddFeatureManagement()
.AddFeatureFilter<TargetingFilter>()
.WithVariantService<IAlgorithm>(Features.VariantImplementationFeature);

var targetingContextAccessor = new OnDemandTargetingContextAccessor();

services.AddSingleton<ITargetingContextAccessor>(targetingContextAccessor);

ServiceProvider serviceProvider = services.BuildServiceProvider();

IVariantServiceProvider<IAlgorithm> featuredAlgorithm = serviceProvider.GetRequiredService<IVariantServiceProvider<IAlgorithm>>();

//
// 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<IAlgorithm, AlgorithmBeta>();
services.AddKeyedSingleton<IAlgorithm>("AlgorithmBeta", (sp, _) => new AlgorithmOmega("KeyedBeta"));

services.AddSingleton(configuration)
.AddFeatureManagement()
.AddFeatureFilter<TargetingFilter>()
.WithVariantService<IAlgorithm>(Features.VariantImplementationFeature);

var targetingContextAccessor = new OnDemandTargetingContextAccessor();

services.AddSingleton<ITargetingContextAccessor>(targetingContextAccessor);

ServiceProvider serviceProvider = services.BuildServiceProvider();

IVariantServiceProvider<IAlgorithm> featuredAlgorithm = serviceProvider.GetRequiredService<IVariantServiceProvider<IAlgorithm>>();

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()
{
Expand Down
Loading