Common Mistakes
These are the most common issues developers encounter when using Pragmatic.FeatureFlags. Each section shows the wrong approach, the correct approach, and explains why.
1. Using Configuration Instead of Feature Flags
Section titled “1. Using Configuration Instead of Feature Flags”Wrong:
// Using IConfiguration for features that need targetingpublic class CheckoutService(IConfiguration config){ public async Task ProcessAsync() { var useNew = config.GetValue<bool>("Features:NewCheckout"); // Same for all users, all tenants, all environments }}Right:
public class CheckoutService(IFeatureFlagStore flags, ITenantContext tenant){ public async Task ProcessAsync() { var context = new FeatureFlagContext { TenantId = tenant.TenantId }; var useNew = await flags.IsEnabledAsync("new-checkout", context); // Can target specific tenants, users, plans, percentages }}Why: IConfiguration (and IConfigurationStore) provides flat key-value lookup — the same value for every request. Feature flags need context-based evaluation: the same flag can be enabled for tenant A and disabled for tenant B. Use Configuration for settings (connection strings, limits). Use FeatureFlags for features that need targeting or gradual rollout.
2. Not Providing Context When Evaluating
Section titled “2. Not Providing Context When Evaluating”Wrong:
public class CheckoutService(IFeatureFlagStore flags){ public async Task ProcessAsync() { // No context -- tenant/user/plan rules can never match var isEnabled = await flags.IsEnabledAsync("new-checkout"); }}Right:
public class CheckoutService(IFeatureFlagStore flags, ITenantContext tenant, ICurrentUser user){ public async Task ProcessAsync() { var context = new FeatureFlagContext { TenantId = tenant.TenantId, UserId = user.UserId, Plan = "enterprise" // from subscription service }; var isEnabled = await flags.IsEnabledAsync("new-checkout", context); }}Why: Without context, only the global Enabled state and percentage rules with "anonymous" seed are evaluated. Tenant, user, plan, environment, and property rules require their respective context values to match. Always provide the richest context available.
3. Using Magic Strings for Flag Names
Section titled “3. Using Magic Strings for Flag Names”Wrong:
// Flag name repeated as strings throughout the codebaseawait flags.IsEnabledAsync("new-checkout", context);// ... in another file:await flags.IsEnabledAsync("new_checkout", context); // typo: underscore instead of hyphen// ... in yet another file:await flags.IsEnabledAsync("newCheckout", context); // typo: different casingRight:
// Define the flag as a typepublic sealed class NewCheckout : IFeatureFlag{ public static string Name => "new-checkout"; public static string? Description => "New checkout flow with Stripe integration";}
// Usage: compile-time safetyawait flags.IsEnabledAsync<NewCheckout>(context);Why: String-based flag names are prone to typos. Since unknown flags return false (safe default), a misspelled flag name silently evaluates to false — the feature appears permanently disabled with no error. IFeatureFlag types provide compile-time safety and IDE navigation. Note: flag name lookup in the store is case-insensitive, but that only helps with casing differences, not typos.
4. Wrong Rule Order (First Match Wins)
Section titled “4. Wrong Rule Order (First Match Wins)”Wrong:
// Goal: enable for enterprise, BUT disable for "legacy-corp" (enterprise tenant)Rules =[ new FeatureFlagRule { Type = "plan", Values = ["enterprise"], Enabled = true }, new FeatureFlagRule { Type = "tenant", Values = ["legacy-corp"], Enabled = false },]// Result: legacy-corp gets Enabled=true because the plan rule matches first!Right:
// Deny rules BEFORE allow rulesRules =[ new FeatureFlagRule { Type = "tenant", Values = ["legacy-corp"], Enabled = false }, new FeatureFlagRule { Type = "plan", Values = ["enterprise"], Enabled = true },]// Result: legacy-corp hits the tenant deny rule first --> disabled// Other enterprise tenants hit the plan rule --> enabledWhy: Rules are evaluated in order and first match wins. If a broad allow rule comes before a specific deny rule, the deny never fires. Always put specific deny rules first, then specific allow rules, then broad percentage rules.
Ordering guideline:
- Specific deny rules (block specific tenants/users)
- Specific allow rules (enable for specific tenants/users/plans)
- Broad percentage rules (gradual rollout)
5. Assuming Automatic Bridging Between ITenantContext and FeatureFlagContext
Section titled “5. Assuming Automatic Bridging Between ITenantContext and FeatureFlagContext”Wrong:
public class CheckoutService(IFeatureFlagStore flags){ public async Task ProcessAsync() { // Assuming TenantId is automatically populated from ITenantContext var context = new FeatureFlagContext(); await flags.IsEnabledAsync("new-checkout", context); // context.TenantId is null -- tenant rules never match }}Right:
public class CheckoutService(IFeatureFlagStore flags, ITenantContext tenant){ public async Task ProcessAsync() { // Explicitly bridge the context var context = new FeatureFlagContext { TenantId = tenant.TenantId }; await flags.IsEnabledAsync("new-checkout", context); }}Or with a context provider:
public class HttpFeatureFlagContextProvider( ITenantContext tenant, ICurrentUser user, IHostEnvironment env) : IFeatureFlagContextProvider{ public Task<FeatureFlagContext> GetContextAsync(CancellationToken ct = default) => Task.FromResult(new FeatureFlagContext { TenantId = tenant.TenantId, UserId = user.UserId, Environment = env.EnvironmentName });}Why: FeatureFlagContext and ITenantContext are independent systems. There is no automatic wiring. You must explicitly copy ITenantContext.TenantId into FeatureFlagContext.TenantId. The IFeatureFlagContextProvider pattern centralizes this bridging so you don’t repeat it in every flag check.
6. Misunderstanding Percentage Rollout Determinism
Section titled “6. Misunderstanding Percentage Rollout Determinism”Wrong understanding:
// "I set 20% rollout but sometimes the same user gets different results"var result1 = await flags.IsEnabledAsync("feature-a", new FeatureFlagContext { UserId = "user-1" });var result2 = await flags.IsEnabledAsync("feature-a", new FeatureFlagContext { UserId = "user-1" });// result1 == result2 (always) -- if they differ, something is wrong with contextWrong assumption:
// "20% rollout on flag-a means the same 20% on flag-b"// WRONG -- each flag has an independent distribution because the flag name is part of the hash seedRight understanding:
hash = SHA256("{flagName}:{userId}")bucket = |hash| % 100
// Same user + same flag = SAME bucket (deterministic)// Same user + different flag = DIFFERENT bucket (independent)// Going from 20% to 30% = ADDS users, never removes existing onesWhy: The hash seed includes the flag name. This means a user in the 20% bucket for flag-a might not be in the 20% bucket for flag-b. This is intentional — it prevents correlation between rollouts. It also means increasing the percentage is additive: users in the 10% cohort are always in the 20% cohort.
7. Not Handling Unknown Flags Explicitly
Section titled “7. Not Handling Unknown Flags Explicitly”Wrong:
// Relying on unknown flags returning false without validationvar isEnabled = await flags.IsEnabledAsync("new-chekout", context); // typo!// Returns false silently -- feature appears permanently disabledRight:
// Use strongly-typed flags for compile-time safetypublic sealed class NewCheckout : IFeatureFlag{ public static string Name => "new-checkout"; public static string? Description => "New checkout flow";}
var isEnabled = await flags.IsEnabledAsync<NewCheckout>(context);// Typos are caught at compile time
// Or if using strings, verify the flag exists during startupvar definition = await flags.GetDefinitionAsync("new-checkout");if (definition is null) logger.LogWarning("Feature flag 'new-checkout' not defined");Why: Unknown flags return false by design (safe default). This means a typo in a flag name does not throw — it silently disables the feature. Use IFeatureFlag types for compile-time safety, or validate flag existence during application startup.
8. Casting IFeatureFlagStore to InMemoryFeatureFlagStore in Production
Section titled “8. Casting IFeatureFlagStore to InMemoryFeatureFlagStore in Production”Wrong:
// In a startup step or servicevar store = (InMemoryFeatureFlagStore)serviceProvider.GetRequiredService<IFeatureFlagStore>();store.Define(new FeatureFlagDefinition { ... });// This cast fails when you swap to a database storeRight:
// Define flags through the store abstraction or a seeding mechanismpublic class FeatureFlagSeeder(IFeatureFlagStore store){ public async Task SeedAsync() { // Check if the store supports programmatic definition if (store is InMemoryFeatureFlagStore memoryStore) { memoryStore.Define(new FeatureFlagDefinition { ... }); } // Database stores have their own seeding mechanism }}Why: InMemoryFeatureFlagStore is the default for development and testing. In production, you will likely use a database store, Azure App Configuration, or LaunchDarkly. Casting to the concrete type breaks when you swap implementations. Use the IFeatureFlagStore interface for evaluation. If you need programmatic flag definition, make it conditional on the store type or use a separate seeding mechanism.
9. Emitting Change Notifications for Rule Changes
Section titled “9. Emitting Change Notifications for Rule Changes”Wrong assumption:
// Expecting WatchAsync to fire when rules changememoryStore.Define(new FeatureFlagDefinition{ Name = "feature-a", Enabled = true, // same as before Rules = [new FeatureFlagRule { Type = "tenant", Values = ["new-tenant"], Enabled = true }]});// WatchAsync does NOT emit a change -- only global Enabled state changes trigger itRight understanding:
// Change notification fires only when global Enabled changesmemoryStore.Define(new FeatureFlagDefinition { Name = "feature-a", Enabled = true });// No notification (same Enabled state)
memoryStore.Define(new FeatureFlagDefinition { Name = "feature-a", Enabled = false });// Notification! WasEnabled=true, IsEnabled=falseWhy: In the InMemoryFeatureFlagStore, WatchAsync only emits a FeatureFlagChange when the global Enabled property changes. Rule modifications without a change to the global state do not trigger a notification. This is a design choice of the in-memory implementation. Custom stores may implement different notification semantics.
10. Not Setting Up Environment in Context
Section titled “10. Not Setting Up Environment in Context”Wrong:
// Environment rules always match or never matchRules =[ new FeatureFlagRule { Type = "environment", Values = ["production"], Enabled = false },]
// But context never includes Environmentvar context = new FeatureFlagContext { TenantId = "acme" };// Environment rule is skipped (context.Environment is null) -- feature may unexpectedly be enabledRight:
// Include Environment in contextvar context = new FeatureFlagContext{ TenantId = tenant.TenantId, UserId = user.UserId, Environment = hostEnvironment.EnvironmentName // "Development", "Staging", "Production"};
// Or centralize via IFeatureFlagContextProviderpublic class HttpFeatureFlagContextProvider( ITenantContext tenant, IHostEnvironment env) : IFeatureFlagContextProvider{ public Task<FeatureFlagContext> GetContextAsync(CancellationToken ct = default) => Task.FromResult(new FeatureFlagContext { TenantId = tenant.TenantId, Environment = env.EnvironmentName });}Why: When a context property is null, rules targeting that property are skipped (they do not match). This means an environment rule with Values = ["production"] does nothing when context.Environment is null — the evaluator moves to the next rule or falls back to the global state. Always populate all relevant context dimensions, especially Environment if you use environment-gated rules.