Switch application features on, off, or to any defined value.
Conditional feature switch support (boolean)
boolisOn=awaitfeatureService.IsOn("feature");
Any type can be switched to any value; for example to do A/B testing.
boolisOn=awaitfeatureService.GetValue<bool>("feature");varmyTypedValue=awaitfeatureService.GetValue<MyType>("feature");if(myTypedValue.Setting=='A') ...
Feature filters and filter groups, allowing for complex rule evaluation.
Turn a feature on/off on feature and filter group level. (aka main/kill-switch)
Contextual feature evaluation.
- Via evaluation context parameter:
bool isOn = featureService.IsOn("feature", evaluationContext: "mycontext"); - Via evaluation context accessor.
serviceCollection.AddScoped<IEvaluationContextAccessor, MyEvaluationContextAccessor>() - Both parameter and accessor can be combined together.
- Via evaluation context parameter:
Out-of-the-box feature filters
DateTimeto set a feature on or off on a specific date.ParallelChange- in conjunction with an evaluation context parameter gives support for the ParallelChange pattern aka Expand/(Migrate/)Contract pattern.
Configurable feature evaluation caching
- For performance and 'stable' feature state.
- Supports multiple caches / cache levels.
- For example
- Level-1: In-memory cache with a scoped lifetime.
- Level-2: Distributed cache like Redis.
- For example
- Feature evaluations are cached using the evaluation context.
Pluggable feature definition providers
InMemoryFeatureDefinitionProvidercan be used in automated tests, or as an intermediate.- Load feature definitions from JSON
- Load feature definitions programmatically
Dependency Injection framework independent.
MSTest attributes - run a unit test multiple times with feature on/off.
Define a boolean feature
varfeatureDefinitionProvider=serviceProvider.GetRequired<InMemoryFeatureDefinitionProvider>();featureDefinitionProvider.SetFeature("MyBoolFeature",isOn:true);
Note: the feature is on by default, use
isOnto turn the feature offUse the feature switch
publicclassMyClass{privatereadonlyIFeatureServicefeatureService;publicSome(IFeatureServicefeatureService){this.featureService=featureService;}publicasyncTaskExecute(){if(awaitthis.featureService.IsOn("MyBoolFeature")){ ...}// orif(awaitthis.featureService.GetValue<bool>("MyBoolFeature")){ ...}}}
Note: if the feature hasn't been defined false will be returned.
When using Microsoft.Extensions.DependencyInjection, e.g. ASP.NET Core, you can use:
// dotnet add package FeatureSwitches.ServiceCollectionserviceCollection.AddFeatureSwitches(addScopedCache:true);Or instead of that NuGet define what you need manually:
serviceCollection.AddSingleton<IFeatureFilterMetadata,DateTimeFeatureFilter>();serviceCollection.AddScoped<FeatureService>();serviceCollection.AddScoped<IFeatureCache,InMemoryFeatureCache>();serviceCollection.AddSingleton<InMemoryFeatureDefinitionProvider>();serviceCollection.AddSingleton<IFeatureDefinitionProvider>(sp =>sp.GetRequiredService<InMemoryFeatureDefinitionProvider>());serviceCollection.AddSingleton<IFeatureCacheContextAccessor,EmptyFeatureCacheContextAccessor>();When using Autofac:
builder.RegisterType<DateTimeFeatureFilter>().As<IFeatureFilterMetadata>().SingleInstance();builder.RegisterType<FeatureService>().As<IFeatureService>().InstancePerLifetimeScope();builder.RegisterType<InMemoryFeatureCache>().As<IFeatureCache>().InstancePerLifetimeScope();builder.RegisterType<InMemoryFeatureDefinitionProvider>().AsSelf().As<IFeatureDefinitionProvider>().SingleInstance();Define a custom feature type
publicenumDirection{Left,Right}featureDefinitionProvider.SetFeature("DirectionFeature",isOn:true,offValue:Direction.Left,onValue:Direction.Right);
Usage
if(awaitthis.featureService.GetValue<Direction>("DirectionFeature")==DirectionFeature.Left){ ...}
Note: If the switch value cannot be converted to the featuretype the offValue will be returned.
Ambient evaluation context
publicclassMyEvaluationContextAccessor:IEvaluationContextAccessor{publicobject?GetContext(){return(Thread.CurrentPrincipal?.IdentityasClaimsIdentity)?.Name;}}serviceCollection.AddSingleton<IEvaluationContextAccessor,MyEvaluationContextAccessor>();
Dependency scope evaluation context
publicclassMyScopedEvaluationContextAccessor:IEvaluationContextAccessor{privatereadonlyMyApplicationContextcontext;publicMyScopedEvaluationContextAccessor(MyApplicationContextcontext){this.context=context;}publicobject?GetContext(){returnthis.context?.Name;}}serviceCollection.AddScoped<IEvaluationContextAccessor,MyScopedEvaluationContextAccessor>();
A feature is on when it is set to on and all applied feature filters decide that the feature should be on (logical AND).
Scoped feature filters
publicclassMyAppContext{publicstringName{get;set;}}publicclassMyUserFeatureFilterSettings{publicHashSet<string>AllowedNames{get;set;}}publicclassMyUserFeatureFilter:IFeatureFilter{privatereadonlyMyAppContextappContext;publicstringName=>"User";publicMyUserFeatureFilter(MyAppContextappContext){this.appContext=appContext;}publicasyncTask<bool>IsOn(FeatureFilterEvaluationContextcontext){varsettings=context.GetSettings<MyUserFeatureFilterSettings>();returnsettings.AllowedNames.Contains(this.appContext.Name)??false;}}varfeatureDefinitionProvider=serviceProvider.GetRequired<InMemoryFeatureDefinitionProvider>();featureDefinitionProvider.SetFeature("MyBoolFeature",isOn:true);featureDefinitionProvider.SetFeatureFilter("MyBoolFeature","User",config:"{ \"AllowedNames\": [\"John\", \"Jane\"] }");// orfeatureDefinitionProvider.SetFeatureFilter("MyBoolFeature","User",config:newMyUserFeatureFilterSettings{AllowedNames=newHashSet<string>{"John","Jane"}});serviceCollection.AddScoped<MyAppContext>();using(varscope=serviceProvider.CreateScope()){scope.ServiceProvider.GetRequired<MyAppContext>().Name="John";varfeatureService=scope.ServiceProvider.GetRequired<FeatureService>();Assert.IsTrue(awaitfeatureService.IsOn("MyBoolFeature"));}
Feature filter groups
A feature is on when it is set to on and the first applied filter groups decides that the feature should indeed be on (Logical OR). The feature's
OnValueis taken from that filter group.Feature filters can be applied to a feature filter group.
As an example we define a feature with 2 filter groups: group A and group B. Users in group A (John) should always get the feature. Users in group B (Jane) should get the feature from a certain launch date.
publicenumAB{Off,A,B}varfeatureDefinitionProvider=serviceProvider.GetRequired<InMemoryFeatureDefinitionProvider>();featureDefinitionProvider.SetFeature<AB>("Feature",isOn:true,offValue:AB.Off);featureDefinitionProvider.SetFeatureGroup("Feature","GroupA",isOn:true,onValue:AB.A);featureDefinitionProvider.SetFeatureGroup("Feature","GroupB",isOn:true,onValue:AB.B);featureDefinitionProvider.SetFeatureFilter("Feature","User",group:"GroupA",config:newMyUserFeatureFilterSettings{AllowedNames=newHashSet<string>{"John"}});featureDefinitionProvider.SetFeatureFilter("Feature","User",group:"GroupB",config:newMyUserFeatureFilterSettings{AllowedNames=newHashSet<string>{"Jane"}});featureDefinitionProvider.SetFeatureFilter("Feature","DateTime",group:"GroupA",config:newDateTimeFeatureFilterSettings{From=newDateTimeOffSet(newDateTime(2020,11,3))});using(varscope=serviceProvider.CreateScope()){scope.ServiceProvider.GetRequired<MyAppContext>().Name="John";Assert.AreEqual(AB.A,awaitfeatureService.GetValue<AB>("Feature"));}using(varscope=serviceProvider.CreateScope()){scope.ServiceProvider.GetRequired<MyAppContext>().Name="Jane";SystemClock.Now=newDateTimeOffSet(newDateTime(2020,11,2));Assert.AreEqual(AB.Off,awaitfeatureService.GetValue<AB>("Feature"));SystemClock.Now=newDateTimeOffSet(newDateTime(2020,11,3));Assert.AreEqual(AB.B,awaitfeatureService.GetValue<AB>("Feature"));}using(varscope=serviceProvider.CreateScope()){scope.ServiceProvider.GetRequired<MyAppContext>().Name="James";Assert.AreEqual(AB.Off,awaitfeatureService.GetValue<AB>("Feature"));}
A contextual feature filter implements IContextualFeatureFilter. A handy ContextualFeatureFilter<T> subclass is provided.
publicclassMyAppContext{publicstringName{get;set;}}publicclassMyUserFeatureFilter:ContextualFeatureFilter<MyAppContext>{publicstringName=>"User";publicasyncTask<bool>IsOn(FeatureFilterEvaluationContextcontext,MyAppContextappContext){varsettings=context.Parameters.Get<MyUserFeatureFilterSettings>();returnsettings?.AllowedNames.Contains(appContext.Name)??false;}}varfeatureDefinitionProvider=serviceProvider.GetRequired<InMemoryFeatureDefinitionProvider>();featureDefinitionProvider.SetFeature("MyBoolFeature",isOn:true);featureDefinitionProvider.SetFeatureFilter("MyBoolFeature","User","{ \"allowedNames\": [\"John\", \"Jane\"] }");varfeatureService=serviceProvider.GetRequired<FeatureService>();Assert.IsTrue(awaitfeatureService.IsOn("MyBoolFeature",newMyAppContext{Name="John"}));The ParallelChange contextual feature filter can be applied to any feature or feature filter group.
Define the feature
varfeatureDefinitionProvider=serviceProvider.GetRequired<InMemoryFeatureDefinitionProvider>();featureDefinitionProvider.SetFeature("MyBoolFeature",isOn:true);featureDatabase.SetFeatureFilter("FeatureA","ParallelChange","\"Expanded\"");
When writing data
if(!awaitfeatureService.IsOn("feature",ParallelChange.Contracted)){Perform_Old_DataWrite();}if(awaitfeatureService.IsOn("feature",ParallelChange.Expanded)){Perform_New_DataWrite();}
When checking in the UI if the feature is on
if(awaitfeatureService.IsOn("feature",ParallelChange.Migrated)){Perform_New_UI();}else{Perform_Old_UI();}
Alternatively the UI can do
if(awaitfeatureService.IsOn("feature")){Perform_New_UI();}else{Perform_Old_UI();}
The InMemoryFeatureDefinitionProvider supports loading features from a JSON file.
[
{
"Name": "FeatureA",
"OffValue": false,
"IsOn": true,
"OnValue": false,
"Filters": [
{
"Name": "User",
"Settings": {
"AllowedNames": [
"John"
]
},
"Group": "GroupA"
},
{
"Name": "User",
"Settings": {
"AllowedNames": [
"Jane"
]
},
"Group": "GroupB"
}
],
"FilterGroups": [
{
"Name": "GroupA",
"IsOn": false,
"OnValue": true
},
{
"Name": "GroupB",
"IsOn": true,
"OnValue": true
}
]
}
]varfeatureDefinitionProvider=serviceProvider.GetRequired<InMemoryFeatureDefinitionProvider>();featureDefinitionProvider.LoadFromJson(json);// or do your own deserializationusing(varfs=File.OpenRead("features.json")){vardefinitions=awaitSystem.Text.Json.JsonSerializer.DeserializeAsync<IEnumerable<FeatureDefinition>>(fs);featureDefinitionProvider.Load(definitions);}There are many different databases, dataaccess layers, UI frameworks and that's why this library doesn't come with any of those.
To give some direction for an Entity Framework + SQL setup. A SQL schema might look roughly like:
CREATE TABLE [Features] (
[Id] uniqueidentifier NOT NULL,
[Name] nvarchar(50) NOT NULL,
[Description] nvarchar(200) NOT NULL,
[Type] nvarchar(50) NOT NULL,
[OffValue] varbinary(100) NOT NULL,
[IsOn] bitNOT NULL,
[OnValue] varbinary(100) NOT NULL
);
CREATE TABLE [FeatureFilterGroups] (
[Id] uniqueidentifier NOT NULL,
[FeatureId] uniqueidentifier NOT NULL,
[Name] nvarchar(50) NOT NULL,
[IsOn] bitNOT NULL,
[OnValue] varbinary(100) NOT NULL
);
CREATE TABLE [FeatureFilters] ( [Id] uniqueidentifier NOT NULL,
[FeatureId] uniqueidentifier NOT NULL,
[GroupId] uniqueidentifier NULL,
[Type] nvarchar(50) NOT NULL,
[Settings] varbinary(max) NOT NULL,
);Of course you should define primary keys, foreign key relations, any indexes and more columns depending on your requirements.
Entities are similar to the tables:
publicclassFeature{publicGuidId{get;set;}publicstringName{get;set;}publicstringDescription{get;set;}publicIList<ApplicationFeatureFilter>Filters{get;privateset;}=newList<ApplicationFeatureFilter>();publicIList<ApplicationFeatureFilterGroup>Groups{get;privateset;}=newList<ApplicationFeatureFilterGroup>();
...}
...publicvoidConfigure(EntityTypeBuilder<Feature>builder){builder.HasMany(x =>x.Filters).WithOne(x =>x.Feature).IsRequired(false).HasForeignKey(x =>x.FeatureId);builder.HasMany(x =>x.Groups).WithOne(x =>x.Feature).IsRequired(false).HasForeignKey(x =>x.FeatureId);}A provider:
publicclassDatabaseFeatureDefinitionProvider:IFeatureDefinitionProvider{privatereadonlyDbContextdbContext;publicDatabaseFeatureDefinitionProvider(DbContextdbContext){this.dbContext=dbContext;}publicasyncTask<string[]>GetFeatures(CancellationTokencancellationToken=default){varfeatures=awaitthis.dbContext.ApplicationFeatures.OrderBy(x =>x.Name).Select(x =>x.Name).ToArrayAsync(cancellationToken).ConfigureAwait(false);returnfeatures;}publicasyncTask<FeatureDefinition?>GetFeatureDefinition(stringfeature,CancellationTokencancellationToken=default){varapplicationFeature=awaitthis.dbContext.ApplicationFeatures.Select(x =>new{x.Name,x.IsOn,x.OnValue,x.OffValue,Filters=x.Filters.Select(f =>new{f.Type,f.Settings,f.GroupId}).ToList(),Groups=x.Groups.Select(g =>new{g.Id,g.Name,g.IsOn,g.OnValue}).ToList()}).Where(x =>x.Name==feature).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);if(applicationFeature==null){returnnull;}varfeatureDefinition=newFeatureDefinition{Name=applicationFeature.Name,OffValue=JsonSerializer.Deserialize<object?>(applicationFeature.OffValue),IsOn=applicationFeature.IsOn,OnValue=JsonSerializer.Deserialize<object?>(applicationFeature.OnValue)};vargroups=newDictionary<Guid,string>();foreach(vargroupinapplicationFeature.Groups){featureDefinition.FilterGroups.Add(newFeatureFilterGroupDefinition{OnValue=JsonSerializer.Deserialize<object?>(group.OnValue),Name=group.Name,IsOn=group.IsOn});groups.Add(group.Id,group.Name);}foreach(varfilterinapplicationFeature.Filters){featureDefinition.Filters.Add(newFeatureFilterDefinition{Group=filter.GroupId==null?null:groups[filter.GroupId.Value],Name=filter.Type,Settings=JsonSerializer.Deserialize<object?>(filter.Settings)});}returnfeatureDefinition;}}Be aware that each await featureService.IsOn(feature) call will, if the feature evaluation is not cached, invoke the DatabaseFeatureDefinitionProvider.GetFeatureDefinition( and query the database.
It depends on your specific setup if that is acceptable or if you need any caching. Caching can be done using the feature evaluation caching system, or you could cache the query results within the DatabaseFeatureDefinitionProvider in for example Redis.
Caching is a complex topic. What you need all depends on your requirements (business, performance, etc) and application setup.
The InMemoryFeatureCache uses a simple ConcurrenctDictionary and typically is registered with a scoped lifetime. Any subsequent calls to await featureService.IsOn(feature) within the dependency scope will deliver the cached result. This can be perfect for an http request handler (e.g. ASP.Net controller) where you want both performance and a stable feature evaluation result during the request.
In a distributed application setup you might have multiple instances serving requests and one instance could evaluate the feature to be on (for example a time activated feature), while at the same time another instance could evaluate the feature to be off. This might, or might not be acceptable, it all depends on your requirements.
If this is not acceptable you probably need a distributed cache like Redis. Since there are many cache subtleties (TTL on cached entries, Redis 6 client-caching yes or no, cache invalidation) implementing an IFeatureCache is up to you.
serviceCollection.AddScoped<IFeatureCache,YourRedisFeatureCache>();You can also setup Redis as a 2nd level feature evaluation cache:
serviceCollection.AddScoped<IFeatureCache,InMemoryFeatureCache>();serviceCollection.AddScoped<IFeatureCache,YourRedisFeatureCache>();The FeatureSwitches.MSTest nuget delivers functionality to run a test multiple times for all defined feature On/Off combinations.
Run the same test twice with feature On and Off:
[FeatureTestMethod(onOff:"FeatureA")]publicvoidMyTestMethod(){varfeatureDefinitionProvider=serviceProvider.GetRequired<InMemoryFeatureDefinitionProvider>();varfeatureService=serviceProvider.GetRequired<IFeatureService>();featureDefinitionProvider.Load(FeatureTestMethodAttribute.GetFeatures(this.TestContex));if(awaitfeatureService.IsOn("FeatureA")){// FeatureA is On asserts}else{// FeatureA is Off asserts}}The FeatureTestMethodAttribute defines a feature with Name=FeatureA,OnValue=true,OffValue=false. The method FeatureTestMethodAttribute.GetFeatures(this.TestContext) returns the feature definitions for the current TestContext with their IsOn=true/false.
[TestClass]publicclassMyTestClass{publicTestContextTestContext{get;set;}[TestInitialize]publicvoidInitialize(){
...var featureDefinitionProvider =serviceProvider.GetRequired<InMemoryFeatureDefinitionProvider>();featureDefinitionProvider.Load(FeatureTestMethodAttribute.GetFeatures(this.TestContext));}[FeatureTestMethod(onOff:"FeatureA")]publicvoidMyTestMethod(){
...var featureService =serviceProvider.GetRequired<IFeatureService>();if(awaitfeatureService.IsOn("FeatureA")){// FeatureA is On asserts}else{// FeatureA is Off asserts}}}Set features On or Off without varying them.
[FeatureTestMethod(onOff:"FeatureA",on:"AlwaysOn",off:"AllwaysOn")]publicvoidMyTestMethod(){
...var featureService =serviceProvider.GetRequired<IFeatureService>();Assert.IsTrue(awaitfeatureService.IsOn("AlwaysOn"));Assert.IsFalse(awaitfeatureService.IsOff("AlwaysOff"));if(awaitfeatureService.IsOn("FeatureA")){// FeatureA is On asserts}else{// FeatureA is Off asserts}}[FeatureTestMethod(onOff:"FeatureA")][FeatureTestValue("FeatureA",onValue:"On",offValue:"Off")]publicvoidMyTestMethod(){
...var featureService =serviceProvider.GetRequired<IFeatureService>();varfeatureValue=awaitfeatureService.GetValue<string>("FeatureA");if(featureValue=="On"){// Feature is On asserts}elseif(featureValue=="Off"){// Feature is Off asserts}}The FeatureTestValueAttribute defines the on and off values for a feature. It's not necessary to specify the attribute for boolean features as that is assumed to be the default when no FeatureTestValueAttribute is defined.
The FeatureTestMethodAttribute.Features is an enumerable that contains all feature definitions for the current test invocation with their IsOn=true/false.
Use multiple on values. In the following example the test is run 3 times. 1x with FeatureA off, 1x with FeatureA set to AB.A and 1x with FeatureA set to AB.B .
[FeatureTestMethod(onOff:"FeatureA")][FeatureTestValue("FeatureA",onValues:newobject[]{AB.A,AB.B},offValue:AB.Off)]publicvoidMyTestMethod(){
...var featureService =serviceProvider.GetRequired<IFeatureService>();varfeatureValue=awaitfeatureService.GetValue<AB>("FeatureA");if(featureValue==AB.Off){// FeatureA is Off asserts}elseif(featureValue==AB.A){// FeatureA is A asserts}elseif(featureValue==AB.B){// FeatureA is B asserts}}Vary between multiple features and their values. In the following example the test will be run 4 times: 2^(#onOffFeatures).
[FeatureTestMethod(onOff:"FeatureA,FeatureB")]publicvoidMyTestMethod(){if(awaitfeatureService.IsOn("FeatureA")){if(awaitfeatureService.IsOn("FeatureB")){// FeatureA is On & Feature B is On asserts}else{// FeatureA is On & Feature B is Off asserts}}else{if(awaitfeatureService.IsOn("FeatureB")){// FeatureA is Off & Feature B is On asserts}else{// FeatureA is Off & Feature B is Off asserts}}}Define a feature filter with a single configuration
[FeatureTestMethod(on:"FeatureA")][FeatureTestFilter("FeatureA","ParallelChange",ParallelChange.Expanded)];publicvoidMyTestMethod(){
...Assert.IsTrue(awaitfeatureService.IsOn("FeatureA",ParallelChange.Expanded));}Define a feature filter with multiple configurations. The test will be run for each configuration. In the following case the test will be run 4 times: 1. FeatureA off 2. FeatureA on and ParallelChange.Expanded 3. FeatureA on and ParallelChange.Migrated 4. FeatureA on and ParallelChange.Contracted
[FeatureTestMethod(onOff:"FeatureA")][FeatureTestFilter("FeatureA","ParallelChange",newobject[]{ParallelChange.Expanded,ParallelChange.Migrated,ParallelChange.Contracted})]publicvoidMyTestMethod(){
...if(!awaitfeatureService.IsOn("FeatureA",ParallelChange.Contracted)){// Assert Old_DataWrite();}if(awaitfeatureService.IsOn("FeatureA",ParallelChange.Expanded)){// Assert New_DataWrite();}if(awaitfeatureService.IsOn("FeatureA",ParallelChange.Migrated)){// Assert New_UI();}else{// Assert Old_UI();}}