Skip to content

Feature Flags

This guide explains how to add feature flags to your application using the SAIF platform.


๐Ÿ“‹ Overview

Feature flags let you enable or disable functionality at runtime without redeploying your application. The platform supports two modes:

Mode Flag source Refresh Use case
Local / no App Config appsettings.json / appsettings.Development.json Hot-reload on file change Local development, apps without App Configuration
Azure App Configuration Azure App Configuration store Request-driven refresh (checks every 5 min) Deployed environments

Both modes use the same IFeatureManager interface from Microsoft.FeatureManagement โ€” your application code doesn't change between them. When AppConfigurationEndpoint is not configured, the platform falls back to reading flags from appsettings.json automatically.

Feature flags are an API-side concern โ€” the AppHost and frontend projects do not require any changes.


โš™๏ธ Setup

1. Registration

AddAzureDefaults() (from SAIF.Platform.Azure) registers everything you need:

  • AddFeatureManagement() โ€” the .NET feature management system
  • Azure App Configuration provider (when AppConfigurationEndpoint is configured)

No additional service registration is needed in Program.cs.

Default configuration prefixes include ConnectionStrings:*

AddAzureDefaults() doesn't only load feature flags from Azure App Configuration โ€” it also selects regular configuration keys under a fixed set of default prefixes, including ConnectionStrings:* (alongside team:*, SAIF:*, services:*, and OTEL_*). This means connection strings published centrally to App Configuration (e.g. ConnectionStrings:messaging, used by the Service Bus Queue guide) are loaded automatically; no extra registration needed.

If your app supplies a custom AzureConfigurationOptions.Prefixes list (overriding the defaults instead of extending them), you must include ConnectionStrings:* in that list yourself, or connection strings published to App Configuration will silently fail to resolve.

2. Enable refresh middleware

Call UseAzureDefaults() after building the app to enable automatic refresh of feature flags from Azure App Configuration:

Program.cs
var builder = WebApplication.CreateBuilder(args);

builder
    .AddAzureDefaults()
    .AddServiceDefaults();

var app = builder.Build();

app.UseAzureDefaults(); // Enables App Configuration refresh middleware

This middleware calls TryRefreshAsync() on each HTTP request, which re-fetches flags from App Configuration when the refresh interval has elapsed. Without it, flag changes in App Configuration are never picked up.

UseAzureDefaults() is safe to call in all environments โ€” it's a no-op when AppConfigurationEndpoint is not configured.

3. Customize refresh interval (optional)

The default refresh interval is 5 minutes. Override it via AddAzureDefaults:

Program.cs
builder.AddAzureDefaults(o => o.FeatureFlagRefreshInterval = TimeSpan.FromSeconds(30));

4. No AppHost changes required

Feature flag management is handled entirely within the API project. The Aspire AppHost and any frontend projects do not need modifications โ€” the AppHost orchestrates the application as usual, and the frontend consumes flag-gated API endpoints like any other endpoint.


๐Ÿšฉ Defining flags

Flag naming convention

For naming conventions, scoping rules, and lifecycle guidance, see the ARB Feature Flag Standard (PROP0007).

Separator character

The ARB standard uses : as the separator (e.g., claims-api:payments:new-processor), but Azure App Configuration feature flag keys do not support :. Use . as a substitute when following the standard naming format.

Project isolation in Azure App Configuration is handled via labels rather than key prefixes:

Label Purpose
{projectId} (e.g., my-app) Base defaults for this project
{projectId}/{environment} (e.g., my-app/test) Environment-specific overrides

This scoping prevents collisions across applications that share the same App Configuration store.

Flag lifecycle

The ARB standard defines three flag categories with different lifecycle expectations:

Category Lifetime Example Cleanup
Release flags Short (days to weeks) ExpressCheckout Remove after 100% rollout
Experiment flags Medium (weeks to months) BlueVsGreenButton Remove after experiment concludes
Operational flags Permanent PaymentProcessingEnabled Maintain indefinitely (circuit breakers, kill switches)

Expiration policy: All release and experiment flags must have expiration dates (default: 90 days). Review active flags quarterly to prevent stale flag accumulation.

Removal process:

  1. Disable the flag โ€” set to its final intended state
  2. Remove flag checks from code
  3. Deploy code changes
  4. Remove the flag entry from appsettings.json / appsettings.{env}.json. If the project's deployment pipeline has feature flag sync enabled (see Deployment sync below), the next deployment automatically deletes the corresponding flag from Azure App Configuration โ€” no manual az appconfig cleanup is needed. If sync is not enabled, or the flag was created directly in the store outside of source, delete it manually with az appconfig feature delete.

Don't delete config before code

Do not delete flags from App Configuration before removing the code that references them. Older code versions may still reference the flag, causing runtime errors.

Removing the whole feature_management block skips cleanup

The sync pipeline only deletes orphaned flags for a source file it successfully loaded a feature_management.feature_flags block from. If you delete the entire block (or the file) instead of leaving an empty feature_flags: [] array, the pipeline has nothing to compare against and any flags previously synced under that label are left behind in App Configuration. To retire the last flag in a file, leave the block in place with an empty array so cleanup still runs.

Local development (appsettings.json)

Add flags to appsettings.json or appsettings.Development.json using the v2 feature management schema:

appsettings.json
{
  "feature_management": {
    "feature_flags": [
      { "id": "ExampleFeature", "enabled": true },
      { "id": "GatedEndpoint", "enabled": false }
    ]
  }
}

Changes to these files are hot-reloaded automatically โ€” toggle a flag value, save, and the next request picks up the change without restarting the application.

This also works in deployed containers that don't use App Configuration at all โ€” i.e. AppConfigurationEndpoint is never set for that app. That's a distinct scenario from the one below, where App Configuration is configured and these same files are the deployment-time source that populates it.

Azure App Configuration

In deployed environments with AppConfigurationEndpoint configured, flags are sourced from Azure App Configuration. The platform loads flags using label-based precedence:

  1. Label {projectId} โ€” base defaults (loaded first)
  2. Label {projectId}/{environmentLabelSuffix} โ€” environment-specific overrides, where {environmentLabelSuffix} is the environment's short name (test, qa, uat, prod) when the project defines one, otherwise the full environment name โ€” see Deployment sync below for exactly how this is derived. This must match builder.Environment.EnvironmentName at runtime for the override to actually be picked up.

For example, a flag ExampleFeature with label my-app set to true can be overridden to false for the test environment by creating the same flag with label my-app/test (assuming test is the environment's short name).

Refresh latency

Flag changes in App Configuration are not instant. The middleware checks for updates on a configurable interval (default: 5 minutes, can be overridden in .AddAzureDefaults()). When the interval expires, the next incoming request triggers a background refresh but still serves cached values โ€” the request after that sees the updated values. This is a known characteristic of the Azure App Configuration middleware's non-blocking refresh model.

Deployment sync (appsettings.json โ†’ Azure App Configuration)

Azure App Configuration isn't populated by hand. A sync-feature-flags deployment pipeline activity reads the same appsettings.json / appsettings.{env}.json files described above and bulk-imports their feature_management blocks into the target App Configuration store, then deletes any previously-synced flags no longer present in source. Concretely:

  • Enabled automatically for saif-feature-api projects: the golden-path pipeline (azure-pipelines-api.yml โ†’ azure-dotnet-api-v3.yml โ†’ the azure-app-orchestrator-v3.yml orchestrator's unconditional feature_flags deployment item) already wires up sync for every scaffolded API app. There's no config block or pipeline parameter for you to add โ€” just keep your flags in appsettings.json as shown above and they sync on deploy.
  • config.featureFlags is the underlying primitive: if you're building a custom pipeline directly against the shared v2/deployments/azure/deployment.yml template (outside the saif-feature-api golden path), the activity only runs when your pipeline config sets featureFlags (e.g. featureFlags: {} to use the defaults). Defining flags in appsettings.json alone does not enable syncing in that scenario.
  • Source location: for saif-feature-api projects, the orchestrator points the activity at src/{ApplicationName}/appsettings.json and src/{ApplicationName}/appsettings.{env}.json (ApplicationName is the .NET project name, not ProjectId). For a custom pipeline using the primitive directly, the activity's own default (when config.featureFlags.dir/.file aren't set) is src/{projectId}/appsettings.json; override the directory with config.featureFlags.dir and the filename stem (without .json) with config.featureFlags.file.
  • Env override file matching: the pipeline matches any file named {file}.{suffix}.json where {suffix} case-insensitively equals either the environment's full name or its short name โ€” not the ASP.NET Core appsettings.{EnvironmentName}.json convention alone. If more than one file matches, only the first one found is used.
  • Labels: the base file is imported under label {projectId}; the env-override file is imported under {projectId}/{shortName} if the project has a short name configured, otherwise {projectId}/{environment} โ€” independent of which filename suffix actually matched.
  • Requires an existing App Configuration endpoint: the activity looks up AppConfigurationEndpoint from the target web app's app settings and fails the deployment if it's missing. Enabling featureFlags sync for an app that isn't provisioned with App Configuration will fail the deploy rather than silently falling back to local JSON.
  • Validation is structural, not full v2 schema: the pipeline checks that feature_management is an object with a feature_flags array and fails the build (exit code 1) if not, but it does not validate individual flag fields (id, enabled, conditions, variants) against the full v2 flag schema โ€” malformed individual flags can still be imported.
  • Preview: pipeline preview runs invoke the same activity in preview mode, which lists the flags that would be imported and the orphaned flags that would be deleted, without applying either โ€” useful for reviewing a flag change before it reaches App Configuration.

Infrastructure provisioning for the App Configuration store itself (creating the resource, granting the pipeline identity access) is covered separately in your project's Terraform.


๐Ÿ› ๏ธ Using flags in endpoints

Inject IFeatureManager into your endpoint handlers.

IsEnabledAsync โ€” inline check

Inject IFeatureManager and check the flag manually. Use this when you need to branch logic inside a handler based on flag state:

Program.cs
app.MapGet("/api/features", async (IFeatureManager featureManager) =>
{
    var enabled = await featureManager.IsEnabledAsync("ExampleFeature");
    return new { ExampleFeature = enabled };
});

This gives you full control โ€” you can return different responses, log the flag state, or combine multiple flags to decide what to do.

Conditional endpoint gating

Use IsEnabledAsync to make an endpoint return 404 Not Found when a flag is off:

Program.cs
app.MapGet("/api/hello/beta", async (IFeatureManager featureManager) =>
{
    if (!await featureManager.IsEnabledAsync("GatedEndpoint"))
        return Results.NotFound();
    return Results.Ok(new { Message = "Hello from the beta endpoint!" });
});

IFeatureManager vs IVariantFeatureManager

Interface When to use
IFeatureManager Recommended for most apps. Simple IsEnabledAsync(name) checks
IVariantFeatureManager When you need variant/targeting feature management from Microsoft.FeatureManagement

๐Ÿ–ฅ๏ธ Frontend considerations

The frontend does not need a feature management library. Instead, it consumes flag state through your API:

  1. Create an API endpoint that returns flag states using IFeatureManager.IsEnabledAsync
  2. Call that endpoint from the frontend
  3. Use the response to conditionally render UI elements
Program.cs โ€” API endpoint exposing flag state
app.MapGet("/api/features", async (IFeatureManager featureManager) =>
{
    return new
    {
        GatedEndpoint = await featureManager.IsEnabledAsync("GatedEndpoint"),
        NewDashboard = await featureManager.IsEnabledAsync("NewDashboard")
    };
});

The frontend fetches /api/features and uses the boolean values to show or hide UI. This keeps flag management centralized in the API and avoids duplicating flag logic in the frontend.

useFeatureFlags hook (React / TypeScript)

The useFeatureFlags hook is available in @saif/platform-react. Projects consuming it from the Azure Artifacts feed use:

package.json
{
  "dependencies": {
    "@saif/platform-react": "^3.0.0"
  }
}

Then import the hook, as it encapsulates the logic for fetching and exposing flag states from the API:

import { useFeatureFlags } from '@saif/platform-react';

Usage in a component:

pages/Home.tsx
import { useFeatureFlags } from "@saif/platform-react";

const Home = () => {
  const apiUrl = import.meta.env.VITE_BACKEND_URL || "";
  const { loading, error, isEnabled } = useFeatureFlags(apiUrl);

  if (loading) return <p>Loading flags...</p>;
  if (error) return <p>Error: {error}</p>;

  return (
    <div>
      {isEnabled("newDashboard") && <NewDashboard />}
      <p>Gated Endpoint: {isEnabled("gatedEndpoint") ? "ON" : "OFF"}</p>
    </div>
  );
};

The hook returns false for unknown flag names, so components degrade gracefully when a flag hasn't been defined yet.


๐Ÿงช Testing

.NET unit tests

Test flag-dependent logic by mocking IFeatureManager with NSubstitute:

FeatureFlagTests.cs
[Fact]
public async Task IsEnabledAsync_ReturnsTrue_WhenFlagIsEnabled()
{
    var featureManager = Substitute.For<IFeatureManager>();
    featureManager.IsEnabledAsync("ExampleFeature").Returns(true);

    var result = await featureManager.IsEnabledAsync("ExampleFeature");

    result.Should().BeTrue();
}

For integration tests, configure flags in-memory โ€” flag names work directly with IFeatureManager when no AppConfigurationEndpoint is set:

FeatureFlagTests.cs
[Fact]
public async Task FeatureManager_ReadsFromInMemoryConfiguration()
{
    var config = new ConfigurationBuilder()
        .AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["feature_management:feature_flags:0:id"] = "ExampleFeature",
            ["feature_management:feature_flags:0:enabled"] = "true",
            ["feature_management:feature_flags:1:id"] = "GatedEndpoint",
            ["feature_management:feature_flags:1:enabled"] = "false",
        })
        .Build();

    var services = new ServiceCollection();
    services.AddSingleton<IConfiguration>(config);
    services.AddFeatureManagement();

    var provider = services.BuildServiceProvider();
    var featureManager = provider.GetRequiredService<IFeatureManager>();

    (await featureManager.IsEnabledAsync("ExampleFeature")).Should().BeTrue();
    (await featureManager.IsEnabledAsync("GatedEndpoint")).Should().BeFalse();
}

Frontend tests (Vitest)

Test the useFeatureFlags hook by mocking fetch:

src/typescript/packages/saif-platform-react/test/useFeatureFlags.test.ts
import { renderHook, waitFor } from "@testing-library/react";
import { useFeatureFlags } from "@saif/platform-react";

it("returns flags from the API", async () => {
  vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
    ok: true,
    json: async () => ({ newDashboard: true, gatedEndpoint: false }),
  } as Response);

  const { result } = renderHook(() =>
    useFeatureFlags("http://localhost:5000")
  );

  await waitFor(() => expect(result.current.loading).toBe(false));

  expect(result.current.isEnabled("newDashboard")).toBe(true);
  expect(result.current.isEnabled("gatedEndpoint")).toBe(false);
  expect(result.current.isEnabled("unknownFlag")).toBe(false);
});

๐Ÿ’ก Tips

  • Always call UseAzureDefaults(): Without it, flag changes in App Configuration are never refreshed. It's safe to call even without App Configuration.
  • Labels handle isolation: In App Configuration, project and environment scoping is done via labels ({projectId}, {projectId}/{shortName-or-environment}), not key names โ€” see Deployment sync for exactly how the env label is derived.
  • Track flag lifecycle: Assign a category (release, experiment, operational), set expiration dates for temporary flags, and remove flags after full rollout.
  • Hot reload: During local development, flag changes in appsettings.json take effect on the next request โ€” no restart needed.
  • appsettings.json fallback: Apps with no AppConfigurationEndpoint at all automatically read flags from appsettings.json in both local development and deployed containers. Apps that do have App Configuration are different: AddAzureAppConfiguration() adds App Configuration on top of the existing appsettings.json sources rather than replacing them, but the feature_management.feature_flags array is merged by array index position across providers, not by flag id โ€” so whether a JSON-only flag happens to "show through" depends on array ordering coincidence, not on whether that flag is present in App Configuration. Don't rely on this: once App Configuration is enabled, treat it (kept in sync by the deployment pipeline, see Deployment sync) as the effective source, and don't expect a flag added only to appsettings.json to reliably appear in a deployed app until it's synced.
  • Deployment sync needs an App Configuration endpoint: for saif-feature-api projects, sync is wired up automatically (see Deployment sync) but still requires the target app to already have an App Configuration endpoint provisioned; for custom pipelines built on the shared primitive, the pipeline config must also set featureFlags to enable it.
  • Feature filters: For more advanced scenarios (percentage rollout, time windows, targeting), see the feature filters documentation.
  • Keep flags short-lived: Feature flags are meant for rollout control, not permanent configuration. Remove flags and their gating code once a feature is fully rolled out.

๐Ÿ” Foundry example

A complete working example is available in the Forge foundry:

foundry/dotnet/feature-flags/

This includes:

  • API โ€” IFeatureManager usage with IsEnabledAsync in Program.cs, UseAzureDefaults() middleware
  • Frontend โ€” useFeatureFlags hook from @saif/platform-react with React usage in Home.tsx
  • .NET unit tests โ€” mock and in-memory configuration tests in feature-flags.UnitTests/
  • Frontend tests โ€” Vitest specs for useFeatureFlags in the @saif/platform-react package

Run it with aspire run to see feature flags in action with a React frontend that displays flag state and calls gated endpoints. Flags are read from appsettings.json in local development.


๐Ÿ“š Resources