diff --git a/.editorconfig b/.editorconfig
index 6db227b..109d27d 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -58,3 +58,6 @@ generated_code = true
[**/*Tests.cs]
dotnet_diagnostic.CA1707.severity = none
+# Test methods must not call ConfigureAwait (xUnit1030), so CA2007's "call
+# ConfigureAwait" would conflict — test projects conventionally disable CA2007.
+dotnet_diagnostic.CA2007.severity = none
diff --git a/AGENTS.md b/AGENTS.md
index b2ed2ae..ed75505 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -7,7 +7,7 @@
## Repository structure
- `Example.slnx` — XML-based solution referencing the `src/` and `tests/` projects.
-- `src/Example/` — the library project (`Example.csproj`) with `ExampleClass.cs`.
+- `src/Example/` — the library project (`Example.csproj`) with `ExampleClass.cs` and `FeatureFlags.cs` (the OpenFeature feature-flag scaffold; see *Feature flags*).
- `tests/Example.Tests/` — xUnit test project (`Example.Tests.csproj`, `ExampleClassTests.cs`) using `Microsoft.NET.Test.Sdk`, `coverlet.collector`, and `xunit.runner.visualstudio`.
- `.editorconfig` — formatting and analyzer rules enforced at build.
- `.github/workflows/` — `ci.yaml` (required-checks aggregation on PRs/merge queue), `validate-scaffold.yaml` (template-repo-only gate that exercises the scaffold-rename script — no-ops downstream), `publish.yaml` (publishes the NuGet library on `v*` tags via the reusable `publish-dotnet-library` workflow), `release.yaml`, `sync-labels.yaml`, `todos.yaml`, and `copilot-setup-steps.yml`.
@@ -24,6 +24,15 @@ dotnet test
Workflow YAML changes should pass `actionlint`.
+## Feature flags
+
+New features land **behind a flag, default-off**, are tested in **both** states, and are switched on only after validation — so deploy is decoupled from release and a rollback is a flag flip, not a redeploy. This is the portfolio-wide *feature-flag-first* standard ([devantler-tech/monorepo#2059](https://github.com/devantler-tech/monorepo/issues/2059)).
+
+- **Call sites use [OpenFeature](https://openfeature.dev/)** — the vendor-neutral flag API — via the `FeatureFlags` helper in `src/Example/FeatureFlags.cs`. Code paths evaluate flags through an `IFeatureClient`, never a provider directly, so the backing provider can change without touching them.
+- **The scaffold ships OpenFeature's built-in in-memory provider** (`FeatureFlags.CreateInMemoryProvider`) so the example evaluates with no external backend. Swap it for a real provider when you adopt the template — **[flagd](https://flagd.dev/)** for a GitOps/self-hosted definition source, a hosted service, or the `Microsoft.FeatureManagement` OpenFeature bridge once it ships **GA** (it is preview-only today, so the scaffold does not depend on it — the template avoids preview packages).
+- **Both states are tested.** `ExampleClassTests` exercises the example flag on, off, and unset (defaults off); mirror this for every flag. Give every flag-exercising test class the same `[Collection("FeatureFlags")]` attribute so xUnit runs them sequentially — otherwise `CreateClientAsync`'s process-wide provider registration races across xUnit's default cross-class parallelism.
+- **Flag lifecycle is mandatory.** Short-lived *release* flags are **removed after rollout** (flag debt is the #1 failure mode); long-lived *ops/permission* flags are the exception. Trivial/mechanical changes are exempt from flagging.
+
CI **does** verify the scaffold: a repository ruleset (*"Require workflows to pass before merging for .NET"*) injects the shared `run-dotnet-tests.yaml` reusable workflow on every PR and merge-queue entry, which builds and tests the solution across `ubuntu`/`windows`/`macos` (with the GitHub Code Quality coverage upload). The repo's own `ci.yaml` `CI - Required Checks` aggregator (empty `job-results`) is a *separate, trivially-passing* status check — **not** the .NET gate; publishing the NuGet library is handled separately by the reusable publish pipeline on `v*` tags. **Don't wire `run-dotnet-tests` into `ci.yaml`** — it already runs via the ruleset, so adding it would double-run every job. The local commands above are fast feedback, not a substitute for a check CI skips.
## Maintenance (autonomous AI assistant)
@@ -50,6 +59,7 @@ must update **every** copy in the same PR, with no straggler left to drift.
- **Triage** new issues/PRs (label; one insightful comment on the oldest un-commented item).
- **Dependency/toolchain hygiene:** curate Dependabot PRs; keep the toolchain version (.NET SDK) and pinned action versions current and aligned with the house workflows; flag majors.
- **CI/workflow health:** keep CI green and tidy (pin/align actions, fix broken/flaky steps, remove dead workflows); red on `main` is top priority.
-- **Scaffold freshness:** the generated project builds & tests on the current toolchain; README/badges accurate; example code idiomatic and minimal. The onboarding rename (`scripts/rename-placeholders.sh`) is pinned by `scripts/rename-placeholders.test.sh` (the `🧱 Validate Scaffold` gate) — keep them in lockstep when either changes.
+- **Scaffold freshness:** the generated project builds & tests on the current toolchain; README/badges accurate; example code idiomatic and minimal. The onboarding rename (`scripts/rename-placeholders.sh`) is pinned by `scripts/rename-placeholders.test.sh` (the `🧱 Validate Scaffold` gate) — keep them in lockstep when either changes (adding a placeholder `.cs` file means adding it to the rename script's substitutions).
+- **Feature-flag hygiene:** keep the OpenFeature scaffold current (see *Feature flags*) — new behaviour gated default-off + tested in both states; when the `Microsoft.FeatureManagement` OpenFeature bridge reaches GA, revisit adopting it as the provider.
- **Toolchain-floor freshness:** on any toolchain bump (or a new .NET GA), re-confirm the `global.json` SDK floor and the `net10.0` TFM still match the *Toolchain-floor policy* above — advance them in lockstep, only when forced — and that every copy of the framework/SDK version (both `*.csproj` and `global.json`) moved together with none left to drift.
- **Maintain your own PRs:** fix CI you caused, resolve conflicts.
\ No newline at end of file
diff --git a/README.md b/README.md
index 52e7f61..3cb8709 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ A minimal, batteries-included .NET template for new projects and libraries. Skip
- **Idiomatic scaffold** — an [`Example.slnx`](Example.slnx) solution wiring a library project ([`src/Example`](src/Example)) to a matching xUnit test project ([`tests/Example.Tests`](tests/Example.Tests)). A single documented member with tests shows the house testing pattern; [`scripts/rename-placeholders.sh`](scripts/rename-placeholders.sh) repoints the `Example` scaffold to your own project name in one shot.
- **House defaults** — every project builds with `Nullable`, `ImplicitUsings`, `AnalysisMode=All`, `EnforceCodeStyleInBuild`, XML documentation generation, and `TreatWarningsAsErrors` enabled, so code-style and analyzer findings are build errors, not warnings. Editor and analyzer rules live in [`.editorconfig`](.editorconfig).
+- **Feature-flag ready** — a portable [OpenFeature](https://openfeature.dev/) scaffold ([`src/Example/FeatureFlags.cs`](src/Example/FeatureFlags.cs)) so new features land **behind a flag, default-off**, are tested in both states, and are switched on only after validation. Ships an in-memory provider (no backend needed); swap in flagd or another provider without touching call sites. See [`AGENTS.md`](AGENTS.md#feature-flags).
- **CI/CD** — a required-checks workflow on pull requests and the merge queue ([`ci.yaml`](.github/workflows/ci.yaml)); the .NET build/test validation runs via an org-required reusable workflow enforced by branch rules (run `dotnet build` / `dotnet test` locally before a PR).
- **Releases & publishing** — merge [Conventional Commits](https://www.conventionalcommits.org/) to `main` and [semantic-release](https://github.com/semantic-release/semantic-release) cuts a `v*` tag and GitHub release ([`release.yaml`](.github/workflows/release.yaml)); that tag then publishes the library to NuGet via the shared [`publish-dotnet-library`](https://github.com/devantler-tech/reusable-workflows) workflow ([`publish.yaml`](.github/workflows/publish.yaml)).
- **Dependency management** — [Dependabot](https://docs.github.com/code-security/dependabot) keeps dependencies and pinned GitHub Actions current.
diff --git a/scripts/rename-placeholders.sh b/scripts/rename-placeholders.sh
index c5b78b9..9dd8e9a 100755
--- a/scripts/rename-placeholders.sh
+++ b/scripts/rename-placeholders.sh
@@ -124,6 +124,9 @@ subst() {
subst "$new_name.slnx"
subst "src/$new_name/$new_name.csproj"
subst "src/$new_name/${type_token}Class.cs"
+# FeatureFlags.cs has no `Example` in its filename, so the directory move above
+# carries it — but its contents (namespace, flag references) still need repointing.
+subst "src/$new_name/FeatureFlags.cs"
subst "tests/$new_name.Tests/$new_name.Tests.csproj"
subst "tests/$new_name.Tests/${type_token}ClassTests.cs"
subst "README.md"
diff --git a/scripts/rename-placeholders.test.sh b/scripts/rename-placeholders.test.sh
index ac005c8..461ef25 100755
--- a/scripts/rename-placeholders.test.sh
+++ b/scripts/rename-placeholders.test.sh
@@ -85,6 +85,7 @@ for p in \
"Widget.slnx" \
"src/Widget/Widget.csproj" \
"src/Widget/WidgetClass.cs" \
+ "src/Widget/FeatureFlags.cs" \
"tests/Widget.Tests/Widget.Tests.csproj" \
"tests/Widget.Tests/WidgetClassTests.cs"; do
[ -f "$p" ] || fail "expected renamed file missing: $p"
@@ -98,6 +99,8 @@ done
# both checked separately below, so scope this to *.slnx/*.csproj/*.cs).
grep -q "^namespace Widget;" "src/Widget/WidgetClass.cs" ||
fail "namespace not repointed in WidgetClass.cs"
+grep -q "^namespace Widget;" "src/Widget/FeatureFlags.cs" ||
+ fail "namespace not repointed in FeatureFlags.cs"
grep -qF 'Project Path="src/Widget/Widget.csproj"' "Widget.slnx" ||
fail "solution Project Path not repointed in Widget.slnx"
grep -qF '..\src\Widget\Widget.csproj' "tests/Widget.Tests/Widget.Tests.csproj" ||
diff --git a/src/Example/Example.csproj b/src/Example/Example.csproj
index 28fd760..33d4285 100644
--- a/src/Example/Example.csproj
+++ b/src/Example/Example.csproj
@@ -11,4 +11,15 @@
true
+
+
+
+
+
diff --git a/src/Example/FeatureFlags.cs b/src/Example/FeatureFlags.cs
new file mode 100644
index 0000000..de890c8
--- /dev/null
+++ b/src/Example/FeatureFlags.cs
@@ -0,0 +1,84 @@
+using OpenFeature;
+using OpenFeature.Providers.Memory;
+
+namespace Example;
+
+///
+/// Feature-flag scaffolding for the template. New features land behind a flag,
+/// default-off, are tested in both states, and are switched on only after
+/// validation (see AGENTS.md § Feature flags). Call sites use the portable,
+/// vendor-neutral OpenFeature API, so the
+/// backing provider can change without touching them. The scaffold ships OpenFeature's
+/// built-in in-memory provider so the example evaluates with no external backend —
+/// replace it with a real provider (flagd for GitOps, a hosted service, …) when you
+/// adopt the template.
+///
+public static class FeatureFlags
+{
+ ///
+ /// Key of the example feature flag. It is registered default-off and gates
+ /// ; replace it with your own flags.
+ ///
+ public const string ExampleFeature = "example-feature";
+
+ /// Variant key the in-memory provider resolves to (flag on).
+ public const string EnabledVariant = "on";
+
+ /// Variant key the in-memory provider resolves to (flag off).
+ public const string DisabledVariant = "off";
+
+ ///
+ /// Builds an OpenFeature in-memory provider seeding
+ /// default-off, so the scaffold evaluates flags with no external backend. Swap it for
+ /// a real provider (flagd, …) without changing any call site.
+ ///
+ /// An in-memory provider with the example flag registered default-off.
+ public static FeatureProvider CreateInMemoryProvider()
+ {
+ return new InMemoryProvider(new Dictionary(StringComparer.Ordinal)
+ {
+ [ExampleFeature] = new Flag(
+ new Dictionary(StringComparer.Ordinal)
+ {
+ [EnabledVariant] = true,
+ [DisabledVariant] = false,
+ },
+ DisabledVariant),
+ });
+ }
+
+ ///
+ /// Registers as the OpenFeature provider and returns a
+ /// client that call sites evaluate flags against.
+ ///
+ /// The provider to register (e.g. from ).
+ /// An OpenFeature client bound to the registered provider.
+ ///
+ /// Call this once during application startup: it re-registers OpenFeature's process-wide
+ /// default provider, and because clients resolve the current provider dynamically,
+ /// a later call silently redirects clients obtained earlier. If you need several independent
+ /// registrations, reach for OpenFeature's domain/named-client API instead of repeated calls.
+ ///
+ public static async Task CreateClientAsync(FeatureProvider provider)
+ {
+ ArgumentNullException.ThrowIfNull(provider);
+ await Api.Instance.SetProviderAsync(provider).ConfigureAwait(false);
+ return Api.Instance.GetClient();
+ }
+
+ ///
+ /// Example of gating behaviour on : returns the enhanced
+ /// message when the flag resolves on, and the default message when it is off or unset.
+ /// Replace it with your own flag-gated code path.
+ ///
+ /// The OpenFeature client to evaluate the flag with.
+ /// The flag-gated message.
+ public static async Task DescribeAsync(IFeatureClient featureClient)
+ {
+ ArgumentNullException.ThrowIfNull(featureClient);
+ bool enabled = await featureClient
+ .GetBooleanValueAsync(ExampleFeature, false)
+ .ConfigureAwait(false);
+ return enabled ? "Example feature is on." : "Example feature is off.";
+ }
+}
diff --git a/tests/Example.Tests/ExampleClassTests.cs b/tests/Example.Tests/ExampleClassTests.cs
index d6ac287..9615287 100644
--- a/tests/Example.Tests/ExampleClassTests.cs
+++ b/tests/Example.Tests/ExampleClassTests.cs
@@ -1,8 +1,20 @@
+using OpenFeature;
+using OpenFeature.Providers.Memory;
+
namespace Example.Tests;
///
/// Tests for the class.
///
+///
+/// Joins the shared "FeatureFlags" xUnit collection.
+/// re-registers the
+/// process-wide Api.Instance provider, and xUnit runs different test classes in
+/// parallel by default — so give every flag-exercising test class the same
+/// [Collection("FeatureFlags")] attribute and xUnit runs them sequentially,
+/// keeping their provider registrations from interleaving.
+///
+[Collection("FeatureFlags")]
public class ExampleClassTests
{
///
@@ -38,4 +50,77 @@ public void Add_NegativeOperand_ReturnsTheirSum()
// Assert
Assert.Equal(6, sum);
}
+
+ ///
+ /// Verifies takes the OFF
+ /// branch when the example flag is registered default-off (the scaffold default).
+ ///
+ [Fact]
+ public async Task DescribeAsync_FlagDefaultOff_ReturnsOffMessage()
+ {
+ // Arrange
+ var client = await FeatureFlags.CreateClientAsync(FeatureFlags.CreateInMemoryProvider());
+
+ // Act
+ string result = await FeatureFlags.DescribeAsync(client);
+
+ // Assert
+ Assert.Contains("is off", result, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Verifies takes the ON
+ /// branch when the example flag resolves on.
+ ///
+ [Fact]
+ public async Task DescribeAsync_FlagOn_ReturnsOnMessage()
+ {
+ // Arrange
+ var client = await FeatureFlags.CreateClientAsync(BuildExampleProvider(enabled: true));
+
+ // Act
+ string result = await FeatureFlags.DescribeAsync(client);
+
+ // Assert
+ Assert.Contains("is on", result, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Verifies an unset flag falls back to the OFF default, so a missing flag definition
+ /// never silently turns a feature on.
+ ///
+ [Fact]
+ public async Task DescribeAsync_FlagUnset_DefaultsToOff()
+ {
+ // Arrange
+ var provider = new InMemoryProvider(new Dictionary(StringComparer.Ordinal));
+ var client = await FeatureFlags.CreateClientAsync(provider);
+
+ // Act
+ string result = await FeatureFlags.DescribeAsync(client);
+
+ // Assert
+ Assert.Contains("is off", result, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Builds an in-memory provider that resolves
+ /// to , so both flag states can be exercised.
+ ///
+ /// Whether the example flag should resolve on.
+ /// An in-memory provider resolving the example flag to .
+ static InMemoryProvider BuildExampleProvider(bool enabled)
+ {
+ string variant = enabled ? FeatureFlags.EnabledVariant : FeatureFlags.DisabledVariant;
+ return new InMemoryProvider(new Dictionary(StringComparer.Ordinal)
+ {
+ [FeatureFlags.ExampleFeature] = new Flag(
+ new Dictionary(StringComparer.Ordinal)
+ {
+ [FeatureFlags.EnabledVariant] = true,
+ [FeatureFlags.DisabledVariant] = false,
+ },
+ variant),
+ });
+ }
}