Skip to content
fullstackhero

Concept

Feature flags

Microsoft.FeatureManagement with a custom TenantFeatureFilter so flags can be enabled per tenant.

views 0 Last updated

Microsoft.FeatureManagement.AspNetCore is the kit’s feature-flag system. The kit ships a custom TenantFeatureFilter so flags can be enabled for specific tenants — useful for staged rollouts, beta cohorts, kill switches per customer, and per-environment toggles without code changes.

Opt in

builder.AddHeroPlatform(o => o.EnableFeatureFlags = true);

AddHeroPlatform registers Microsoft.FeatureManagement services plus TenantFeatureFilter so per-tenant evaluation works.

Declaring flags

Flags live in appsettings.json under FeatureManagement. Three example shapes:

{
"FeatureManagement": {
// 1. simple on/off
"BetaCheckoutFlow": true,
// 2. tenant-scoped — only enabled for listed tenants
"BetaProductImagesV2": {
"EnabledFor": [
{ "Name": "Tenant", "Parameters": { "Tenants": ["acme", "globex"] } }
]
},
// 3. multi-filter (tenant + percentage rollout)
"NewSearchUi": {
"EnabledFor": [
{ "Name": "Tenant", "Parameters": { "Tenants": ["acme"] } },
{ "Name": "Percentage", "Parameters": { "Value": 10 } }
]
}
}
}

The Tenant filter is the kit’s TenantFeatureFilter; the Percentage filter is built into Microsoft.FeatureManagement. You can stack filters — they OR together by default (any filter passing enables the flag).

Checking a flag

Inject IFeatureManager and call IsEnabledAsync:

public sealed class GetProductByIdQueryHandler(
ICatalogDbContext db,
IFeatureManager features,
IImageEnricher images)
: IQueryHandler<GetProductByIdQuery, ProductResponse>
{
public async ValueTask<ProductResponse> Handle(GetProductByIdQuery q, CancellationToken ct)
{
var product = await db.Products.FindAsync([q.ProductId], ct).ConfigureAwait(false);
if (await features.IsEnabledAsync("BetaProductImagesV2").ConfigureAwait(false))
return await images.EnrichV2Async(product, ct).ConfigureAwait(false);
return ProductResponse.From(product);
}
}

The check resolves the current tenant context (via ICurrentUser.GetTenant()) and consults the registered filters. No flag definition? IsEnabledAsync returns false.

Gating endpoints

For all-or-nothing endpoint gating, use the [FeatureGate] attribute:

endpoints.MapGet("/catalog/products/recommended", handler)
.RequirePermission(perm)
.WithMetadata(new FeatureGateAttribute("RecommendationsEndpoint"));

When the flag is off, the endpoint returns 404 (or whichever fallback the gate is configured with). Use this for shipping new endpoints behind a flag without exposing them broadly.

Common patterns

Staged rollout

Enable for one tenant first, then add more once you’re confident:

{
"FeatureManagement": {
"NewBilling": {
"EnabledFor": [
{ "Name": "Tenant", "Parameters": { "Tenants": ["test-tenant"] } }
]
}
}
}

Re-deploy with more tenants in the array as confidence grows; eventually drop the filter and set the flag to true globally.

Kill switch

{ "FeatureManagement": { "ChatRealtimeEnabled": true } }

Set to false in an incident to instantly disable the SignalR push path without redeploying. Have the handler fall back to polling, or return a graceful “feature temporarily unavailable” response.

Per-environment

appsettings.Production.json overrides appsettings.json. Set flags to false for production until they ship:

appsettings.Production.json
{
"FeatureManagement": {
"BetaCheckoutFlow": false
}
}

Where flag state lives

In configuration. The kit doesn’t ship a database-backed flag store. That’s deliberate: editing JSON config is cheap, auditable through git history, and doesn’t require a separate management UI.

If you outgrow JSON — you need runtime flag toggles without redeploy, or per-user (not per-tenant) flags — wire a custom IFeatureDefinitionProvider against your store of choice (Redis, EF Core, ConfigCat, LaunchDarkly). The kit’s TenantFeatureFilter pattern is the template for custom filters.

Removing a flag

When a flag has been on globally for a while and the alternative is gone:

  1. Delete the flag definition from appsettings.json.
  2. Remove the IFeatureManager.IsEnabledAsync call in the handler — collapse the conditional to the always-on branch.
  3. Delete the dead alternative path.

Don’t skip step 3. Lingering “old” branches are technical debt and a security risk (un-tested code that might still be reachable).