Skip to content

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.

  • A .NET 10 project referencing Granit.Core
  • Granit.Features for feature definitions and resolution
  • Granit.Features.EntityFrameworkCore for database-backed overrides (optional)
Terminal window
# Core: definitions, resolution, ASP.NET Core integration, Wolverine middleware
dotnet add package Granit.Features
# EF Core persistence for tenant overrides (production)
dotnet add package Granit.Features.EntityFrameworkCore
[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.

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>();
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);
}

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);
}
}

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);

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 level
Plan value (priority 200)
| null -> next level
Default (priority 300) <- value declared in code

Results 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 identifier
public 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 plan
public 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:

Terminal window
dotnet ef migrations add AddFeatureOverrides --context FeaturesDbContext

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);
}
}
ExceptionTrigger
FeatureNotEnabledExceptionIFeatureChecker.RequireEnabledAsync() — feature is disabled
FeatureLimitExceededExceptionIFeatureLimitGuard.GuardAsync() — quota exceeded
FeatureNotFoundExceptionIFeatureDefinitionStore.GetRequired() — unknown feature name
FeatureValueValidationExceptionValue incompatible with the feature’s ValueType constraints