Building a Runtime Feature Flag Engine in .NET MAUI
π© Building a Runtime Feature Flag Engine in .NET MAUI
Modern mobile applications rarely behave exactly the same for every user. A feature might be available only to beta testers. A redesigned checkout experience might initially be enabled for 5% of customers. An experimental screen might need to disappear immediately if production telemetry reveals a problem. Enterprise customers may receive capabilities that aren't available in the standard edition. The traditional solution is usually some variation of:
if (user.IsPremium)
{
// Show premium feature
}
That works. Until it doesn't. As the application grows, these conditions begin appearing everywhere:
if (configuration.EnableNewDashboard)
{
}
if (user.Role == "Administrator")
{
}
if (Preferences.Get("EnableExperimentalCheckout", false))
{
}
if (DeviceInfo.Platform == DevicePlatform.Android)
{
}
if (remoteConfig["EnableNewSearch"] == "true")
{
}
Soon, feature availability is scattered throughout pages, ViewModels, services, navigation code, configuration files, and platform-specific implementations.
More importantly, changing those decisions may require publishing another application version. For mobile applications, that's a significant limitation.
Users don't necessarily update immediately, store reviews take time, staged deployments can last days, and a production incident may require disabling functionality right now.
That's where a runtime feature flag engine becomes extremely useful. π
In this guide, we'll build a production-oriented feature flag architecture for .NET MAUI that supports:
- π© Runtime feature evaluation
- πΎ Persistent local caching
- βοΈ Remote configuration
- π± Offline operation
- π€ User targeting
- π’ Tenant targeting
- π± Platform targeting
- π Percentage rollouts
- π§ͺ A/B experiments
- π‘οΈ Kill switches
- β³ Time-based activation
- π Background refresh
- π§ Navigation integration
- πΌοΈ UI integration
- βοΈ Dependency Injection
- π§ͺ Automated testing
- π Diagnostics and observability
- π Security boundaries
- ποΈ Enterprise architecture considerations
The goal isn't simply to add a few Boolean configuration values. We're going to build a small feature decision engine.
π Table of Contents
- π© What Is a Feature Flag?
- π€ Why Feature Flags Matter in Mobile Applications
- β The Naive Approach
- ποΈ Target Architecture
- π§© Feature Flag Types
- π¦ Designing the Core Contracts
- ποΈ Defining Feature Keys
- π§ Building the Evaluation Context
- π Modeling Feature Definitions
- βοΈ Building the Feature Flag Engine
- π Implementing Evaluation Rules
- π± Platform-Based Flags
- π€ User-Based Targeting
- π’ Tenant-Based Targeting
- π Percentage Rollouts
- π§ͺ A/B Testing
- β³ Time-Based Flags
- π‘οΈ Kill Switches
- βοΈ Remote Feature Configuration
- πΎ Building an Offline Cache
- π Runtime Refresh
- π‘ Connectivity-Aware Synchronization
- βοΈ Dependency Injection
- πΌοΈ Feature Flags in the UI
- π§ Feature Flags and Navigation
- π§± Feature-Gated Services
- π§© Feature Flags and Modular Architectures
- π Security Considerations
- β‘ Performance and Caching
- π Diagnostics and Observability
- π§ͺ Testing the Engine
- π’ Multi-Tenant Applications
- π Progressive Rollouts
- π Rollback Strategies
- β οΈ Common Mistakes
- π§Ή Feature Flag Lifecycle Management
- ποΈ Suggested Project Structure
- π Architecture Comparison
- π’ Real-World Enterprise Scenario
- β Best Practices
- π― Final Thoughts
- π References
π© 1. What Is a Feature Flag?
A feature flag, also called a feature toggle, is a runtime decision that determines whether a capability should be enabled. The simplest possible implementation is:
bool enableNewDashboard = true;
if (enableNewDashboard)
{
// Use new dashboard.
}
But production feature management is considerably more powerful. Instead of asking:
Is
NewDashboardenabled?
we might ask:
Is
NewDashboardenabled for this particular user, tenant, platform, application version, environment, and rollout bucket at this moment?
That distinction is important. A mature feature flag system is effectively a policy evaluation engine.
π€ 2. Why Feature Flags Matter in Mobile Applications
Feature flags are useful everywhere, but mobile development introduces several constraints that make them especially valuable.
When deploying a web application, you can often replace the server deployment immediately. With mobile applications:
- You publish a new version.
- The store processes it.
- Users receive the update.
- Some users install it immediately.
- Others wait days or weeks.
- Some may never update.
This means several application versions can exist simultaneously in production.
Feature flags provide another layer of control.
Application Release
β
βΌ
βββββββββββββββββββ
β Installed Build β
ββββββββββ¬βββββββββ
β
βΌ
Runtime Feature Engine
β
ββββββββββββββββΌβββββββββββββββ
βΌ βΌ βΌ
Enabled Disabled Variant
You can change behavior without necessarily shipping another binary.
π Typical scenarios
| Scenario | Feature Flag Benefit |
|---|---|
| π Gradual release | Enable a feature for a small percentage first |
| π Production defect | Disable functionality remotely |
| π§ͺ Experiment | Compare two experiences |
| π€ Beta program | Enable features for selected users |
| π’ Enterprise edition | Enable capabilities per tenant |
| π± Platform issue | Disable only on Android or iOS |
| π Regional rollout | Enable functionality gradually by market |
| π Backend migration | Switch clients between implementations |
| π‘οΈ Emergency response | Activate a kill switch |
| π¦ Legacy migration | Keep old and new implementations available temporarily |
β 3. The Naive Approach
A common implementation begins with configuration:
public class FeatureOptions
{
public bool NewDashboard { get; set; }
public bool NewCheckout { get; set; }
public bool ExperimentalSearch { get; set; }
}
Then:
if (_options.NewDashboard)
{
await ShowNewDashboardAsync();
}
For a small application, this may be enough. The problems appear when requirements become dynamic. What if NewDashboard should be:
- enabled for Android but not iOS,
- enabled for employees,
- enabled for 10% of customers,
- disabled for app versions below 5.2,
- enabled only after a specific date,
- immediately disabled during an incident?
A Boolean isn't enough anymore. You need rules.
ποΈ 4. Target Architecture
Let's design the system around several responsibilities.
ββββββββββββββββββββββββ
β Remote Configuration β
βββββββββββββ¬βββββββββββ
β
βΌ
βββββββββββββββββββ
β Refresh Service β
ββββββββββ¬βββββββββ
β
βΌ
βββββββββββββββββββ
β Persistent Cacheβ
ββββββββββ¬βββββββββ
β
βΌ
βββββββββββββββββββββββ
β Feature Flag Engine β
ββββββββββββ¬βββββββββββ
β
ββββββββββββββββββββββββββΌβββββββββββββββββββββββββ
β β β
βΌ βΌ βΌ
Evaluation Context Flag Definition Rule Pipeline
β β
βββββββββΌβββββββββ ββββββββββββββββΌββββββββββββββ
βΌ βΌ βΌ βΌ βΌ βΌ
User Tenant Platform User Percentage Time
Rules
β
βΌ
Feature Decision
β
βββββββββββββββββββββΌβββββββββββββββββββββ
βΌ βΌ βΌ
UI Navigation Services
This architecture separates:
- Where flags come from
- How they are stored
- How they're evaluated
- Who is requesting the decision
- What the application does with the result
That separation will become extremely important later.
π§© 5. Feature Flag Types
Not every feature flag serves the same purpose.
A useful classification looks like this:
| Type | Purpose | Example |
|---|---|---|
| π Release flag | Progressive feature deployment | New dashboard |
| π§ͺ Experiment flag | A/B testing | Checkout A vs B |
| π‘οΈ Operational flag | Runtime protection | Disable image processing |
| π’ Entitlement flag | Customer capability | Enterprise reports |
| π± Platform flag | Platform-specific control | New Android camera |
| π Migration flag | Switch implementations | REST v1 β REST v2 |
| π§βπ» Development flag | Internal/testing capability | Debug diagnostics |
These flags also have different expected lifetimes.
An experiment might exist for two weeks.
An entitlement may exist for years.
A kill switch may remain permanently available.
This matters because feature flags can easily become technical debt.
We'll return to that later.
π¦ 6. Designing the Core Contracts
Let's start with the engine itself.
public interface IFeatureFlagEngine
{
Task<bool> IsEnabledAsync(
string feature,
FeatureContext context,
CancellationToken cancellationToken = default);
}
For applications where the engine can resolve the current user context automatically, we can provide a convenience abstraction:
public interface IFeatureManager
{
Task<bool> IsEnabledAsync(
string feature,
CancellationToken cancellationToken = default);
}
The distinction is useful.
IFeatureFlagEngine is the low-level evaluator.
IFeatureManager is the application-facing facade.
ποΈ 7. Defining Feature Keys
Avoid magic strings throughout the application. Bad:
if (await _featureManager.IsEnabledAsync("new-dashbaord"))
{
}
Notice the typo. Instead:
public static class Features
{
public const string NewDashboard = "new-dashboard";
public const string NewCheckout = "new-checkout";
public const string NewSearch = "new-search";
public const string DiagnosticPanel = "diagnostic-panel";
public const string EmergencyPaymentsKillSwitch =
"emergency-payments-kill-switch";
}
Now:
if (await _featureManager.IsEnabledAsync(Features.NewDashboard))
{
}
For larger applications, you can go further and introduce strongly typed identifiers:
public readonly record struct FeatureKey(string Value);
Then:
public static class AppFeatures
{
public static readonly FeatureKey NewDashboard =
new("new-dashboard");
public static readonly FeatureKey NewCheckout =
new("new-checkout");
}
This makes accidental interchange with arbitrary strings harder.
π§ 8. Building the Evaluation Context
Feature decisions need context.
public sealed record FeatureContext
{
public string? UserId { get; init; }
public string? TenantId { get; init; }
public string? Country { get; init; }
public string? AppVersion { get; init; }
public string? Environment { get; init; }
public string Platform { get; init; } = string.Empty;
public IReadOnlyCollection<string> Roles { get; init; }
= Array.Empty<string>();
}
In .NET MAUI we can populate some values automatically:
public sealed class MauiFeatureContextProvider
{
public FeatureContext Create(
string? userId,
string? tenantId,
IEnumerable<string>? roles = null)
{
return new FeatureContext
{
UserId = userId,
TenantId = tenantId,
Platform = DeviceInfo.Platform.ToString(),
AppVersion = AppInfo.Current.VersionString,
Roles = roles?.ToArray() ?? Array.Empty<string>()
};
}
}
Now the engine knows not only which feature is being evaluated, but for whom and where.
π 9. Modeling Feature Definitions
A feature definition needs more than a Boolean.
public sealed class FeatureDefinition
{
public required string Key { get; init; }
public bool Enabled { get; init; }
public bool KillSwitch { get; init; }
public int? RolloutPercentage { get; init; }
public DateTimeOffset? StartsAtUtc { get; init; }
public DateTimeOffset? EndsAtUtc { get; init; }
public IReadOnlyCollection<string> Users { get; init; }
= Array.Empty<string>();
public IReadOnlyCollection<string> Tenants { get; init; }
= Array.Empty<string>();
public IReadOnlyCollection<string> Platforms { get; init; }
= Array.Empty<string>();
}
A remote representation might look like:
{
"key": "new-dashboard",
"enabled": true,
"killSwitch": false,
"rolloutPercentage": 25,
"platforms": [
"Android",
"iOS"
],
"users": [],
"tenants": [
"enterprise-001"
]
}
This already gives us significantly more control than a Boolean configuration value.
βοΈ 10. Building the Feature Flag Engine
Let's introduce a definition provider.
public interface IFeatureDefinitionProvider
{
Task<FeatureDefinition?> GetAsync(
string feature,
CancellationToken cancellationToken = default);
}
Now the engine:
public sealed class FeatureFlagEngine : IFeatureFlagEngine
{
private readonly IFeatureDefinitionProvider _definitions;
public FeatureFlagEngine(
IFeatureDefinitionProvider definitions)
{
_definitions = definitions;
}
public async Task<bool> IsEnabledAsync(
string feature,
FeatureContext context,
CancellationToken cancellationToken = default)
{
var definition = await _definitions.GetAsync(
feature,
cancellationToken);
if (definition is null)
return false;
if (!definition.Enabled)
return false;
if (definition.KillSwitch)
return false;
if (!MatchesPlatform(definition, context))
return false;
if (!MatchesTenant(definition, context))
return false;
if (!MatchesUser(definition, context))
return false;
if (!MatchesTimeWindow(definition))
return false;
if (!MatchesRollout(definition, context))
return false;
return true;
}
}
This is our first real decision pipeline.
But putting every rule inside one class will eventually create another monolith.
We can improve it.
π 11. Implementing Evaluation Rules
Create a rule abstraction.
public interface IFeatureEvaluationRule
{
int Order { get; }
ValueTask<bool> EvaluateAsync(
FeatureDefinition definition,
FeatureContext context,
CancellationToken cancellationToken);
}
Now individual policies become isolated.
public sealed class PlatformRule : IFeatureEvaluationRule
{
public int Order => 100;
public ValueTask<bool> EvaluateAsync(
FeatureDefinition definition,
FeatureContext context,
CancellationToken cancellationToken)
{
if (definition.Platforms.Count == 0)
return ValueTask.FromResult(true);
var matches = definition.Platforms.Any(
x => string.Equals(
x,
context.Platform,
StringComparison.OrdinalIgnoreCase));
return ValueTask.FromResult(matches);
}
}
Then:
public sealed class RuleBasedFeatureFlagEngine : IFeatureFlagEngine
{
private readonly IFeatureDefinitionProvider _definitions;
private readonly IReadOnlyList<IFeatureEvaluationRule> _rules;
public RuleBasedFeatureFlagEngine(
IFeatureDefinitionProvider definitions,
IEnumerable<IFeatureEvaluationRule> rules)
{
_definitions = definitions;
_rules = rules
.OrderBy(x => x.Order)
.ToArray();
}
public async Task<bool> IsEnabledAsync(
string feature,
FeatureContext context,
CancellationToken cancellationToken = default)
{
var definition = await _definitions.GetAsync(
feature,
cancellationToken);
if (definition is null || !definition.Enabled)
return false;
foreach (var rule in _rules)
{
cancellationToken.ThrowIfCancellationRequested();
if (!await rule.EvaluateAsync(
definition,
context,
cancellationToken))
{
return false;
}
}
return true;
}
}
Now the engine follows the Open/Closed Principle. Adding another rule doesn't require modifying the evaluator.
π± 12. Platform-Based Flags
Suppose a new camera implementation works well on Android but still has issues on iOS.
Instead of delaying the entire release:
{
"key": "new-camera",
"enabled": true,
"platforms": [
"Android"
]
}
The engine automatically rejects iOS:
New Camera
β
βΌ
Enabled?
Yes
β
βΌ
Platform Allowed?
β
βββββ΄βββββ
β β
Android iOS
β β
βΌ βΌ
YES NO
This is extremely useful during platform-specific migrations.
π€ 13. User-Based Targeting
Beta users can receive functionality first.
public sealed class UserTargetingRule : IFeatureEvaluationRule
{
public int Order => 200;
public ValueTask<bool> EvaluateAsync(
FeatureDefinition definition,
FeatureContext context,
CancellationToken cancellationToken)
{
if (definition.Users.Count == 0)
return ValueTask.FromResult(true);
if (string.IsNullOrWhiteSpace(context.UserId))
return ValueTask.FromResult(false);
var enabled = definition.Users.Contains(
context.UserId,
StringComparer.OrdinalIgnoreCase);
return ValueTask.FromResult(enabled);
}
}
Now your backend configuration could specify:
{
"key": "experimental-search",
"enabled": true,
"users": [
"user-1002",
"user-1048",
"user-2025"
]
}
π’ 14. Tenant-Based Targeting
For enterprise applications, tenant targeting is even more useful. Imagine three customers:
Application
β
βββ Contoso
βββ Fabrikam
βββ AdventureWorks
Only Contoso purchased Advanced Analytics.
{
"key": "advanced-analytics",
"enabled": true,
"tenants": [
"contoso"
]
}
Your UI doesn't need hardcoded customer checks. Bad:
if (_tenant.Name == "Contoso")
{
}
Better:
if (await _features.IsEnabledAsync(
Features.AdvancedAnalytics))
{
}
Business policy is moved out of presentation logic.
π 15. Percentage Rollouts
This is where feature flags become especially powerful. Suppose you want to release NewCheckout gradually.
Day 1
5%
Day 3
20%
Day 7
50%
Day 14
100%
A critical requirement is stable assignment. Don't do this:
return Random.Shared.Next(0, 100) < percentage;
That would cause users to move in and out of the feature every time it is evaluated. π¬ Instead, calculate a deterministic bucket.
public static int GetStableBucket(
string feature,
string userId)
{
var value = $"{feature}:{userId}";
var bytes = SHA256.HashData(
Encoding.UTF8.GetBytes(value));
var number = BitConverter.ToUInt32(bytes, 0);
return (int)(number % 100);
}
Then:
var bucket = GetStableBucket(
definition.Key,
context.UserId!);
return bucket < definition.RolloutPercentage;
A user assigned to bucket 12 remains in bucket 12.
π Rollout Example
| User | Bucket | 10% Rollout | 25% Rollout | 50% Rollout |
|---|---|---|---|---|
| π€ A | 4 | β | β | β |
| π€ B | 17 | β | β | β |
| π€ C | 38 | β | β | β |
| π€ D | 71 | β | β | β |
| π€ E | 95 | β | β | β |
As rollout increases, previously enabled users remain enabled.
That's exactly what we want.
π§ͺ 16. A/B Testing
Feature flags can also select variants. Instead of:
bool enabled;
you may return:
public sealed record FeatureDecision(
bool Enabled,
string? Variant = null);
Possible variants:
checkout-control
checkout-a
checkout-b
For example:
| Bucket | Variant | | --: | --- | | 0β49 | π °οΈ Checkout A | | 50β99 | π ±οΈ Checkout B | Then:
var decision =
await _featureManager.EvaluateAsync(
Features.CheckoutExperiment);
switch (decision.Variant)
{
case "checkout-a":
await ShowCheckoutAAsync();
break;
case "checkout-b":
await ShowCheckoutBAsync();
break;
default:
await ShowLegacyCheckoutAsync();
break;
}
β οΈ Important
A feature flag engine can assign variants, but proper experimentation also requires:
- Exposure tracking
- Conversion events
- Statistical analysis
- Experiment start/end governance
- Stable assignment
- Privacy considerations
Feature flags and experimentation overlap, but they aren't exactly the same system.
β³ 17. Time-Based Flags
Some features should activate automatically.
public sealed class TimeWindowRule : IFeatureEvaluationRule
{
private readonly TimeProvider _timeProvider;
public TimeWindowRule(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public int Order => 300;
public ValueTask<bool> EvaluateAsync(
FeatureDefinition definition,
FeatureContext context,
CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
if (definition.StartsAtUtc is { } start &&
now < start)
{
return ValueTask.FromResult(false);
}
if (definition.EndsAtUtc is { } end &&
now >= end)
{
return ValueTask.FromResult(false);
}
return ValueTask.FromResult(true);
}
}
Using TimeProvider instead of directly calling DateTime.UtcNow makes testing much easier.
π‘οΈ 18. Kill Switches
One of the highest-value applications of feature management is the kill switch.
Imagine a new image processing engine causes crashes on certain Android devices.
Without remote control:
Bug discovered
β
Fix developed
β
New build
β
Store submission
β
Review
β
User update
With a kill switch:
Bug discovered
β
Remote flag changed
β
Clients refresh
β
Feature disabled
π₯ Huge operational difference.
A kill switch should usually take precedence over ordinary targeting.
Feature Evaluation
β
βΌ
Kill Switch?
/ \
YES NO
β β
βΌ βΌ
DISABLED Continue Rules
For safety-critical operational flags, fail-safe behavior should be explicitly designed.
βοΈ 19. Remote Feature Configuration
So far, our definitions could come from anywhere. That's intentional.
public interface IRemoteFeatureSource
{
Task<IReadOnlyCollection<FeatureDefinition>> GetAsync(
CancellationToken cancellationToken = default);
}
Possible implementations include:
- βοΈ Azure App Configuration
- π Your REST API
- π₯ Firebase Remote Config
- π¦ Custom configuration service
- π’ Internal enterprise configuration platform
Your engine should not depend directly on any one vendor.
Azure App Configuration ββ
β
Custom REST API ββββββββββΌβββΊ IRemoteFeatureSource
β
Other Provider βββββββββββ
β
βΌ
Feature Flag Engine
That abstraction protects the application architecture from provider-specific concerns.
πΎ 20. Building an Offline Cache
Mobile applications cannot assume connectivity. If the application requires network access every time this executes:
await _featureManager.IsEnabledAsync(...);
the architecture is already problematic. Feature evaluation should usually happen locally. Remote systems should refresh definitions, not participate in every decision.
Remote Server
β
β periodic refresh
βΌ
Local Persistent Cache
β
βΌ
In-Memory Snapshot
β
βΌ
Feature Evaluation
Define storage:
public interface IFeatureFlagStore
{
Task<IReadOnlyCollection<FeatureDefinition>> LoadAsync(
CancellationToken cancellationToken = default);
Task SaveAsync(
IReadOnlyCollection<FeatureDefinition> definitions,
CancellationToken cancellationToken = default);
}
A JSON-backed implementation could serialize the current snapshot into FileSystem.AppDataDirectory.
var path = Path.Combine(
FileSystem.AppDataDirectory,
"feature-flags.json");
Cache strategy
| State | Behavior |
|---|---|
| π Online + cache exists | Use cache immediately, refresh remotely |
| π Online + no cache | Fetch remote definitions |
| π΄ Offline + cache exists | Use cached definitions |
| π΄ Offline + no cache | Use safe defaults |
| β Remote refresh fails | Keep last known good snapshot |
| β οΈ Remote payload invalid | Reject update and keep previous snapshot |
This pattern is extremely important.
Never destroy a known-good configuration because a refresh failed.
π 21. Runtime Refresh
Flags become far more useful when they can change while the app is running.
public interface IFeatureRefreshService
{
Task RefreshAsync(
CancellationToken cancellationToken = default);
}
Implementation:
public sealed class FeatureRefreshService
: IFeatureRefreshService
{
private readonly IRemoteFeatureSource _remote;
private readonly IFeatureFlagStore _store;
private readonly FeatureSnapshotProvider _snapshot;
public FeatureRefreshService(
IRemoteFeatureSource remote,
IFeatureFlagStore store,
FeatureSnapshotProvider snapshot)
{
_remote = remote;
_store = store;
_snapshot = snapshot;
}
public async Task RefreshAsync(
CancellationToken cancellationToken = default)
{
var definitions =
await _remote.GetAsync(cancellationToken);
await _store.SaveAsync(
definitions,
cancellationToken);
_snapshot.Replace(definitions);
}
}
The refresh pipeline becomes:
Refresh Requested
β
βΌ
Download Configuration
β
βΌ
Validate Configuration
β
βΌ
Persist Snapshot
β
βΌ
Swap In-Memory Snapshot
β
βΌ
Notify Interested UI
Notice that the active snapshot changes only after the new configuration has been validated.
π‘ 22. Connectivity-Aware Synchronization
.NET MAUI exposes connectivity information through Connectivity. You can use it as a hint:
if (Connectivity.Current.NetworkAccess ==
NetworkAccess.Internet)
{
await _refreshService.RefreshAsync();
}
You can also react to connectivity changes:
Connectivity.Current.ConnectivityChanged +=
OnConnectivityChanged;
But connectivity APIs don't guarantee that your backend is reachable.
The device might technically have internet access while:
- DNS fails,
- your API is unavailable,
- authentication expired,
- a captive portal intercepts requests.
Therefore:
Connectivity should optimize refresh behavior, not determine correctness.
Remote refresh must still handle network failures normally.
βοΈ 23. Dependency Injection
Register the architecture in MauiProgram.cs.
builder.Services.AddSingleton<FeatureSnapshotProvider>();
builder.Services.AddSingleton<IFeatureFlagStore, JsonFeatureFlagStore>();
builder.Services.AddSingleton<IRemoteFeatureSource, ApiFeatureSource>();
builder.Services.AddSingleton<IFeatureDefinitionProvider, CachedFeatureDefinitionProvider>();
builder.Services.AddSingleton<IFeatureEvaluationRule, KillSwitchRule>();
builder.Services.AddSingleton<IFeatureEvaluationRule, PlatformRule>();
builder.Services.AddSingleton<IFeatureEvaluationRule, UserTargetingRule>();
builder.Services.AddSingleton<IFeatureEvaluationRule, TenantTargetingRule>();
builder.Services.AddSingleton<IFeatureEvaluationRule, TimeWindowRule>();
builder.Services.AddSingleton<IFeatureEvaluationRule, PercentageRolloutRule>();
builder.Services.AddSingleton<IFeatureFlagEngine, RuleBasedFeatureFlagEngine>();
builder.Services.AddSingleton<IFeatureManager, FeatureManager>();
builder.Services.AddSingleton<IFeatureRefreshService, FeatureRefreshService>();
builder.Services.AddSingleton(TimeProvider.System);
The application layer only needs:
IFeatureManager
Most pages shouldn't know anything about remote configuration, caching, hashing, targeting rules, or synchronization.
πΌοΈ 24. Feature Flags in the UI
A ViewModel can evaluate a feature.
public partial class HomeViewModel : ObservableObject
{
private readonly IFeatureManager _features;
[ObservableProperty]
private bool isNewDashboardEnabled;
public HomeViewModel(IFeatureManager features)
{
_features = features;
}
public async Task InitializeAsync()
{
IsNewDashboardEnabled =
await _features.IsEnabledAsync(
Features.NewDashboard);
}
}
XAML:
<Button
Text="Open New Dashboard"
IsVisible="{Binding IsNewDashboardEnabled}" />
This is much cleaner than embedding feature evaluation logic throughout XAML converters or code-behind.
π§ 25. Feature Flags and Navigation
Flags can protect navigation too.
Hiding a button is not enough.
A route might still be reached through:
- Deep links
- Programmatic navigation
- Restored navigation state
- Notifications
- Custom URI handlers
Create a navigation guard:
public sealed class FeatureNavigationGuard
{
private readonly IFeatureManager _features;
public FeatureNavigationGuard(
IFeatureManager features)
{
_features = features;
}
public async Task<bool> CanNavigateAsync(
string requiredFeature)
{
return await _features.IsEnabledAsync(
requiredFeature);
}
}
Usage:
if (!await _guard.CanNavigateAsync(
Features.AdvancedReports))
{
return;
}
await Shell.Current.GoToAsync(
nameof(AdvancedReportsPage));
π‘οΈ Defense in depth
Feature Flag
β
βββ UI Visibility
β
βββ Navigation Guard
β
βββ Service / API Authorization
The feature flag improves UX.
The backend still enforces authorization.
π§± 26. Feature-Gated Services
Sometimes the feature controls an implementation rather than a screen.
Suppose you're migrating from a legacy API.
public interface ICheckoutService
{
Task CheckoutAsync();
}
Two implementations:
public sealed class LegacyCheckoutService
: ICheckoutService
{
public Task CheckoutAsync()
{
// Existing implementation.
return Task.CompletedTask;
}
}
public sealed class NewCheckoutService
: ICheckoutService
{
public Task CheckoutAsync()
{
// New implementation.
return Task.CompletedTask;
}
}
Feature-aware facade:
public sealed class FeatureAwareCheckoutService
: ICheckoutService
{
private readonly IFeatureManager _features;
private readonly LegacyCheckoutService _legacy;
private readonly NewCheckoutService _modern;
public FeatureAwareCheckoutService(
IFeatureManager features,
LegacyCheckoutService legacy,
NewCheckoutService modern)
{
_features = features;
_legacy = legacy;
_modern = modern;
}
public async Task CheckoutAsync()
{
if (await _features.IsEnabledAsync(
Features.NewCheckout))
{
await _modern.CheckoutAsync();
return;
}
await _legacy.CheckoutAsync();
}
}
The consumer remains unaware of the migration.
π₯ This is an excellent pattern for safely replacing production implementations.
π§© 27. Feature Flags and Modular Architectures
Feature flags pair extremely well with modular/plugin architectures. Imagine:
Host Application
β
βββ Orders Module
βββ Reports Module
βββ Analytics Module
βββ Support Module
Feature flags can determine which modules appear in the user experience.
if (await features.IsEnabledAsync(
Features.Analytics))
{
menu.Add(analyticsMenuItem);
}
But there's an important architectural distinction:
A runtime feature flag does not necessarily mean the assembly itself should be dynamically loaded or unloaded.
In many MAUI applications, the code remains packaged with the application while runtime flags control whether it is reachable or active.
This avoids unnecessary complexity with:
- AOT
- Trimming
- Assembly loading
- Native platform packaging
- App Store constraints
π 28. Security Considerations
This deserves special attention.
π¨ Feature flags are not authorization. Never assume this:
if (!await features.IsEnabledAsync("admin-panel"))
{
return;
}
protects sensitive backend operations. A modified client could bypass the check. The server must independently authorize the operation.
Correct model
MAUI Client
β
Feature Flag Check
β
βΌ
Show Feature
β
βΌ
API Request
β
βΌ
Server Authorization
β
ββββββ΄βββββ
βΌ βΌ
Allowed Denied
π Sensitive flag data
Avoid putting secrets into flag definitions. Bad:
{
"apiKey": "super-secret-key"
}
Feature configuration should be treated as client-readable configuration. Even if stored securely, assume a determined user can inspect the application.
β‘ 29. Performance and Caching
Feature evaluation can occur frequently. Consider a page with:
12 buttons
8 menu entries
5 cards
4 navigation actions
If every decision performs an HTTP request, performance will be terrible. The evaluation path should ideally be:
Feature Request
β
βΌ
In-Memory Snapshot
β
βΌ
Local Rule Evaluation
β
βΌ
Decision
No network. No disk. No JSON parsing.
π Comparison
| Strategy | Latency | Offline | Recommended |
|---|---|---|---|
| π Remote request per evaluation | High | β | β |
| πΎ Disk read per evaluation | Medium | β | β |
| π§ In-memory snapshot | Very low | β | β |
| π§ Memory + periodic refresh | Very low | β | β Best |
Use immutable or effectively immutable snapshots whenever possible.
π 30. Diagnostics and Observability
When feature flags influence production behavior, you eventually need to answer:
Why did this user receive this experience?
A simple Boolean doesn't tell you. Consider a richer decision:
public sealed record FeatureDecision(
string Feature,
bool Enabled,
string Reason,
string? Variant = null,
DateTimeOffset? EvaluatedAt = null);
Possible reasons:
- GloballyDisabled
- KillSwitchActive
- PlatformMismatch
- TenantMismatch
- UserTargeted
- RolloutIncluded
- RolloutExcluded
- TimeWindowExpired
- Enabled
Example:
new FeatureDecision(
Feature: "new-checkout",
Enabled: false,
Reason: "RolloutExcluded",
EvaluatedAt: DateTimeOffset.UtcNow);
This is extremely useful for diagnostics.
π Useful telemetry
Track aggregate information such as:
- Feature evaluated
- Feature enabled
- Variant selected
- Configuration refreshed
- Configuration refresh failed
- Snapshot age
- Invalid configuration rejected
Be careful with user identifiers and privacy-sensitive telemetry.
π§ͺ 31. Testing the Engine
One major benefit of separating evaluation rules is testability.
Platform test
[Fact]
public async Task AndroidOnlyFeature_ShouldBeDisabledOnIos()
{
var definition = new FeatureDefinition
{
Key = "camera-v2",
Enabled = true,
Platforms = new[] { "Android" }
};
var context = new FeatureContext
{
Platform = "iOS"
};
var rule = new PlatformRule();
var result = await rule.EvaluateAsync(
definition,
context,
CancellationToken.None);
Assert.False(result);
}
Tenant test
[Fact]
public async Task Feature_ShouldBeEnabledForConfiguredTenant()
{
var definition = new FeatureDefinition
{
Key = "enterprise-reports",
Enabled = true,
Tenants = new[] { "contoso" }
};
var context = new FeatureContext
{
TenantId = "contoso"
};
var rule = new TenantTargetingRule();
Assert.True(await rule.EvaluateAsync(
definition,
context,
CancellationToken.None));
}
Rollout stability test
[Fact]
public void Bucket_ShouldBeStable()
{
var first = StableBucket.Get(
"new-dashboard",
"user-123");
var second = StableBucket.Get(
"new-dashboard",
"user-123");
Assert.Equal(first, second);
}
Offline fallback test
[Fact]
public async Task FailedRefresh_ShouldKeepPreviousSnapshot()
{
// Arrange known-good cached configuration.
// Simulate remote failure.
// Verify active configuration remains unchanged.
}
These tests become essential once flags influence critical workflows.
π’ 32. Multi-Tenant Applications
Enterprise MAUI applications frequently support multiple organizations from one application binary. Feature flags can act as a runtime capability layer.
MAUI Application
β
βββββββββββββββΌββββββββββββββ
βΌ βΌ βΌ
Tenant A Tenant B Tenant C
β β β
Standard Premium Custom
Configuration:
{
"features": {
"advanced-reports": {
"enabled": true,
"tenants": [
"tenant-b",
"tenant-c"
]
}
}
}
This is useful for:
- π’ Enterprise licensing
- π¨ White-label experiences
- π§© Optional modules
- π Regional functionality
- π§ͺ Customer-specific pilots
However, commercial entitlements should normally also be enforced server-side.
π 33. Progressive Rollouts
A safe rollout might look like:
Internal Users
β
βΌ
1%
β
βΌ
5%
β
βΌ
10%
β
βΌ
25%
β
βΌ
50%
β
βΌ
100%
At every stage you monitor:
- π₯ Crash rate
- β±οΈ Response times
- π Failure rates
- π€ User feedback
- π Conversion
- π Resource consumption
If something degrades:
50%
β
βΌ
Problem detected
β
βΌ
Kill switch / rollback
β
βΌ
0%
No new mobile binary required for the flag change itself.
π 34. Rollback Strategies
Feature flags enable several rollback patterns.
π‘οΈ Immediate Disable
New implementation β OFF
Legacy implementation β ON
π Rollout Reduction
50% β 10%
π± Platform Rollback
Android β OFF
iOS β ON
π’ Tenant Rollback
Tenant A β OFF
Other tenants β ON
This level of precision is difficult to achieve with application releases alone.
β οΈ 35. Common Mistakes
β Calling the Server for Every Evaluation
Feature decisions should generally be local.
β Using Random Percentage Assignment
Random.Shared.Next(...)
will create inconsistent experiences. Use deterministic bucketing.
β Treating Flags as Authorization
The client cannot be trusted as the final security boundary.
β Keeping Flags Forever
Old flags accumulate quickly.
- NewDashboard
- NewDashboardV2
- UseNewDashboard
- DashboardExperiment
- DashboardMigration
- DashboardFinal
π΅ Remove obsolete flags.
β Feature Logic Everywhere
Avoid:
if (flag)
in dozens of unrelated classes. Prefer feature-aware abstractions when behavior becomes complex.
β No Offline Strategy
Mobile connectivity is unreliable by definition. Always define fallback behavior.
β Replacing Good Configuration with Bad Data
Remote refresh should be transactional:
Download
β
Validate
β
Persist
β
Activate
Never:
Download
β
Immediately Activate
β
Discover Invalid Configuration π₯
π§Ή 36. Feature Flag Lifecycle Management
Feature flags should have owners. A useful metadata model might include:
public sealed record FeatureMetadata(
string Owner,
string Purpose,
DateTimeOffset CreatedAt,
DateTimeOffset? ExpectedRemovalDate);
Example:
Feature: new-checkout
Owner: Payments Team
Type: Release
Created: 2026-04-01
Expected Removal: 2026-06-15
Once rollout reaches 100% and the old implementation is removed, the flag should usually disappear too.
π Suggested policy
| Flag Type | Typical Lifetime |
|---|---|
| π Release | Days / weeks |
| π§ͺ Experiment | Weeks |
| π Migration | Weeks / months |
| π‘οΈ Operational | Long-lived |
| π’ Entitlement | Long-lived |
| π§βπ» Development | Short-lived |
Without lifecycle management, feature flags become architecture debt.
ποΈ 37. Suggested Project Structure
A clean implementation could look like:
Features/
β
βββ Abstractions/
β βββ IFeatureManager.cs
β βββ IFeatureFlagEngine.cs
β βββ IFeatureDefinitionProvider.cs
β βββ IFeatureFlagStore.cs
β βββ IRemoteFeatureSource.cs
β
βββ Models/
β βββ FeatureDefinition.cs
β βββ FeatureContext.cs
β βββ FeatureDecision.cs
β
βββ Evaluation/
β βββ FeatureFlagEngine.cs
β βββ KillSwitchRule.cs
β βββ PlatformRule.cs
β βββ UserTargetingRule.cs
β βββ TenantTargetingRule.cs
β βββ TimeWindowRule.cs
β βββ PercentageRolloutRule.cs
β
βββ Storage/
β βββ JsonFeatureFlagStore.cs
β βββ FeatureSnapshotProvider.cs
β
βββ Remote/
β βββ ApiFeatureSource.cs
β
βββ Synchronization/
β βββ FeatureRefreshService.cs
β
βββ Diagnostics/
βββ FeatureDiagnostics.cs
This is significantly easier to evolve than a single FeatureService containing everything.
π 38. Architecture Comparison
| Capability | Hardcoded Boolean | Remote Boolean | Runtime Feature Engine |
|---|---|---|---|
| π© Enable/disable | β | β | β |
| βοΈ Remote changes | β | β | β |
| π΄ Offline support | β | β οΈ | β |
| π€ User targeting | β | β οΈ | β |
| π’ Tenant targeting | β | β οΈ | β |
| π± Platform targeting | β | β οΈ | β |
| π Percentage rollout | β | β | β |
| π§ͺ Experiments | β | β | β |
| π‘οΈ Kill switches | β | β | β |
| β³ Scheduling | β | β οΈ | β |
| π Decision diagnostics | β | β | β |
| π§ͺ Rule-level testing | β | β | β |
| π Last-known-good fallback | β | β οΈ | β |
π’ 39. Real-World Enterprise Scenario
Imagine a retail application used by thousands of employees.
A new inventory scanner is ready.
The company doesn't want to release it to everyone immediately.
Stage 1 β Development
Employees in development group only
Stage 2 β Pilot Stores
Tenant / Store IDs:
101
204
309
Stage 3 β Android Rollout
Platform: Android
Rollout: 10%
Stage 4 β Expanded Rollout
Platform: Android
Rollout: 50%
Stage 5 β All Android Users
Android: 100%
iOS: 0%
Stage 6 β Full Deployment
Android: 100%
iOS: 100%
Then a crash appears on a specific platform version. Instead of rolling back the entire application:
Inventory Scanner
β
βββ Android affected version β OFF
β
βββ Android other versions β ON
β
βββ iOS β ON
That is the operational power of feature flags.
β 40. Best Practices
π© Flag Design
- β Give every feature a stable identifier.
- β Define safe defaults.
- β Classify flags by purpose.
- β Assign ownership.
- β Define expected removal dates for temporary flags.
π§ Evaluation
- β Evaluate locally.
- β Use deterministic rollout buckets.
- β Make rules independently testable.
- β Return diagnostic reasons when useful.
- β Use immutable snapshots.
βοΈ Remote Configuration
- β Treat remote configuration as synchronization.
- β Validate before activation.
- β Preserve last-known-good configuration.
- β Handle remote failures gracefully.
- β Never put secrets in feature definitions.
π΄ Offline
- β Persist a snapshot.
- β Define first-launch defaults.
- β Never require connectivity for normal evaluation.
- β Treat connectivity APIs as hints.
π Security
- β Never replace server authorization with feature flags.
- β Assume client-side configuration can be inspected.
- β Protect commercial entitlements server-side.
- β Minimize sensitive targeting data.
β‘ Performance
- β Keep decisions in memory.
- β Avoid HTTP calls during evaluation.
- β Avoid disk access during evaluation.
- β Cache deterministic context where appropriate.
π§ͺ Testing
- β Test every rule independently.
- β Test percentage stability.
- β Test offline startup.
- β Test corrupted configuration.
- β Test failed refresh.
- β Test kill-switch precedence.
- β Test navigation protection.
π§Ή Maintenance
- β Remove completed rollout flags.
- β Remove obsolete code paths.
- β Track flag ownership.
- β Audit long-lived flags regularly.
π― 41. Final Thoughts
Feature flags are often introduced as a simple mechanism:
if (enabled)
{
}
But at scale, they become much more than Boolean configuration.
A well-designed runtime feature flag engine can become an operational control plane for your .NET MAUI application.
It allows teams to: π release features progressively, π§ͺ run controlled experiments, π‘οΈ disable problematic functionality, π± isolate platform-specific issues, π’ customize enterprise experiences, π€ target specific users, π control percentage rollouts, π migrate between implementations, and π΄ continue making decisions even when the device is offline.
The most important architectural principle is to separate feature evaluation from feature synchronization.
Your application shouldn't contact a remote service every time it needs a decision.
Instead:
Remote Configuration
β
βΌ
Refresh
β
βΌ
Persistent Last-Known-Good Snapshot
β
βΌ
In-Memory Configuration
β
βΌ
Local Decision Engine
β
βΌ
Application Behavior
That design is fast, resilient, testable, provider-independent, and appropriate for the realities of mobile applications.
And perhaps the biggest benefit is operational.
A traditional release answers:
What code did we ship?
A runtime feature engine lets you answer another question:
What functionality should this particular user receive right now?
For large .NET MAUI applications, that can be an extremely powerful capability. π
π 42. References
| Resource | Description |
|---|---|
| π .NET MAUI Documentation | Official .NET MAUI documentation |
| π© Feature management in .NET | Microsoft's .NET feature-management reference |
| βοΈ Azure App Configuration | Centralized configuration and feature management |
| π© Azure App Configuration Feature Management | Feature flag concepts and management |
| π± .NET MAUI Connectivity | Network connectivity APIs in MAUI |
| πΎ .NET MAUI File System Helpers | Application data and cache directories |
| π .NET MAUI Secure Storage | Securely storing small key/value data |
| βοΈ .NET MAUI Dependency Injection | DI architecture in .NET MAUI |
| π§ .NET MAUI Shell Navigation | Shell navigation and route architecture |
| π± .NET MAUI Device Information | Platform/device context |
| π¦ .NET MAUI App Information | Application version and package information |
| β° TimeProvider | Testable abstraction for time-based rules |
| π SHA256 | Deterministic hashing foundation for rollout buckets |
| π§ͺ xUnit | Unit testing framework for .NET |
| π§° .NET Community Toolkit | .NET helper libraries |
| π± .NET Community Toolkit MVVM | MVVM helpers useful for feature-aware ViewModels |
π Where to Go From Here
Once this architecture is working, several interesting extensions become possible:
- π Build an A/B Experiment Engine
- π§© Integrate flags with a Plugin Architecture
- π Build a Feature Telemetry Dashboard
- π΄ Add a Self-Healing Offline Configuration Layer
- π Implement server-driven UI capabilities
- π’ Build tenant-specific capability policies
- 𧬠Generate strongly typed feature keys with a Roslyn Source Generator
- π‘οΈ Add automated kill-switch health policies
- π Create a complete progressive delivery platform for .NET MAUI
At that point, feature flags stop being a collection of if statements and become a genuine part of your application's runtime architecture. π©π
Was this useful?
Sign in to react. Guest comments are still welcome.



 Layer in .NET MAUI/RASPMAUI.png)
Comments (0)
No approved comments yet.