---
title: Feature Flags
description: Configure feature flags and consume them in Forge applications.
moved_from:
  - guides/development/feature-flags.md
---

# 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`.

!!! warning "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](../eventing/servicebus-queue.md)) 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:

```csharp title="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`:

```csharp title="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)](https://dev.azure.com/SAIFCorporation/SAIF/_git/ARB?path=/proposals/approved/PROP0007-Feature-Flags/PROP0007-Feature-Flags.md&version=GBmain).

!!! note "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](#deployment-sync-appsettingsjson-azure-app-configuration) 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`.

!!! warning "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.

!!! warning "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](https://github.com/microsoft/FeatureManagement/blob/main/Schema/FeatureManagement.v2.0.0.schema.json):

```json title="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](#deployment-sync-appsettingsjson-azure-app-configuration) 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).

!!! info "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](https://learn.microsoft.com/en-us/azure/azure-app-configuration/enable-dynamic-configuration-aspnet-core#request-driven-configuration-refresh) 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](https://github.com/microsoft/FeatureManagement/blob/main/Schema/FeatureFlag.v2.0.0.schema.json) — 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:

```csharp title="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:

```csharp title="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

```csharp title="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:

```json title="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:

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

Usage in a component:

```tsx title="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:

```csharp title="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:

```csharp title="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`:

```typescript title="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](#deployment-sync-appsettingsjson-azure-app-configuration) 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](#deployment-sync-appsettingsjson-azure-app-configuration)) 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](#deployment-sync-appsettingsjson-azure-app-configuration)) 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](https://learn.microsoft.com/en-us/azure/azure-app-configuration/howto-feature-filters-aspnet-core).
- **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

- [ARB Feature Flag Standard (PROP0007)](https://dev.azure.com/SAIFCorporation/SAIF/_git/ARB?path=/proposals/approved/PROP0007-Feature-Flags/PROP0007-Feature-Flags.md&version=GBmain) — naming conventions, lifecycle management, governance, and compliance requirements
- [Microsoft Feature Management v2 schema](https://github.com/microsoft/FeatureManagement/blob/main/Schema/FeatureManagement.v2.0.0.schema.json) — top-level `feature_management.feature_flags` document schema
- [Feature Flag v2 schema](https://github.com/microsoft/FeatureManagement/blob/main/Schema/FeatureFlag.v2.0.0.schema.json) — individual flag schema (`id`, `enabled`, `conditions`, `variants`, `allocation`, `telemetry`)
- [Microsoft.FeatureManagement overview](https://learn.microsoft.com/en-us/azure/azure-app-configuration/use-feature-flags-dotnet-core)
- [Azure App Configuration — feature flag management](https://learn.microsoft.com/en-us/azure/azure-app-configuration/manage-feature-flags)
- [Feature filters (percentage, time window, targeting)](https://learn.microsoft.com/en-us/azure/azure-app-configuration/howto-feature-filters-aspnet-core)
- [Azure App Configuration refresh](https://learn.microsoft.com/en-us/azure/azure-app-configuration/enable-dynamic-configuration-aspnet-core)
- [Import/export App Configuration data](https://learn.microsoft.com/en-us/azure/azure-app-configuration/howto-import-export-data) — bulk import used by the sync pipeline
