Skip to content

Architecture and Core Concepts

This guide explains why Pragmatic.FeatureFlags exists, how the evaluation engine works, and how it integrates with the rest of the Pragmatic.Design ecosystem. Read this before diving into the individual feature guides.


Applications need to control feature visibility at runtime — for gradual rollouts, A/B testing, beta programs, and kill switches. Without a feature flag system, you end up with one of these patterns.

Compile-time flags that require redeployment

Section titled “Compile-time flags that require redeployment”
public class CheckoutService
{
// Changing this requires a code change + redeployment
private const bool UseNewCheckout = false;
public async Task ProcessAsync()
{
if (UseNewCheckout)
await NewCheckoutFlowAsync();
else
await LegacyCheckoutFlowAsync();
}
}

To toggle the feature, you redeploy the application. No gradual rollout. No per-tenant control. No kill switch without downtime.

Configuration-based flags that lack targeting

Section titled “Configuration-based flags that lack targeting”
public class CheckoutService(IConfiguration config)
{
public async Task ProcessAsync()
{
// Simple on/off -- same for all users
var useNew = config.GetValue<bool>("Features:NewCheckout");
if (useNew)
await NewCheckoutFlowAsync();
else
await LegacyCheckoutFlowAsync();
}
}

Configuration gives you runtime toggling, but it is a flat key-value store. The same flag value applies to every user, every tenant, every environment. You cannot enable a feature for enterprise tenants only, or roll it out to 10% of users, or gate it to staging before production.

Hand-rolled targeting with scattered logic

Section titled “Hand-rolled targeting with scattered logic”
public class CheckoutService(IConfiguration config, ITenantContext tenant, ICurrentUser user)
{
public async Task ProcessAsync()
{
var betaTenants = config.GetSection("Features:NewCheckout:BetaTenants").Get<string[]>();
var betaUsers = config.GetSection("Features:NewCheckout:BetaUsers").Get<string[]>();
var rolloutPercent = config.GetValue<int>("Features:NewCheckout:RolloutPercent");
var isEnabled = betaTenants?.Contains(tenant.TenantId) == true
|| betaUsers?.Contains(user.UserId) == true
|| ComputeHash(user.UserId) % 100 < rolloutPercent;
if (isEnabled)
await NewCheckoutFlowAsync();
else
await LegacyCheckoutFlowAsync();
}
}

Now you have targeting, but the evaluation logic is duplicated across every feature check. Each developer implements hashing differently. Percentage rollouts are inconsistent. There is no central view of what is enabled for whom.

  1. Compile-time flags require redeployment to toggle.
  2. Configuration flags are flat on/off — no targeting, no gradual rollout.
  3. Hand-rolled targeting is scattered, inconsistent, and hard to audit.
  4. No change notification — when a flag changes, downstream caches and UI need to react.

Pragmatic.FeatureFlags provides a rules-based evaluation engine that separates flag definition from evaluation context. Flags have ordered targeting rules. Evaluation takes a context (tenant, user, plan, environment, custom properties) and returns a deterministic result.

// 1. Define the flag with targeting rules
memoryStore.Define(new FeatureFlagDefinition
{
Name = "new-checkout",
Enabled = false, // globally disabled
Rules =
[
// Enable for enterprise tenants
new FeatureFlagRule { Type = "tenant", Values = ["acme", "contoso"], Enabled = true },
// Enable for 20% of all users (gradual rollout)
new FeatureFlagRule { Type = "percentage", Values = ["20"], Enabled = true }
]
});
// 2. Evaluate with context
var context = new FeatureFlagContext { TenantId = "acme", UserId = "user-42" };
var isEnabled = await store.IsEnabledAsync("new-checkout", context);
// Result: true (matched tenant rule for "acme")

Rules are evaluated in order — first match wins. If no rule matches, the global Enabled state is the fallback. The evaluation engine handles deterministic percentage rollouts, case-insensitive matching, and custom property rules.


Every feature flag check flows through the same deterministic pipeline:

IsEnabledAsync("new-checkout", context)
|
v
Lookup flag definition in store
|
+-- Not found --> return false (safe default)
|
v
Evaluate rules in order (first match wins)
|
+-- Rule 1: type="tenant", values=["acme","contoso"]
| context.TenantId = "acme" --> MATCH --> return rule.Enabled (true)
|
+-- Rule 2: type="percentage", values=["20"]
| (skipped -- Rule 1 already matched)
|
v
No rule matched --> return definition.Enabled (global state)

Key behaviors:

  • Unknown flags return false — safe default, no exception.
  • First match wins — rule order matters. Put specific rules before broad rules.
  • Null context values skip the rule — a tenant rule does not match when context.TenantId is null. The evaluator moves to the next rule.
  • Case-insensitive matching"ACME" matches "acme" in all list rules.
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 SHA256 hash of {flagName}:{userId} falls in the bucket
property["region", "eu-west", "eu-east"]context.Properties["region"] is in ["eu-west", "eu-east"]

Unknown rule types are silently skipped — they do not match and do not error.

Percentage Rollout (Deterministic Bucketing)

Section titled “Percentage Rollout (Deterministic Bucketing)”

Percentage rules use SHA256 to hash the flag name and user/tenant identifier into a stable bucket:

hash = SHA256("{flagName}:{userId or tenantId or 'anonymous'}")
bucket = |hash| % 100
if bucket < percentage --> rule matches

Properties of deterministic bucketing:

PropertyBehavior
DeterministicSame user + same flag = same result every time
IndependentDifferent flags have different distributions (flag name is part of the seed)
ProgressiveGoing from 10% to 20% adds users — never removes existing ones
FallbackIf neither UserId nor TenantId is set, "anonymous" is used as the seed
Boundaries0% = always disabled, 100% = always enabled

This means you can safely increase the rollout percentage without “churning” users. A user in the 10% bucket is always in the 20% bucket too.

For targeting beyond the built-in fields, use property rules with custom key-value pairs:

// Define: enable for EU regions
new FeatureFlagRule
{
Type = "property",
Values = ["region", "eu-west", "eu-east"], // [0]=key, [1..]=values
Enabled = true
}
// Evaluate with context
var context = new FeatureFlagContext
{
Properties = new Dictionary<string, string>
{
["region"] = "eu-west"
}
};
// Result: matches (eu-west is in allowed values)

The first element in Values is the property key; the remaining elements are the allowed values. The match is case-insensitive. If the property key is not in the context, the rule does not match (moves to the next rule).


Instead of using magic strings, define flags as types for compile-time safety:

public sealed class LoyaltyDiscount : IFeatureFlag
{
public static string Name => "loyalty-discount";
public static string? Description => "10% loyalty discount -- gradual rollout";
}
// Usage: no magic string
var isEnabled = await store.IsEnabledAsync<LoyaltyDiscount>(context, ct);
// Compare to string-based:
var isEnabled = await store.IsEnabledAsync("loyalty-discount", context, ct);

The IFeatureFlag interface uses static abstract members:

public interface IFeatureFlag
{
static abstract string Name { get; }
static abstract string? Description { get; }
}

The FeatureFlagStoreExtensions class provides generic overloads on IFeatureFlagStore:

Extension MethodDescription
IsEnabledAsync<TFlag>(ct)Evaluate typed flag without context
IsEnabledAsync<TFlag>(context, ct)Evaluate typed flag with targeting context
GetDefinitionAsync<TFlag>(ct)Get full definition of typed flag

When to use typed flags: For flags that are checked in multiple places, strongly-typed flags prevent typos and enable IDE navigation. For one-off checks or dynamic flag names (e.g., from configuration), string-based API is fine.


All feature flag operations go through IFeatureFlagStore:

public interface IFeatureFlagStore
{
Task<bool> IsEnabledAsync(string flagName, CancellationToken ct = default);
Task<bool> IsEnabledAsync(string flagName, FeatureFlagContext context, CancellationToken ct = default);
Task<FeatureFlagDefinition?> GetDefinitionAsync(string flagName, CancellationToken ct = default);
Task<IReadOnlyList<FeatureFlagDefinition>> GetAllAsync(CancellationToken ct = default);
IAsyncEnumerable<FeatureFlagChange> WatchAsync(CancellationToken ct = default);
}

The store is responsible for both storage and evaluation. This design allows backends (database, LaunchDarkly, Azure App Configuration) to implement evaluation natively if they support it, rather than forcing everything through the in-memory evaluator.

The default store for development and testing:

  • Uses ConcurrentDictionary<string, FeatureFlagDefinition> with case-insensitive keys.
  • Delegates evaluation to FeatureFlagEvaluator (internal).
  • Emits FeatureFlagChange via Channel<T> when Define() changes a flag’s global Enabled state.
  • Additional methods: Define(FeatureFlagDefinition) and Remove(string).

Implement IFeatureFlagStore for production backends:

public class DatabaseFeatureFlagStore(IDbConnectionFactory db) : IFeatureFlagStore
{
public async Task<bool> IsEnabledAsync(
string flagName, FeatureFlagContext context, CancellationToken ct = default)
{
var definition = await GetDefinitionAsync(flagName, ct);
if (definition is null)
return false; // Unknown flag = disabled
// Reuse the evaluation logic or implement custom
return FeatureFlagEvaluator.Evaluate(definition, context);
}
// ... other methods
}

Note: FeatureFlagEvaluator is internal to the Pragmatic.FeatureFlags package. Custom stores must either replicate the evaluation logic or load definitions and implement their own rule processing.

See Stores for detailed store documentation.


The context carries the targeting dimensions for evaluation:

public sealed record FeatureFlagContext
{
public string? TenantId { get; init; }
public string? UserId { get; init; }
public string? Plan { get; init; }
public string? Environment { get; init; }
public IReadOnlyDictionary<string, string> Properties { get; init; }
}
PropertyTypeDescription
TenantIdstring?Current tenant identifier
UserIdstring?Current user identifier
Planstring?Subscription plan (e.g., "enterprise", "pro", "free")
Environmentstring?Runtime environment (e.g., "staging", "production")
PropertiesIReadOnlyDictionary<string, string>Custom key-value properties for property rules

Use FeatureFlagContext.Empty when no context is available. This is equivalent to new FeatureFlagContext() and means only the global Enabled state and percentage rules (with "anonymous" seed) are evaluated.


IFeatureFlagContextProvider: Automatic Context Resolution

Section titled “IFeatureFlagContextProvider: Automatic Context Resolution”

For applications where the context comes from ambient state (HTTP request, tenant, user), implement IFeatureFlagContextProvider:

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.UserId,
Plan = tenantContext.Plan,
Environment = hostEnv.EnvironmentName
});
}
}

Important: The bridging between ITenantContext and FeatureFlagContext.TenantId is manual. You must explicitly copy the values. There is no automatic wiring between the multi-tenancy and feature flags systems.


IFeatureFlagStore.WatchAsync returns an IAsyncEnumerable<FeatureFlagChange> for real-time change notifications:

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);
// Invalidate caches, update UI, trigger recomputation, etc.
}

The FeatureFlagChange record:

public sealed record FeatureFlagChange(
string FlagName,
bool WasEnabled,
bool IsEnabled,
DateTimeOffset Timestamp);

In InMemoryFeatureFlagStore: A change is emitted only when Define() changes a flag’s global Enabled state. Changes to rules without changing the global state do not emit a notification. The notification uses Channel<FeatureFlagChange> internally, providing unbounded buffering.

In custom stores: The implementation determines when and how changes are detected (polling, database notifications, webhooks, etc.).


Beta program: specific tenants + percentage rollout

Section titled “Beta program: specific tenants + percentage rollout”
Rules =
[
new FeatureFlagRule { Type = "plan", Values = ["enterprise"], Enabled = true },
new FeatureFlagRule { Type = "percentage", Values = ["10"], Enabled = true }
]
// Enterprise tenants: always enabled (rule 1 matches first)
// Everyone else: 10% rollout (rule 2)
Rules =
[
new FeatureFlagRule { Type = "tenant", Values = ["legacy-corp"], Enabled = false },
]
// Global Enabled = true
// Everyone gets the feature except "legacy-corp"
Rules =
[
new FeatureFlagRule { Type = "environment", Values = ["production"], Enabled = false },
new FeatureFlagRule { Type = "user", Values = ["admin-1"], Enabled = true },
]
// In production: disabled for everyone (rule 1 matches)
// Outside production: only admin-1 gets it (rule 2 matches)
// Everyone else outside production: falls through to global Enabled
Rules =
[
new FeatureFlagRule
{
Type = "property",
Values = ["region", "eu-west", "eu-east"],
Enabled = true
}
]
// Global Enabled = false
// Only EU users see the feature

Rule ordering tip: Put specific deny rules first, then specific allow rules, then broad percentage rules. The first match wins, so a deny rule before a percentage rule prevents specific entities from ever being in the rollout.


services.AddPragmaticFeatureFlags();
// Registers InMemoryFeatureFlagStore as IFeatureFlagStore (TryAdd, singleton)

When the SG detects Pragmatic.FeatureFlags in the project references, it auto-registers this call. No manual registration is needed for basic usage.

// Generic overload -- always replaces (AddSingleton, not TryAdd)
services.AddPragmaticFeatureFlags<MyDatabaseFeatureFlagStore>();
// Or manual registration (register before AddPragmaticFeatureFlags)
services.AddSingleton<IFeatureFlagStore, MyCustomStore>();
services.AddPragmaticFeatureFlags(); // TryAdd won't replace your store

The store is registered as singleton by default. Flag definitions are global; the evaluation context is per-call. This is appropriate because the store holds the flag definitions, not per-request state.


FeatureFlagContext.TenantId enables per-tenant feature targeting. The bridging is manual — your IFeatureFlagContextProvider must copy ITenantContext.TenantId into the context.

See Pragmatic.MultiTenancy.

FeatureFlagContext.UserId enables per-user targeting. Copy from ICurrentUser.UserId.

See Pragmatic.Identity.

Pragmatic.FeatureFlags and Pragmatic.Configuration are separate systems:

SystemPurposeEvaluation
IConfigurationStoreKey-value settings (connection strings, limits)Simple lookup
IFeatureFlagStoreFeature toggles with targetingContext-based rules

Use Configuration for settings that apply uniformly. Use FeatureFlags for features that need targeting, rollout, or per-tenant/user control.

See Pragmatic.Configuration.

Check flags in action handlers or endpoint HandleAsync to gate features at the business logic level:

public override async Task<Result<CheckoutResult>> HandleAsync(CancellationToken ct)
{
var context = new FeatureFlagContext { TenantId = tenantContext.TenantId };
if (await flags.IsEnabledAsync("new-checkout", context, ct))
return await NewCheckoutFlowAsync(ct);
else
return await LegacyCheckoutFlowAsync(ct);
}

See Pragmatic.Actions and Pragmatic.Endpoints.

When the SG detects Pragmatic.FeatureFlags in project references (HasFeatureFlags in DetectedFeatures), it auto-registers services.AddPragmaticFeatureFlags(). No manual registration needed.

See Pragmatic.Composition.


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
Store handles evaluationBackends can implement evaluation natively (e.g., LaunchDarkly SDK)
IFeatureFlag with static abstract membersCompile-time safety without runtime allocation for the flag type

Layer 0 (Foundation) Layer 1 (Capabilities)
|-- Result |-- Configuration
|-- Ensure |-- FeatureFlags <-- this module
+-- Abstractions |-- Validation
+-- ...

Pragmatic.FeatureFlags depends only on Pragmatic.Abstractions (for the interface types) and Microsoft.Extensions.DependencyInjection.Abstractions. It has no dependency on ASP.NET Core, Persistence, or any other Pragmatic module.