GM.FeatureManagement.Mediator
1.0.0
dotnet add package GM.FeatureManagement.Mediator --version 1.0.0
NuGet\Install-Package GM.FeatureManagement.Mediator -Version 1.0.0
<PackageReference Include="GM.FeatureManagement.Mediator" Version="1.0.0" />
<PackageVersion Include="GM.FeatureManagement.Mediator" Version="1.0.0" />
<PackageReference Include="GM.FeatureManagement.Mediator" />
paket add GM.FeatureManagement.Mediator --version 1.0.0
#r "nuget: GM.FeatureManagement.Mediator, 1.0.0"
#:package GM.FeatureManagement.Mediator@1.0.0
#addin nuget:?package=GM.FeatureManagement.Mediator&version=1.0.0
#tool nuget:?package=GM.FeatureManagement.Mediator&version=1.0.0
GM.FeatureManagement
Feature flags for the GM.* ecosystem — provider-agnostic, cache-backed, with targeting built for
gradual rollouts and environment gating.
consumer ──▶ IFeatureManager ──▶ (GM.Caching, TTL) ──▶ IFeatureDefinitionProvider ──▶ flag source
IsEnabled/GetVariant fast, consistent pluggable appsettings │ EF │ SaaS
| Package | Purpose | Status |
|---|---|---|
| GM.FeatureManagement | IFeatureManager, targeting, cache-backed evaluation, appsettings provider |
✅ built |
| GM.FeatureManagement.AspNetCore | [FeatureGate("Key")] + .RequireFeature("Key") for MVC / minimal-API endpoints |
✅ built |
| GM.FeatureManagement.Mediator | Pipeline behavior gating command/query handlers behind a flag | ✅ built |
Core concepts
IFeatureManager— the whole consumer surface:IsEnabledAsync(key, context?)— is this feature on for this subject?GetVariantAsync(key, context?)— which arm of an A/B test / rollout did this subject get?
FeatureContext— who/what you're evaluating for:UserId,TenantId, an explicitTargetingKey, and free-formAttributes. TheTargetingId(key → user → tenant) is what percentage and variant assignment hash on, so a subject is sticky — same bucket every time, on every instance.IFeatureDefinitionProvider— where flags live, decoupled from evaluation. The default reads appsettings; swap in another implementation to move flags to a hosted service without changing consumer code (see Swapping the source).FeatureDefinition— the declarative flag (below).
Evaluation order
IsEnabledAsync applies a definition's rules in a fixed order — first decisive rule wins:
- Master switch (
Enabled: false) → off. A kill switch that beats everything. - Environment gate (
EnabledEnvironments) → off if the current env isn't listed (empty = all). - Exclusions (
ExcludedUsers/ExcludedTenants) → off. - Inclusions (
TargetUsers/TargetTenants) → on, bypassing the percentage gate. - Percentage rollout (
RolloutPercentage) → on only if the subject's stable bucket falls under the percentage. NoTargetingId(anonymous) → conservatively off. - Otherwise on — and
GetVariantAsyncassigns a weighted variant if any are declared.
Bucketing uses a stable SHA-256 hash, not string.GetHashCode() (which is randomised per
process), so rollouts and variant assignments are identical across every replica and every restart.
Registration
Expects an ICacheService — call AddGMCaching() (in-memory) or, for cross-instance
consistency, AddGMRedisCaching() first. Then, fluent:
builder.Services.AddGMCaching();
builder.Services.AddGMFeatureManagement(o =>
{
o.CacheTtl = TimeSpan.FromSeconds(30); // change propagation window vs. store load
// Gradual KYC vendor switch: shift 25% of users from Didit to Identomat.
o.Features["KycVendorSwitch"] = new FeatureDefinition
{
Enabled = true,
Variants =
[
new() { Name = "Didit", Weight = 75 },
new() { Name = "Identomat", Weight = 25, Configuration = { ["endpoint"] = "https://…" } },
],
};
// Environment-gate an in-progress feature to non-prod until it's stable.
o.Features["NewPayments"] = new FeatureDefinition
{
Enabled = true,
EnabledEnvironments = ["Development", "Staging"],
};
});
…or from appsettings (FeatureManagement section binds the same shape):
builder.Services.AddGMCaching();
builder.Services.AddGMFeatureManagement(builder.Configuration.GetSection("FeatureManagement"));
"FeatureManagement": {
"CacheTtl": "00:00:30",
"Features": {
"KycVendorSwitch": {
"Enabled": true,
"Variants": [ { "Name": "Didit", "Weight": 75 }, { "Name": "Identomat", "Weight": 25 } ]
},
"NewPayments": { "Enabled": true, "EnabledEnvironments": [ "Development", "Staging" ] }
}
}
Using it
public class KycService(IFeatureManager features)
{
public async Task<IKycVendor> ResolveVendorAsync(string userId)
{
// Sticky per user — a user assigned Identomat stays on Identomat as the rollout widens.
var variant = await features.GetVariantAsync("KycVendorSwitch", FeatureContext.ForUser(userId));
return variant.Name == "Identomat" ? _identomat : _didit;
}
public async Task<bool> PaymentsEnabledAsync(string userId, string tenantId) =>
await features.IsEnabledAsync("NewPayments", FeatureContext.ForUser(userId, tenantId));
}
Why cache the definition (not the result)
Evaluation against a context is a cheap local hash; the potentially slow part is fetching the
definition from the provider (trivial for appsettings, a network/DB hit for a hosted service). So the
manager caches the definition in GM.Caching for CacheTtl — single-flight, so a burst of checks
triggers one provider call, with negative caching for undefined keys. The result:
- checks are fast and don't hammer the backing store,
- a flag change propagates within the TTL — no redeploy,
- with
AddGMRedisCaching()the cache is shared, so all instances agree.
Swapping the flag source
IFeatureDefinitionProvider is the seam. The default is appsettings; register your own before
AddGMFeatureManagement (the built-in uses TryAdd, so yours wins) to move flags to a hosted
service or a database:
services.AddSingleton<IFeatureDefinitionProvider, LaunchDarklyFeatureProvider>();
// or an admin-UI store backed by GM.EntityFramework.Persistence
services.AddGMFeatureManagement(); // consumers still just inject IFeatureManager
Nothing on the consumer side changes — the cache, targeting, and evaluation are identical.
🚩 Flag — should GM.Secrets be the source of truth for flag values?
You asked me to flag rather than assume this. Recommendation: keep feature flags and secrets conceptually and operationally separate — do not make GM.Secrets the store for flag values.
| Feature flags | Secrets | |
|---|---|---|
| Data | Non-sensitive rollout config (percentages, env lists, targeting) | Credentials, keys, connection strings |
| Change cadence | Flip often, sometimes many times a day | Rotate rarely |
| Audience | Product / ops / an admin UI | Tightly held, least-privilege |
| Blast radius of a mistake | A feature bug | A breach |
Storing flags in a secrets store forces a bad trade: either you loosen access to the secret store so ops can toggle flags (weakening secret hygiene), or you lock flags behind secret-level access (so nobody can flip them quickly). They want opposite access models.
But they do meet at one point: a variant sometimes needs a credential — the Identomat arm of the
KYC switch needs an Identomat API key. The clean pattern is a reference, not a copy: the flag's
variant Configuration carries the secret's name (non-sensitive), and the consumer resolves the
actual value through GM.Secrets at point of use.
"Variants": [
{ "Name": "Identomat", "Weight": 25, "Configuration": { "apiKeySecret": "kyc/identomat/api-key" } }
]
var variant = await features.GetVariantAsync("KycVendorSwitch", ctx);
var apiKey = await secrets.GetAsync(variant["apiKeySecret"]!); // GM.Secrets resolves the value
So: flags point at secrets; they never store them. Two stores, one seam — not one store doing both.
Gating integrations
Each a thin package over IFeatureManager — install only what you use.
GM.FeatureManagement.AspNetCore
Gate endpoints; a closed gate returns a configurable status (404 by default, so an unreleased feature
looks absent). The FeatureContext is built from the caller's user/tenant claims automatically.
builder.Services.AddGMFeatureManagementAspNetCore(); // claims-based context + the [FeatureGate] filter
// minimal API
app.MapGet("/payments/checkout", () => Results.Ok(...)).RequireFeature("NewPayments");
// MVC
[HttpGet, FeatureGate("NewPayments")]
public IActionResult Get() => Ok(...);
GM.FeatureManagement.Mediator
Gate a command/query handler — the handler never runs when the flag is off (a
FeatureDisabledException short-circuits the pipeline):
builder.Services.AddGMFeatureGateBehavior(); // register in the GM.Mediator pipeline
// mark the request — a single-key marker…
public record RunPayoutCommand(string Account) : IRequest<Result>, IFeatureGatedRequest
{
public string FeatureKey => "NewPayments";
}
// …or the attribute (supports multiple keys + Any/All)
[FeatureGate("NewPayments")]
public record RunPayoutCommand(string Account) : IRequest<Result>;
Both read the ambient FeatureContext from IFeatureContextAccessor — the AspNetCore package's
claims-based accessor, or your own.
Conventions
Built to the shared GM.* conventions: net10.0, single lockstep <Version>, Conventional-Commits →
Versionize releases, NuGet Trusted Publishing (OIDC), README + icon packed into every nupkg. Runnable
usage lives in
GM.FeatureManagement.Samples.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- GM.FeatureManagement (>= 1.0.0)
- GM.Mediator (>= 1.3.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0 | 92 | 8/7/2026 |