Skip to content

Feature Flags

The Feature Flags pattern enables activating or deactivating features at runtime without redeployment. Granit extends this pattern for SaaS tiering: feature resolution follows a multi-level cascade Tenant > Plan > Default, with a hybrid L1/L2 cache for performance.

Three feature types are supported:

  • Toggle: enabled/disabled (boolean)
  • Numeric: numeric value with min/max constraints (SaaS quotas)
  • Selection: value from an allowed set of choices
sequenceDiagram
    participant API as Endpoint / Handler
    participant FC as FeatureChecker
    participant HC as HybridCache (L1+L2)
    participant TVP as TenantValueProvider (20)
    participant PVP as PlanValueProvider (10)
    participant DVP as DefaultValueProvider (0)
    participant FS as IFeatureStore (DB)

    API->>FC: GetValueAsync("MaxUsers")
    FC->>HC: GetOrCreateAsync("t:{tid}:MaxUsers")
    alt Cache hit
        HC-->>FC: Cached value
    else Cache miss
        HC->>TVP: GetValueAsync(feature, tenantId)
        TVP->>FS: Read tenant override
        alt Override found
            FS-->>TVP: "500"
            TVP-->>HC: "500"
        else No override
            TVP-->>HC: null
            HC->>PVP: GetValueAsync(feature, planId)
            PVP-->>HC: "100" (plan value)
        end
        HC-->>HC: Store in L1 + L2
    end
    FC-->>API: "500" or "100"
ComponentFileRole
FeatureDefinitionsrc/Granit.Features/Definitions/FeatureDefinition.csName, default value, type, constraints
FeatureDefinitionProvidersrc/Granit.Features/Definitions/FeatureDefinitionProvider.csAbstract class to be implemented by the application
FeatureDefinitionStoresrc/Granit.Features/Definitions/FeatureDefinitionStore.csSingleton registry aggregating all providers
FeatureGroupDefinitionsrc/Granit.Features/Definitions/FeatureGroupDefinition.csLogical grouping of features
ProviderOrderFileSource
TenantFeatureValueProvider20src/Granit.Features/ValueProviders/TenantFeatureValueProvider.csIFeatureStore (DB)
PlanFeatureValueProvider10src/Granit.Features/ValueProviders/PlanFeatureValueProvider.csIPlanFeatureStore (application)
DefaultValueFeatureValueProvider0src/Granit.Features/ValueProviders/DefaultValueFeatureValueProvider.csFeatureDefinition.DefaultValue (code)
ComponentFileRole
FeatureCheckersrc/Granit.Features/Checker/FeatureChecker.csResolution orchestration + HybridCache
FeatureCacheKeysrc/Granit.Features/Cache/FeatureCacheKey.csFormat: t:{tenantId}:{featureName}
FeatureCacheInvalidationHandlersrc/Granit.Features/Cache/FeatureCacheInvalidationHandler.csListens to FeatureValueChangedEvent, purges cache
ComponentFileRole
IFeatureLimitGuardsrc/Granit.Features/Limits/IFeatureLimitGuard.csCheckAsync(feature, currentCount) — throws FeatureLimitExceededException
FeatureLimitGuardsrc/Granit.Features/Limits/FeatureLimitGuard.csImplementation
ComponentFileRole
[RequiresFeature]src/Granit.Features/AspNetCore/RequiresFeatureAttribute.csAttribute on actions/endpoints
RequiresFeatureFiltersrc/Granit.Features/AspNetCore/RequiresFeatureFilter.csIAsyncActionFilter for MVC
RequiresFeatureEndpointFiltersrc/Granit.Features/AspNetCore/RequiresFeatureEndpointFilter.csMinimal API filter
RequiresFeatureMiddlewaresrc/Granit.Features/Wolverine/RequiresFeatureMiddleware.csWolverine handler middleware
ProblemSolution
Different SaaS plans (Free/Pro/Enterprise) with different limitsNumeric features with NumericConstraint + FeatureLimitGuard
Per-tenant override without redeploymentTenantFeatureValueProvider reads overrides from DB
Performance: resolution must not query the DB on every requestHybridCache L1 (in-memory) + L2 (Redis) with event-driven invalidation
Multi-instance consistency: a feature change must be visible everywhereFeatureValueChangedEvent purges L1 and L2 cache via Wolverine
API protection: block access if the feature is disabled[RequiresFeature] on MVC, Minimal API, and Wolverine handlers
// 1. Define features (code-first)
public sealed class AcmeFeatureDefinitionProvider : FeatureDefinitionProvider
{
public override void Define(IFeatureDefinitionContext context)
{
FeatureGroupDefinition group = context.AddGroup("Acme");
group.AddFeature("Acme.MaxUsers",
defaultValue: "50",
valueType: FeatureValueType.Numeric,
numericConstraint: new NumericConstraint(Min: 1, Max: 10_000));
group.AddFeature("Acme.Telehealth",
defaultValue: "false",
valueType: FeatureValueType.Toggle);
}
}
// 2. Check in a handler
public static class CreatePatientHandler
{
public static async Task Handle(
CreatePatientCommand command,
IFeatureLimitGuard limitGuard,
IFeatureChecker features,
PatientDbContext db,
CancellationToken cancellationToken)
{
// Throws FeatureLimitExceededException if quota is reached
long currentCount = await db.Patients.CountAsync(ct);
await limitGuard.CheckAsync("Acme.MaxUsers", currentCount, ct);
// Check that a feature toggle is enabled
await features.RequireEnabledAsync("Acme.Telehealth", ct);
// Business logic...
}
}
// 3. Protect an endpoint
app.MapPost("/api/patients", CreatePatientEndpoint.Handle)
.RequiresFeature("Acme.MaxUsers");