Selection modes determine how the framework chooses which condition to execute for each method call. The framework supports multiple selection modes through a modular architecture.
| Mode | Package | Use Case | Selection Criteria |
|---|---|---|---|
| Boolean Feature Flag | Core | Simple on/off experiments | IFeatureManager enabled state |
| Configuration Value | Core | Multi-variant selection | IConfiguration key value |
| Variant Feature Flag | ExperimentFramework.FeatureManagement |
Targeted rollouts | IVariantFeatureManager variant name |
| Sticky Routing | ExperimentFramework.StickyRouting |
A/B testing by user | Hash of user identity |
| OpenFeature | ExperimentFramework.OpenFeature |
External flag management | OpenFeature provider evaluation |
Built-in modes (Boolean Feature Flag, Configuration Value) are included in the core ExperimentFramework package.
Extension modes (Variant Feature Flag, Sticky Routing, OpenFeature) are provided via separate NuGet packages. Install only what you need to keep your dependencies minimal.
Boolean feature flags provide simple on/off switching between two implementations based on feature flag state.
- Testing a new implementation against the current one
- Gradual rollout to a percentage of users
- Enabling features for specific user segments
- Quick rollback capability
Define the experiment using UsingFeatureFlag():
var experiments = ExperimentFrameworkBuilder.Create()
.Trial<IPaymentProcessor>(t => t
.UsingFeatureFlag("UseNewPaymentProvider")
.AddControl<StripePayment>("false")
.AddVariant<NewPaymentProvider>("true"));
services.AddExperimentFramework(experiments);Configure the feature flag in appsettings.json:
{
"FeatureManagement": {
"UseNewPaymentProvider": false
}
}The framework integrates with Microsoft.FeatureManagement, which provides advanced capabilities:
Percentage Rollout: Enable for a percentage of users
{
"FeatureManagement": {
"UseNewPaymentProvider": {
"EnabledFor": [
{
"Name": "Microsoft.Percentage",
"Parameters": {
"Value": 25
}
}
]
}
}
}Time Windows: Enable during specific time periods
{
"FeatureManagement": {
"UseNewPaymentProvider": {
"EnabledFor": [
{
"Name": "Microsoft.TimeWindow",
"Parameters": {
"Start": "2024-01-01T00:00:00Z",
"End": "2024-01-31T23:59:59Z"
}
}
]
}
}
}Targeting: Enable for specific users or groups
{
"FeatureManagement": {
"UseNewPaymentProvider": {
"EnabledFor": [
{
"Name": "Microsoft.Targeting",
"Parameters": {
"Audience": {
"Users": ["alice@example.com", "bob@example.com"],
"Groups": [
{
"Name": "BetaTesters",
"RolloutPercentage": 50
}
]
}
}
}
]
}
}
}The framework uses IFeatureManagerSnapshot for scoped services, ensuring consistent feature evaluation within a request:
using (var scope = serviceProvider.CreateScope())
{
var payment = scope.ServiceProvider.GetRequiredService<IPaymentProcessor>();
// All calls within this scope see the same feature flag value
await payment.AuthorizeAsync(100m);
await payment.ChargeAsync(100m);
await payment.CaptureAsync();
}Configuration values enable multi-variant selection based on a string configuration value.
- Testing more than two implementations
- Environment-specific selection (dev/staging/production)
- Runtime configuration changes
- Feature variations based on deployment
Define the experiment using UsingConfigurationKey():
var experiments = ExperimentFrameworkBuilder.Create()
.Trial<IRecommendationEngine>(t => t
.UsingConfigurationKey("Recommendations:Algorithm")
.AddControl<ContentBased>("")
.AddCondition<CollaborativeFiltering>("collaborative")
.AddCondition<HybridRecommendations>("hybrid")
.AddCondition<MLRecommendations>("ml"));
services.AddExperimentFramework(experiments);Configure the selection in appsettings.json:
{
"Recommendations": {
"Algorithm": "collaborative"
}
}When the configuration key is missing or empty, the control condition is used:
.AddControl<ContentBased>("") // Used when key is missing or emptyIf your configuration source supports reloading, changes take effect on the next method call:
builder.Configuration.AddJsonFile("appsettings.json",
optional: false,
reloadOnChange: true);The next method invocation will read the updated configuration value and select the appropriate condition.
Use configuration value selection for environment-specific behavior:
// appsettings.Development.json
{
"Cache": {
"Provider": "inmemory"
}
}
// appsettings.Production.json
{
"Cache": {
"Provider": "redis"
}
}.Trial<ICache>(t => t
.UsingConfigurationKey("Cache:Provider")
.AddControl<InMemoryCache>("inmemory")
.AddCondition<RedisCache>("redis"))Variant feature flags integrate with IVariantFeatureManager to support multi-variant experiments with sophisticated targeting.
Package Required: This selection mode requires the
ExperimentFramework.FeatureManagementpackage.
- Multi-variant experiments (A/B/C/D testing)
- Gradual rollout across multiple variants
- Targeted delivery of specific variants to user segments
- Complex allocation strategies
dotnet add package ExperimentFramework.FeatureManagement
dotnet add package Microsoft.FeatureManagementRegister the provider and define the experiment:
// Register the variant feature flag provider
services.AddExperimentVariantFeatureFlags();
services.AddFeatureManagement();
// Define experiments
var experiments = ExperimentFrameworkBuilder.Create()
.Trial<IEmailSender>(t => t
.UsingVariantFeatureFlag("EmailProvider")
.AddControl<SmtpSender>("smtp")
.AddVariant<SendGridSender>("sendgrid")
.AddVariant<MailgunSender>("mailgun")
.AddVariant<AmazonSesSender>("ses"));
services.AddExperimentFramework(experiments);Configure variants in appsettings.json:
{
"FeatureManagement": {
"EmailProvider": {
"EnabledFor": [
{
"Name": "Microsoft.Targeting",
"Parameters": {
"Audience": {
"Users": ["user1@example.com"],
"Groups": [
{
"Name": "BetaTesters",
"RolloutPercentage": 100
}
],
"DefaultRolloutPercentage": 0
}
}
}
],
"Variants": [
{
"Name": "smtp",
"ConfigurationValue": "smtp",
"StatusOverride": "Disabled"
},
{
"Name": "sendgrid",
"ConfigurationValue": "sendgrid",
"ConfigurationReference": "EmailProvider-SendGrid"
},
{
"Name": "mailgun",
"ConfigurationValue": "mailgun",
"ConfigurationReference": "EmailProvider-Mailgun"
},
{
"Name": "ses",
"ConfigurationValue": "ses",
"ConfigurationReference": "EmailProvider-SES"
}
],
"Allocation": {
"DefaultWhenEnabled": "sendgrid",
"User": [
{
"Variant": "ses",
"Users": ["alice@example.com"]
}
],
"Group": [
{
"Variant": "mailgun",
"Groups": ["BetaTesters"],
"RolloutPercentage": 50
},
{
"Variant": "sendgrid",
"Groups": ["BetaTesters"],
"RolloutPercentage": 50
}
]
}
}
}
}The variant feature manager selects which variant a user receives based on:
- User-specific assignments
- Group membership and rollout percentages within groups
- Default variant when enabled but no specific allocation matches
If IVariantFeatureManager is not available or returns null, the experiment uses the control condition:
// Variant manager not installed or returns null
// -> Uses SmtpSender (control condition)This allows the framework to work without a hard dependency on variant support.
The framework automatically extracts CancellationToken from method parameters and passes it to the variant manager:
public interface IEmailSender
{
Task SendAsync(string to, string subject, string body, CancellationToken cancellationToken);
}
// CancellationToken is automatically forwarded to IVariantFeatureManager
var result = await emailSender.SendAsync("user@example.com", "Subject", "Body", ct);Sticky routing provides deterministic condition selection based on user identity, ensuring the same user always sees the same condition.
Package Required: This selection mode requires the
ExperimentFramework.StickyRoutingpackage.
- A/B testing where users must consistently see the same variant
- Session-based experiments
- User-segmented experiments
- Avoiding variant flipping during a user session
dotnet add package ExperimentFramework.StickyRoutingSticky routing uses a SHA256 hash of the user identity and selector name to deterministically select a condition:
- Get user identity from
IExperimentIdentityProvider - Compute:
hash = SHA256(identity + ":" + selectorName) - Select condition:
conditions[hash % conditionCount]
The same identity always produces the same hash, ensuring consistent condition selection.
Implement IExperimentIdentityProvider to provide user identity:
using ExperimentFramework.StickyRouting;
public class UserIdentityProvider : IExperimentIdentityProvider
{
private readonly IHttpContextAccessor _httpContext;
public UserIdentityProvider(IHttpContextAccessor httpContext)
{
_httpContext = httpContext;
}
public bool TryGetIdentity(out string identity)
{
var userId = _httpContext.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userId))
{
identity = userId;
return true;
}
identity = string.Empty;
return false;
}
}Register the provider and identity provider, then define the experiment:
// Register sticky routing provider
services.AddExperimentStickyRouting();
// Register your identity provider
services.AddScoped<IExperimentIdentityProvider, UserIdentityProvider>();
// Define experiments
var experiments = ExperimentFrameworkBuilder.Create()
.Trial<IRecommendationEngine>(t => t
.UsingStickyRouting("RecommendationExperiment")
.AddControl<ContentBased>("control")
.AddCondition<CollaborativeFiltering>("variant-a")
.AddCondition<HybridRecommendations>("variant-b"));
services.AddExperimentFramework(experiments);Sticky routing distributes users evenly across conditions based on hash distribution:
Users: user1, user2, user3, user4, user5, user6
Condition Keys (sorted): control, variant-a, variant-b
Distribution:
- user1 -> hash % 3 = 0 -> control
- user2 -> hash % 3 = 1 -> variant-a
- user3 -> hash % 3 = 2 -> variant-b
- user4 -> hash % 3 = 0 -> control
- user5 -> hash % 3 = 1 -> variant-a
- user6 -> hash % 3 = 2 -> variant-b
The distribution is approximately even across all conditions.
If IExperimentIdentityProvider is not registered or returns no identity, sticky routing falls back to the default (control) condition:
// No identity provider registered or TryGetIdentity returns false
// -> Uses the control conditionSticky routing provides strong consistency guarantees:
- Same user + same experiment = same condition (always)
- Different users = distributed across conditions
- Changing condition keys or order will change user assignments
Condition keys are sorted alphabetically before hashing to ensure deterministic behavior:
// These produce the same results regardless of registration order
.AddControl<A>("alpha")
.AddCondition<B>("beta")
.AddCondition<C>("charlie")
// Internally sorted to: ["alpha", "beta", "charlie"]For multi-tenant applications, include tenant ID in the identity:
public class TenantUserIdentityProvider : IExperimentIdentityProvider
{
private readonly ITenantProvider _tenantProvider;
private readonly IHttpContextAccessor _httpContext;
public bool TryGetIdentity(out string identity)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var userId = _httpContext.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(tenantId) && !string.IsNullOrEmpty(userId))
{
identity = $"{tenantId}:{userId}";
return true;
}
identity = string.Empty;
return false;
}
}This ensures users in different tenants can be assigned to different conditions.
OpenFeature integration allows routing based on any OpenFeature-compatible feature flag provider.
Package Required: This selection mode requires the
ExperimentFramework.OpenFeaturepackage.
- Using external feature flag services (LaunchDarkly, Flagsmith, CloudBees, etc.)
- Standardized feature flag management across multiple platforms
- Vendor-agnostic feature flag evaluation
- Existing OpenFeature infrastructure
dotnet add package ExperimentFramework.OpenFeature
dotnet add package OpenFeatureRegister the provider, configure OpenFeature, and define experiments:
// Register OpenFeature provider
services.AddExperimentOpenFeature();
// Configure OpenFeature provider at startup
await Api.Instance.SetProviderAsync(new YourOpenFeatureProvider());
// Define experiments
var experiments = ExperimentFrameworkBuilder.Create()
.Trial<IPaymentProcessor>(t => t
.UsingOpenFeature("payment-processor")
.AddControl<StripeProcessor>("stripe")
.AddVariant<PayPalProcessor>("paypal")
.AddVariant<SquareProcessor>("square"));
services.AddExperimentFramework(experiments);When no flag key is specified, the framework generates a kebab-case name:
| Service Type | Generated Flag Key |
|---|---|
IPaymentProcessor |
payment-processor |
IUserService |
user-service |
The framework automatically detects the flag type:
Boolean flags (conditions are "true" and "false"):
- Uses
GetBooleanValueAsync()
String flags (multi-variant):
- Uses
GetStringValueAsync()
If OpenFeature is not configured or evaluation fails, the control condition is used.
For detailed configuration and provider examples, see the OpenFeature Integration Guide.
Use this decision tree to choose the right selection mode:
Do you need user-specific consistency?
├─ Yes: Use Sticky Routing
└─ No:
└─ Using external feature flag service (LaunchDarkly, Flagsmith, etc.)?
├─ Yes: Use OpenFeature
└─ No:
└─ How many variants?
├─ Two: Use Boolean Feature Flag
└─ More than two:
└─ Need advanced targeting (user segments, groups, etc.)?
├─ Yes: Use Variant Feature Flag
└─ No: Use Configuration Value
When multiple trials within a named experiment need to share the same selection configuration, you can define the selection mode at the experiment level. This eliminates redundancy and simplifies maintenance.
- Multiple related services should switch together based on the same flag
- Coordinated rollout across multiple interfaces
- Reducing configuration duplication
- Grouped trials that represent a single feature change
Define the selection mode once at the experiment level:
var experiments = ExperimentFrameworkBuilder.Create()
.Experiment("q1-2025-cloud-migration", exp => exp
.UsingFeatureFlag("UseCloudDb") // Shared across all trials
.Trial<IDatabase>(t => t
.AddControl<LocalDatabase>()
.AddCondition<CloudDatabase>("true"))
.Trial<ICache>(t => t
.AddControl<InMemoryCache>()
.AddCondition<RedisCache>("true"))
.ActiveFrom(DateTimeOffset.Parse("2025-01-01"))
.ActiveUntil(DateTimeOffset.Parse("2025-03-31")));
services.AddExperimentFramework(experiments);Both IDatabase and ICache trials share the same UseCloudDb feature flag. When the flag is enabled, both services use their condition implementations; when disabled, both use their control implementations.
All selection modes can be configured at the experiment level:
Boolean Feature Flags:
.Experiment("migration", exp => exp
.UsingFeatureFlag("MigrationEnabled")
.Trial<IDatabase>(t => ...)
.Trial<ICache>(t => ...))Configuration Values:
.Experiment("environment-config", exp => exp
.UsingConfigurationKey("Environment:Mode")
.Trial<IDatabase>(t => ...)
.Trial<ICache>(t => ...))Custom Modes:
.Experiment("custom-routing", exp => exp
.UsingCustomMode("StickyRouting", "my-experiment")
.Trial<IPayment>(t => ...)
.Trial<INotification>(t => ...))Extension Package Methods:
For ExperimentFramework.FeatureManagement:
.Experiment("variant-test", exp => exp
.UsingVariantFeatureFlag("ProviderVariant")
.Trial<IEmail>(t => ...)
.Trial<ISms>(t => ...))For ExperimentFramework.OpenFeature:
.Experiment("feature-rollout", exp => exp
.UsingOpenFeature("payment-experiment")
.Trial<IPayment>(t => ...)
.Trial<IRefund>(t => ...))Individual trials can override the experiment-level selection mode when needed:
.Experiment("mixed-config", exp => exp
.UsingFeatureFlag("UseCloudDb") // Experiment-level default
.Trial<IDatabase>(t => t
.AddControl<LocalDatabase>()
.AddCondition<CloudDatabase>("true")) // Uses experiment flag
.Trial<ITaxProvider>(t => t
.UsingFeatureFlag("UseTaxCalculation") // Override with different flag
.AddControl<DefaultTaxProvider>()
.AddCondition<OkTaxProvider>("true")))In this example:
IDatabaseuses the experiment-levelUseCloudDbflagITaxProvideroverrides with its ownUseTaxCalculationflag
Before (without shared selection mode):
.Experiment("migration", exp => exp
.Trial<IDatabase>(t => t
.UsingFeatureFlag("UseCloudDb") // Repetitive
.AddControl<LocalDatabase>()
.AddCondition<CloudDatabase>("true"))
.Trial<ICache>(t => t
.UsingFeatureFlag("UseCloudDb") // Repetitive
.AddControl<InMemoryCache>()
.AddCondition<RedisCache>("true")))After (with shared selection mode):
.Experiment("migration", exp => exp
.UsingFeatureFlag("UseCloudDb") // Single declaration
.Trial<IDatabase>(t => t
.AddControl<LocalDatabase>()
.AddCondition<CloudDatabase>("true"))
.Trial<ICache>(t => t
.AddControl<InMemoryCache>()
.AddCondition<RedisCache>("true")))This approach:
- Reduces configuration duplication
- Makes the shared dependency explicit
- Simplifies flag management
- Maintains consistency across related trials
You can define multiple experiments on different services:
var experiments = ExperimentFrameworkBuilder.Create()
.Trial<IDatabase>(t => t
.UsingFeatureFlag("UseCloudDb")
.AddControl<LocalDatabase>("false")
.AddVariant<CloudDatabase>("true"))
.Trial<ICache>(t => t
.UsingConfigurationKey("Cache:Provider")
.AddControl<InMemoryCache>("inmemory")
.AddCondition<RedisCache>("redis"))
.Trial<IRecommendationEngine>(t => t
.UsingStickyRouting("RecommendationExperiment")
.AddControl<ContentBased>("control")
.AddCondition<CollaborativeFiltering>("variant-a"));
services.AddExperimentFramework(experiments);Each experiment operates independently with its own selection mode and configuration.
Need a selection mode that isn't built-in? Create your own with minimal boilerplate:
[SelectionMode("Redis")]
public class RedisSelectionProvider : SelectionModeProviderBase
{
private readonly IConnectionMultiplexer _redis;
public RedisSelectionProvider(IConnectionMultiplexer redis)
{
_redis = redis;
}
public override async ValueTask<string?> SelectTrialKeyAsync(SelectionContext context)
{
var value = await _redis.GetDatabase().StringGetAsync(context.SelectorName);
return value.HasValue ? value.ToString() : null;
}
}
// Register with one line
services.AddSelectionModeProvider<RedisSelectionProvider>();
// Use with UsingCustomMode
.Trial<ICache>(t => t
.UsingCustomMode("Redis", "cache:provider")
.AddControl<MemoryCache>()
.AddCondition<RedisCache>("redis"))See the Extensibility Guide for complete details on creating custom providers.
- Error Handling - Handle failures in experimental implementations
- Extensibility - Create custom selection mode providers
- Naming Conventions - Customize how feature flags and config keys are named
- Samples - See complete examples of each selection mode