Skip to content

Pragmatic.FeatureFlags

Context-aware feature flag evaluation with targeting rules, percentage rollout, and deterministic bucketing.

Applications need runtime control over feature visibility — for gradual rollouts, A/B testing, beta programs, and kill switches. Configuration (IConfiguration, IConfigurationStore) gives you simple on/off, but the same value applies to every user, every tenant, every environment.

// Without Pragmatic: hand-rolled targeting scattered across the codebase
var betaTenants = config.GetSection("Features:NewCheckout:BetaTenants").Get<string[]>();
var rolloutPercent = config.GetValue<int>("Features:NewCheckout:RolloutPercent");
var isEnabled = betaTenants?.Contains(tenantId) == true
|| ComputeHash(userId) % 100 < rolloutPercent;

The problems compound:

  • Targeting logic is duplicated. Every feature that needs per-tenant or per-user evaluation reimplements the same hash-and-check pattern.
  • No consistency guarantee. A hand-rolled % 100 check produces different results depending on the hash function, seed, and rounding. The same user can see a feature enabled on one request and disabled on the next.
  • No change detection. When someone toggles a flag in the database, caches serve stale values until the next restart. There is no push notification or watch mechanism.
  • Configuration and evaluation are conflated. IConfiguration is designed for static key-value pairs, not context-dependent evaluation. You end up parsing JSON arrays inside appsettings.json and writing evaluation logic in business code.

With Pragmatic.FeatureFlags, you define flags with targeting rules. The evaluation engine handles the rest.

// Define once with targeting rules
store.Define(new FeatureFlagDefinition
{
Name = "new-checkout",
Enabled = false,
Rules =
[
new FeatureFlagRule { Type = "tenant", Values = ["acme", "contoso"], Enabled = true },
new FeatureFlagRule { Type = "percentage", Values = ["20"], Enabled = true }
]
});
// Evaluate with context -- deterministic, consistent, auditable
var context = new FeatureFlagContext { TenantId = "acme", UserId = "user-42" };
var isEnabled = await store.IsEnabledAsync("new-checkout", context);

Rules are evaluated in order — first match wins. Percentage rollouts use deterministic SHA256 bucketing (same user = same result). Unknown flags return false (safe default). The store is pluggable — in-memory for dev, database/LaunchDarkly/Azure App Configuration for production.


Pragmatic.FeatureFlags provides a rules-based feature flag engine separate from key-value configuration. Unlike IConfigurationStore (simple string values), feature flags require context-based evaluation: the same flag can be enabled for one tenant and disabled for another, or gradually rolled out to a percentage of users.

The module ships with an in-memory store for development and testing. Production backends (database, Azure App Configuration, LaunchDarkly, etc.) implement the same IFeatureFlagStore interface.

Terminal window
dotnet add package Pragmatic.FeatureFlags

services.AddPragmaticFeatureFlags();
// Registers InMemoryFeatureFlagStore as default
var store = serviceProvider.GetRequiredService<IFeatureFlagStore>();
// For InMemoryFeatureFlagStore, cast to define flags programmatically:
var memoryStore = (InMemoryFeatureFlagStore)store;
memoryStore.Define(new FeatureFlagDefinition
{
Name = "new-checkout",
Enabled = false, // globally disabled
Description = "New checkout flow with Stripe integration",
Rules =
[
// Enable for enterprise tenants
new FeatureFlagRule { Type = "tenant", Values = ["acme", "contoso"], Enabled = true },
// Enable for 20% of users (gradual rollout)
new FeatureFlagRule { Type = "percentage", Values = ["20"], Enabled = true }
]
});
public class CheckoutService(IFeatureFlagStore flags)
{
public async Task<bool> UseNewCheckout(string tenantId, string userId)
{
var context = new FeatureFlagContext
{
TenantId = tenantId,
UserId = userId
};
return await flags.IsEnabledAsync("new-checkout", context);
}
}

For automatic context resolution from ambient state (HTTP request, tenant, user):

public class HttpFeatureFlagContextProvider(
ITenantContext tenantContext,
ICurrentUser currentUser,
IHostEnvironment hostEnv) : IFeatureFlagContextProvider
{
public Task<FeatureFlagContext> GetContextAsync(CancellationToken ct = default)
{
return Task.FromResult(new FeatureFlagContext
{
TenantId = tenantContext.TenantId,
UserId = currentUser.Id,
Plan = null, // from tenant metadata if available
Environment = hostEnv.EnvironmentName
});
}
}
[DomainAction]
public partial class ProcessPayment : DomainAction<PaymentResult>
{
private IFeatureFlagStore _flags = null!;
public override async Task<Result<PaymentResult, IError>> Execute(CancellationToken ct)
{
var context = new FeatureFlagContext { UserId = "current-user" };
if (await _flags.IsEnabledAsync("stripe-v2", context))
{
// New Stripe v2 integration
return await ProcessWithStripeV2(ct);
}
// Legacy payment processing
return await ProcessWithLegacy(ct);
}
}

Rules are evaluated in order — first match wins. If no rule matches, the flag’s global Enabled state is used.

Rule TypeValuesMatches When
tenant["acme", "contoso"]context.TenantId is in the list (case-insensitive)
user["user-1", "user-2"]context.UserId is in the list (case-insensitive)
plan["enterprise", "pro"]context.Plan is in the list (case-insensitive)
environment["staging", "development"]context.Environment is in the list (case-insensitive)
percentage["20"]Deterministic hash of {flagName}:{userId} falls in the bucket
property["region", "eu-west", "eu-east"]context.Properties["region"] is in ["eu-west", "eu-east"]
Evaluate("new-checkout", context)
|
v
For each rule (in order):
|
+-- Rule matches context? --yes--> return rule.Enabled
|
+-- Rule doesn't apply ---------> next rule
|
v
No rule matched --> return definition.Enabled (global state)

Percentage rules use deterministic bucketing via SHA256:

hash = SHA256("{flagName}:{userId|tenantId|anonymous}")
bucket = |hash| % 100
if bucket < percentage --> enabled
  • Same user + same flag = same result (deterministic)
  • Different flags = different distribution (flag name in seed)
  • 0% = always disabled, 100% = always enabled
  • Falls back to tenantId when userId is null, then to "anonymous"
// Beta: enterprise tenants + 10% of all other users
Rules =
[
new FeatureFlagRule { Type = "plan", Values = ["enterprise"], Enabled = true },
new FeatureFlagRule { Type = "percentage", Values = ["10"], Enabled = true }
]
// Disabled for a specific tenant, enabled for everyone else
Rules =
[
new FeatureFlagRule { Type = "tenant", Values = ["legacy-corp"], Enabled = false },
]
// Global Enabled = true --> everyone except "legacy-corp" gets the feature
// Environment gate + user allowlist
Rules =
[
new FeatureFlagRule { Type = "environment", Values = ["production"], Enabled = false },
new FeatureFlagRule { Type = "user", Values = ["admin-1"], Enabled = true },
]
// In production: disabled. Outside production: only admin-1 gets it.
// Regional rollout via property rules
Rules =
[
new FeatureFlagRule { Type = "property", Values = ["region", "eu-west", "eu-east"], Enabled = true },
]
// Enabled for EU users, disabled for others

Define flags as types implementing IFeatureFlag for compile-time safety:

public sealed class NewCheckoutFlag : IFeatureFlag
{
public static string Name => "new-checkout";
public static string Description => "New checkout flow with Stripe integration";
}
// Evaluate with type safety
var isEnabled = await store.IsEnabledAsync<NewCheckoutFlag>(context);
var definition = await store.GetDefinitionAsync<NewCheckoutFlag>();

The FeatureFlagStoreExtensions class provides generic overloads that extract Name from the IFeatureFlag type, eliminating magic strings.


PropertyTypeDescription
TenantIdstring?Current tenant identifier
UserIdstring?Current user identifier
Planstring?Subscription plan (enterprise, pro, free)
Environmentstring?Runtime environment (staging, production)
PropertiesIReadOnlyDictionary<string, string>Custom key-value properties for property rules

Use FeatureFlagContext.Empty when no context is available.

PropertyTypeDescription
NamestringFlag identifier (case-insensitive lookup)
EnabledboolGlobal state — used when no rule matches
Descriptionstring?Human-readable description
RulesIReadOnlyList<FeatureFlagRule>Ordered targeting rules
PropertyTypeDescription
TypestringRule type: tenant, user, plan, environment, percentage, property
ValuesIReadOnlyList<string>Target values (interpretation depends on type)
EnabledboolWhat to return when the rule matches
MethodDescription
IsEnabledAsync(flagName)Evaluate without context (uses defaults)
IsEnabledAsync(flagName, context)Evaluate with targeting context
GetDefinitionAsync(flagName)Get full definition including rules
GetAllAsync()List all defined flags (ordered by name)
WatchAsync()Stream of flag changes (IAsyncEnumerable<FeatureFlagChange>)

Additional methods for programmatic flag management:

MethodDescription
Define(definition)Create or update a flag
Remove(flagName)Remove a flag definition

Change notifications are emitted via WatchAsync when Define() changes a flag’s Enabled state.


// Default: in-memory store
services.AddPragmaticFeatureFlags();
// Custom store implementation
services.AddPragmaticFeatureFlags<MyDatabaseFeatureFlagStore>();
// Or register manually before calling AddPragmaticFeatureFlags
services.AddSingleton<IFeatureFlagStore, MyCustomStore>();
services.AddPragmaticFeatureFlags(); // TryAdd won't replace your store

// React to flag changes (e.g., invalidate caches, update UI)
await foreach (var change in store.WatchAsync(cancellationToken))
{
logger.LogInformation(
"Flag '{Flag}' changed: {Was} -> {Is} at {Time}",
change.FlagName,
change.WasEnabled,
change.IsEnabled,
change.Timestamp);
}
public class FeatureFlagWatcher(
IFeatureFlagStore store,
ILogger<FeatureFlagWatcher> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var change in store.WatchAsync(ct))
{
logger.LogInformation("Flag '{Flag}' toggled to {State}",
change.FlagName, change.IsEnabled);
// React: invalidate caches, notify clients, etc.
}
}
}

[Fact]
public async Task NewCheckout_EnabledForEnterpriseTenant()
{
var store = new InMemoryFeatureFlagStore();
store.Define(new FeatureFlagDefinition
{
Name = "new-checkout",
Enabled = false,
Rules =
[
new FeatureFlagRule { Type = "tenant", Values = ["acme"], Enabled = true }
]
});
var context = new FeatureFlagContext { TenantId = "acme" };
var result = await store.IsEnabledAsync("new-checkout", context);
result.Should().BeTrue();
}
[Fact]
public async Task NewCheckout_DisabledForUnknownTenant()
{
var store = new InMemoryFeatureFlagStore();
store.Define(new FeatureFlagDefinition
{
Name = "new-checkout",
Enabled = false,
Rules =
[
new FeatureFlagRule { Type = "tenant", Values = ["acme"], Enabled = true }
]
});
var context = new FeatureFlagContext { TenantId = "other-corp" };
var result = await store.IsEnabledAsync("new-checkout", context);
result.Should().BeFalse(); // No rule matches, falls back to Enabled=false
}
[Fact]
public async Task UnknownFlag_ReturnsFalse()
{
var store = new InMemoryFeatureFlagStore();
var result = await store.IsEnabledAsync("nonexistent-flag");
result.Should().BeFalse(); // Safe default
}

DecisionRationale
Separate from ConfigurationFeature flags need context-based evaluation, not simple key-value lookup
First-match-wins rule evaluationSimple, predictable — order matters, easy to reason about
Deterministic percentage rolloutSHA256 hash ensures same user always gets same bucket for same flag
Flag name in hash seedDifferent flags have independent distributions — no correlation
Unknown flags return falseSafe default — unrecognized flags are disabled
Case-insensitive matchingPrevents subtle bugs from casing differences in tenant/user IDs
IAsyncEnumerable for WatchAsyncNatural streaming pattern, no callback registration needed
No rule = global stateSimple flags (on/off) don’t need rules at all
IFeatureFlag for type safetyEliminates magic strings when evaluating flags

ProblemSolution
Enable features for specific tenantsTenant targeting rule
Beta features for specific usersUser targeting rule
Features gated by subscription tierPlan targeting rule
Enable features only in stagingEnvironment targeting rule
Gradual rollout to N% of usersPercentage rule with deterministic SHA256 bucketing
Complex targeting by custom attributesProperty rule (key-value matching)
Need change notification for cache invalidationWatchAsync with IAsyncEnumerable<FeatureFlagChange>
Different flag backends per environmentIFeatureFlagStore abstraction with DI swap
Magic strings for flag namesIFeatureFlag strongly-typed flag definitions
Hand-rolled hash functions for rolloutBuilt-in FeatureFlagEvaluator with SHA256

With ModuleIntegration
Pragmatic.ConfigurationSeparate pillars — Configuration for key-value, FeatureFlags for evaluation
Pragmatic.MultiTenancyITenantContext feeds FeatureFlagContext.TenantId
Pragmatic.IdentityICurrentUser feeds FeatureFlagContext.UserId
Pragmatic.CompositionAuto-registered by SG when referenced; override in IStartupStep.ConfigureServices
Pragmatic.ActionsCheck flags in action Execute to gate features
Pragmatic.EndpointsCheck flags in endpoint handlers for A/B routing

Layer 0 (Foundation) Layer 1 (Capabilities)
+-- Result +-- Configuration
+-- Ensure +-- FeatureFlags <-- this module
+-- Abstractions +-- Validation
+-- ...
  • .NET 10.0+
  • Pragmatic.Abstractions (interfaces)

Part of the Pragmatic.Design ecosystem.