Pragmatic.FeatureFlags
Context-aware feature flag evaluation with targeting rules, percentage rollout, and deterministic bucketing.
The Problem
Section titled “The Problem”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 codebasevar 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
% 100check 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.
IConfigurationis designed for static key-value pairs, not context-dependent evaluation. You end up parsing JSON arrays insideappsettings.jsonand writing evaluation logic in business code.
The Solution
Section titled “The Solution”With Pragmatic.FeatureFlags, you define flags with targeting rules. The evaluation engine handles the rest.
// Define once with targeting rulesstore.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, auditablevar 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.
Overview
Section titled “Overview”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.
Installation
Section titled “Installation”dotnet add package Pragmatic.FeatureFlagsQuick Start
Section titled “Quick Start”1. Register Services
Section titled “1. Register Services”services.AddPragmaticFeatureFlags();// Registers InMemoryFeatureFlagStore as default2. Define Flags
Section titled “2. Define Flags”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 } ]});3. Evaluate Flags
Section titled “3. Evaluate Flags”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); }}4. Context Provider (Optional)
Section titled “4. Context Provider (Optional)”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 }); }}5. Use in Domain Actions
Section titled “5. Use in Domain Actions”[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); }}Evaluation Engine
Section titled “Evaluation Engine”Rule Types
Section titled “Rule Types”Rules are evaluated in order — first match wins. If no rule matches, the flag’s global Enabled state is used.
| Rule Type | Values | Matches 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"] |
Evaluation Flow
Section titled “Evaluation Flow”Evaluate("new-checkout", context) | vFor each rule (in order): | +-- Rule matches context? --yes--> return rule.Enabled | +-- Rule doesn't apply ---------> next rule | vNo rule matched --> return definition.Enabled (global state)Percentage Rollout
Section titled “Percentage Rollout”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
tenantIdwhenuserIdis null, then to"anonymous"
Rule Composition Examples
Section titled “Rule Composition Examples”// Beta: enterprise tenants + 10% of all other usersRules =[ new FeatureFlagRule { Type = "plan", Values = ["enterprise"], Enabled = true }, new FeatureFlagRule { Type = "percentage", Values = ["10"], Enabled = true }]
// Disabled for a specific tenant, enabled for everyone elseRules =[ new FeatureFlagRule { Type = "tenant", Values = ["legacy-corp"], Enabled = false },]// Global Enabled = true --> everyone except "legacy-corp" gets the feature
// Environment gate + user allowlistRules =[ 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 rulesRules =[ new FeatureFlagRule { Type = "property", Values = ["region", "eu-west", "eu-east"], Enabled = true },]// Enabled for EU users, disabled for othersStrongly-Typed Flags
Section titled “Strongly-Typed Flags”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 safetyvar 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.
API Reference
Section titled “API Reference”FeatureFlagContext
Section titled “FeatureFlagContext”| Property | Type | Description |
|---|---|---|
TenantId | string? | Current tenant identifier |
UserId | string? | Current user identifier |
Plan | string? | Subscription plan (enterprise, pro, free) |
Environment | string? | Runtime environment (staging, production) |
Properties | IReadOnlyDictionary<string, string> | Custom key-value properties for property rules |
Use FeatureFlagContext.Empty when no context is available.
FeatureFlagDefinition
Section titled “FeatureFlagDefinition”| Property | Type | Description |
|---|---|---|
Name | string | Flag identifier (case-insensitive lookup) |
Enabled | bool | Global state — used when no rule matches |
Description | string? | Human-readable description |
Rules | IReadOnlyList<FeatureFlagRule> | Ordered targeting rules |
FeatureFlagRule
Section titled “FeatureFlagRule”| Property | Type | Description |
|---|---|---|
Type | string | Rule type: tenant, user, plan, environment, percentage, property |
Values | IReadOnlyList<string> | Target values (interpretation depends on type) |
Enabled | bool | What to return when the rule matches |
IFeatureFlagStore
Section titled “IFeatureFlagStore”| Method | Description |
|---|---|
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>) |
InMemoryFeatureFlagStore
Section titled “InMemoryFeatureFlagStore”Additional methods for programmatic flag management:
| Method | Description |
|---|---|
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.
DI Registration
Section titled “DI Registration”// Default: in-memory storeservices.AddPragmaticFeatureFlags();
// Custom store implementationservices.AddPragmaticFeatureFlags<MyDatabaseFeatureFlagStore>();
// Or register manually before calling AddPragmaticFeatureFlagsservices.AddSingleton<IFeatureFlagStore, MyCustomStore>();services.AddPragmaticFeatureFlags(); // TryAdd won't replace your storeWatch for Changes
Section titled “Watch for Changes”// 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);}Background Service Pattern
Section titled “Background Service Pattern”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. } }}Testing
Section titled “Testing”Unit Tests — InMemoryFeatureFlagStore
Section titled “Unit Tests — InMemoryFeatureFlagStore”[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}Design Decisions
Section titled “Design Decisions”| Decision | Rationale |
|---|---|
| Separate from Configuration | Feature flags need context-based evaluation, not simple key-value lookup |
| First-match-wins rule evaluation | Simple, predictable — order matters, easy to reason about |
| Deterministic percentage rollout | SHA256 hash ensures same user always gets same bucket for same flag |
| Flag name in hash seed | Different flags have independent distributions — no correlation |
Unknown flags return false | Safe default — unrecognized flags are disabled |
| Case-insensitive matching | Prevents subtle bugs from casing differences in tenant/user IDs |
IAsyncEnumerable for WatchAsync | Natural streaming pattern, no callback registration needed |
| No rule = global state | Simple flags (on/off) don’t need rules at all |
IFeatureFlag for type safety | Eliminates magic strings when evaluating flags |
Feature Summary
Section titled “Feature Summary”| Problem | Solution |
|---|---|
| Enable features for specific tenants | Tenant targeting rule |
| Beta features for specific users | User targeting rule |
| Features gated by subscription tier | Plan targeting rule |
| Enable features only in staging | Environment targeting rule |
| Gradual rollout to N% of users | Percentage rule with deterministic SHA256 bucketing |
| Complex targeting by custom attributes | Property rule (key-value matching) |
| Need change notification for cache invalidation | WatchAsync with IAsyncEnumerable<FeatureFlagChange> |
| Different flag backends per environment | IFeatureFlagStore abstraction with DI swap |
| Magic strings for flag names | IFeatureFlag strongly-typed flag definitions |
| Hand-rolled hash functions for rollout | Built-in FeatureFlagEvaluator with SHA256 |
Cross-Module Integration
Section titled “Cross-Module Integration”| With Module | Integration |
|---|---|
| Pragmatic.Configuration | Separate pillars — Configuration for key-value, FeatureFlags for evaluation |
| Pragmatic.MultiTenancy | ITenantContext feeds FeatureFlagContext.TenantId |
| Pragmatic.Identity | ICurrentUser feeds FeatureFlagContext.UserId |
| Pragmatic.Composition | Auto-registered by SG when referenced; override in IStartupStep.ConfigureServices |
| Pragmatic.Actions | Check flags in action Execute to gate features |
| Pragmatic.Endpoints | Check flags in endpoint handlers for A/B routing |
Layer Dependencies
Section titled “Layer Dependencies”Layer 0 (Foundation) Layer 1 (Capabilities)+-- Result +-- Configuration+-- Ensure +-- FeatureFlags <-- this module+-- Abstractions +-- Validation +-- ...Requirements
Section titled “Requirements”- .NET 10.0+
Pragmatic.Abstractions(interfaces)
Further Reading
Section titled “Further Reading”- Getting Started — step-by-step setup
- Concepts — architecture, evaluation engine, and core design
- Stores — IFeatureFlagStore, InMemoryFeatureFlagStore, custom backends
- Common Mistakes — avoid the most frequent pitfalls
- Troubleshooting — problem/solution guide
License
Section titled “License”Part of the Pragmatic.Design ecosystem.