Skip to content

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 targeting
public 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.


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.


Wrong:

// Flag name repeated as strings throughout the codebase
await 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 casing

Right:

// Define the flag as a type
public sealed class NewCheckout : IFeatureFlag
{
public static string Name => "new-checkout";
public static string? Description => "New checkout flow with Stripe integration";
}
// Usage: compile-time safety
await 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.


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 rules
Rules =
[
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 --> enabled

Why: 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:

  1. Specific deny rules (block specific tenants/users)
  2. Specific allow rules (enable for specific tenants/users/plans)
  3. 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 context

Wrong 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 seed

Right 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 ones

Why: 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.


Wrong:

// Relying on unknown flags returning false without validation
var isEnabled = await flags.IsEnabledAsync("new-chekout", context); // typo!
// Returns false silently -- feature appears permanently disabled

Right:

// Use strongly-typed flags for compile-time safety
public 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 startup
var 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 service
var store = (InMemoryFeatureFlagStore)serviceProvider.GetRequiredService<IFeatureFlagStore>();
store.Define(new FeatureFlagDefinition { ... });
// This cast fails when you swap to a database store

Right:

// Define flags through the store abstraction or a seeding mechanism
public 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 change
memoryStore.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 it

Right understanding:

// Change notification fires only when global Enabled changes
memoryStore.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=false

Why: 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.


Wrong:

// Environment rules always match or never match
Rules =
[
new FeatureFlagRule { Type = "environment", Values = ["production"], Enabled = false },
]
// But context never includes Environment
var context = new FeatureFlagContext { TenantId = "acme" };
// Environment rule is skipped (context.Environment is null) -- feature may unexpectedly be enabled

Right:

// Include Environment in context
var context = new FeatureFlagContext
{
TenantId = tenant.TenantId,
UserId = user.UserId,
Environment = hostEnvironment.EnvironmentName // "Development", "Staging", "Production"
};
// Or centralize via IFeatureFlagContextProvider
public 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.