Add feature flags
Granit.Features provides a SaaS-oriented feature management system. Features are resolved through a cascade (Tenant override, Plan value, Default) and cached with hybrid L1/L2 caching. Three value types cover common scenarios: toggles, numeric quotas, and selection lists.
Prerequisites
Section titled “Prerequisites”- A .NET 10 project referencing
Granit.Core Granit.Featuresfor feature definitions and resolutionGranit.Features.EntityFrameworkCorefor database-backed overrides (optional)
Step 1 — Install packages
Section titled “Step 1 — Install packages”# Core: definitions, resolution, ASP.NET Core integration, Wolverine middlewaredotnet add package Granit.Features
# EF Core persistence for tenant overrides (production)dotnet add package Granit.Features.EntityFrameworkCoreStep 2 — Register the module
Section titled “Step 2 — Register the module”[DependsOn(typeof(GranitFeaturesModule))]public sealed class AppModule : GranitModule { }Multi-tenancy is not required. If ICurrentTenant is not registered, the Tenant
level of the cascade is silently skipped.
[DependsOn(typeof(GranitFeaturesModule))][DependsOn(typeof(GranitFeaturesEntityFrameworkCoreModule))][DependsOn(typeof(GranitMultiTenancyModule))]public sealed class AppModule : GranitModule { }Step 3 — Define features
Section titled “Step 3 — Define features”Create a definition provider that declares all features for your application. Each feature has a name, a default value, and a value type.
using Granit.Features.Definitions;using Granit.Features.ValueTypes;
namespace MyApp.Features;
public sealed class AppFeatureDefinitionProvider : IFeatureDefinitionProvider{ public void Define(IFeatureDefinitionContext context) { var saas = context.AddGroup("App", "Application features");
// Toggle: on/off saas.AddFeature("App.VideoConsultation", defaultValue: "false", valueType: FeatureValueType.Toggle, displayName: "Video consultation");
// Numeric: integer quota with constraints saas.AddFeature("App.MaxPatients", defaultValue: "50", valueType: FeatureValueType.Numeric, displayName: "Patient quota", numericConstraint: new NumericConstraint(min: 0, max: 10_000));
// Selection: one value from a predefined list saas.AddFeature("App.Plan", defaultValue: "starter", valueType: FeatureValueType.Selection, displayName: "Commercial plan"); }}Register the provider at startup:
services.AddFeatureDefinitions<AppFeatureDefinitionProvider>();Step 4 — Check features in code
Section titled “Step 4 — Check features in code”Read feature values with IFeatureChecker
Section titled “Read feature values with IFeatureChecker”using Granit.Features.Checker;
namespace MyApp.Services;
public sealed class VideoConsultationService(IFeatureChecker featureChecker){ public async Task<bool> IsAvailableAsync( CancellationToken cancellationToken) => await featureChecker.IsEnabledAsync( "App.VideoConsultation", cancellationToken);
public async Task<long> GetPatientQuotaAsync( CancellationToken cancellationToken) => await featureChecker.GetNumericAsync( "App.MaxPatients", cancellationToken);}Guard numeric limits
Section titled “Guard numeric limits”IFeatureLimitGuard throws FeatureLimitExceededException when a count reaches
or exceeds the configured quota:
using Granit.Features.Limits;
namespace MyApp.Services;
public sealed class PatientService( IFeatureLimitGuard limitGuard, IPatientRepository repo){ public async Task CreateAsync( CreatePatientCommand cmd, CancellationToken cancellationToken) { var currentCount = await repo.CountAsync(cancellationToken); await limitGuard.GuardAsync( "App.MaxPatients", currentCount, cancellationToken); // Throws FeatureLimitExceededException if currentCount >= limit
await repo.AddAsync(cmd.ToEntity(), cancellationToken); }}Protect endpoints with [RequiresFeature]
Section titled “Protect endpoints with [RequiresFeature]”The [RequiresFeature] attribute returns HTTP 403 with a ProblemDetails response
(type: upgrade_required) when the feature is disabled:
[RequiresFeature("App.VideoConsultation")]app.MapPost("/video-sessions", CreateVideoSession);Protect Wolverine handlers
Section titled “Protect Wolverine handlers”The same attribute works on Wolverine message handlers. Messages are rejected before handler execution:
public sealed class CreateVideoSessionHandler{ [RequiresFeature("App.VideoConsultation")] public async Task Handle( CreateVideoSessionCommand cmd, CancellationToken cancellationToken) { // Only runs if the feature is enabled for the current tenant }}Step 5 — Understand the resolution cascade
Section titled “Step 5 — Understand the resolution cascade”Feature values are resolved through a three-level cascade. The first non-null value wins:
Tenant override (priority 100) | null -> next levelPlan value (priority 200) | null -> next levelDefault (priority 300) <- value declared in codeResults are cached by IHybridCache (L1 in-process + L2 Redis) and invalidated
via FeatureValueChangedEvent through Wolverine.
Step 6 — Implement Plan resolution (optional)
Section titled “Step 6 — Implement Plan resolution (optional)”To enable the Plan level in the cascade, provide two implementations:
using Granit.Features.Plans;
namespace MyApp.Features;
// Resolves the current tenant's plan identifierpublic sealed class AppPlanIdProvider( ICurrentTenant currentTenant, AppDbContext db) : IPlanIdProvider{ public async Task<string?> GetCurrentPlanIdAsync( CancellationToken cancellationToken) { if (!currentTenant.IsAvailable) { return null; }
var tenant = await db.Tenants.FindAsync( [currentTenant.Id], cancellationToken); return tenant?.PlanId; }}
// Retrieves feature values for a given planpublic sealed class AppPlanFeatureStore(AppDbContext db) : IPlanFeatureStore{ public async Task<string?> GetOrNullAsync( string planId, string featureName, CancellationToken cancellationToken) => await db.PlanFeatures .Where(f => f.PlanId == planId && f.FeatureName == featureName) .Select(f => f.Value) .FirstOrDefaultAsync(cancellationToken);}Register them in Program.cs:
services.AddSingleton<IPlanIdProvider, AppPlanIdProvider>();services.AddSingleton<IPlanFeatureStore, AppPlanFeatureStore>();Step 7 — Persist tenant overrides with EF Core
Section titled “Step 7 — Persist tenant overrides with EF Core”GranitFeaturesEntityFrameworkCoreModule replaces InMemoryFeatureStore with
EfCoreFeatureStore. Overrides are stored in the saas_feature_overrides table
with full ISO 27001 audit trail.
Create the migration:
dotnet ef migrations add AddFeatureOverrides --context FeaturesDbContextInvalidate cache on override changes
Section titled “Invalidate cache on override changes”After modifying a tenant override, publish FeatureValueChangedEvent to invalidate
the hybrid cache across all instances:
using Granit.Features.Events;
namespace MyApp.Features;
public sealed class OverrideTenantFeatureHandler( IFeatureStoreWriter store, IMessageBus bus){ public async Task Handle( OverrideTenantFeatureCommand cmd, CancellationToken cancellationToken) { await store.SetAsync( cmd.FeatureName, cmd.TenantId, cmd.Value, cancellationToken);
await bus.PublishAsync( new FeatureValueChangedEvent(cmd.TenantId, cmd.FeatureName), cancellationToken); }}Exceptions
Section titled “Exceptions”| Exception | Trigger |
|---|---|
FeatureNotEnabledException | IFeatureChecker.RequireEnabledAsync() — feature is disabled |
FeatureLimitExceededException | IFeatureLimitGuard.GuardAsync() — quota exceeded |
FeatureNotFoundException | IFeatureDefinitionStore.GetRequired() — unknown feature name |
FeatureValueValidationException | Value incompatible with the feature’s ValueType constraints |
Next steps
Section titled “Next steps”- Manage application settings — runtime settings with cascading resolution
- Settings and Features reference — full API and configuration details
- Create a module — build a module that uses feature checks