Architecture Decision Record
ADR-031: Config-Driven Feature Flags with Dual-Surface Enforcement
Status
Accepted (2026-06-27). Revised 2026-08-18 (a targeting-context accessor is now registered, so the
built-in Targeting and Percentage filters give consistent per-user bucketing across replicas; the
last Trade-offs entry is narrowed accordingly. See the Revision (2026-08-18) at the end). Revised
2026-08-31 (the targeting identifier is the JWT sub claim read through FindUserIdValue(), not a
user_id claim; the Revision section is corrected accordingly).
Context
The apps need to decouple release from deploy: ship code dark, flip a kill switch, or roll a feature
out to a percentage of users without a redeploy. A flag has to be enforceable at two different points
of the request path (the HTTP edge (an MVC action) and inside the CQRS pipeline (a command or query))
because a feature can be reachable from either. ADR-014 already names a FeatureGate decorator as the
outermost slot of the command/query pipeline, but it only decides the decorator ordering; it does not
decide the provider, the controller-edge surface, the disabled-response convention, or the rollout
filters. This ADR records those.
Decision
Standardize on Microsoft.FeatureManagement, configured from the "FeatureManagement" configuration
section and registered once in AddAPI
(services.AddFeatureManagement() + services.AddSingleton<IDisabledFeaturesHandler, DisabledFeatureHandler>(), MMCA.Common.API/DependencyInjection.cs:91-93), with the built-in
Percentage / TimeWindow / Targeting filters available for progressive rollout. The same flag name
is enforced at two independent surfaces:
- HTTP edge:
[FeatureGate("X")](Microsoft.FeatureManagement.Mvc) on a controller or action. WhenXis off,DisabledFeatureHandlerreturns an RFC 9457 ProblemDetails404("Feature not available"), matching the standardApiControllerBase.HandleFailureerror shape. - CQRS pipeline: a command/query implements
IFeatureGated(exposingFeatureName). TheFeatureGateCommandDecorator/FeatureGateQueryDecorator(the outermost decorator (ADR-014)) checksIFeatureManager.IsEnabledAsync(FeatureName)and, when off, short-circuits withError.NotFoundError("Feature.Disabled", …)(ErrorType.NotFound) before any logging, caching, validation, or transaction work. - Disabled =
404(NotFound), never403. Both surfaces return not-found, so a disabled feature is indistinguishable from a nonexistent one: it hides the feature's existence rather than advertising a forbidden capability. - Flag names are module constants (
CatalogFeatures/SalesFeaturesin Store,ConferenceFeatures/EngagementFeaturesin ADC) that match keys in each service's"FeatureManagement"config, so a flag flips at config + restart, not at deploy. The framework itself uses[FeatureGate](e.g. the notification controllers), and the decorators ship with unit tests.
Rationale
- Release decoupled from deploy. A kill switch or a percentage rollout becomes a configuration change, not a code change: the central reason feature management exists.
- Two surfaces because the enforcement points see different request shapes. Gating both the edge and the handler with one flag name keeps controller and use case in agreement, so a disabled feature is unreachable from either entry instead of leaking through the one that was missed.
- The
404convention reuses the existing edge. Both surfaces emit the same Result→ProblemDetails not-found shape (ADR-013), so a disabled feature looks like any other not-found and leaks nothing about hidden functionality.
Trade-offs
- The two enforcement points must agree. A flag gated on the controller but not the handler (or vice versa) is a half-protected feature; no fitness rule asserts both are wired, so coherence is a convention/audit concern.
- Flag debt. Every flag is a branch that must eventually be removed; the framework provides no expiry or staleness check.
- Per-service configuration. The same flag name must be present in each service that enforces it. A
missing key resolves to disabled (
IsEnabledAsync's default): fail-safe for a kill switch, but it will silently hide a feature you meant to ship if the key is forgotten. - Rollout state is per instance unless a context is wired. Percentage/Targeting bucketing is evaluated locally, so consistent assignment across replicas/users needs a deliberate targeting context; out of the box the rollout is per-process.
Revision (2026-08-18)
Progressive rollout is now usable, because the targeting context exists. The Decision above listed the Percentage / TimeWindow / Targeting filters as "available", and the last Trade-offs entry recorded the catch: without a targeting context wired, bucketing is evaluated per process, so a percentage rollout assigns a user differently on each replica and a user can see a feature appear and disappear between requests.
CurrentUserTargetingContextAccessor
(MMCA.Common/Source/Presentation/MMCA.Common.API/FeatureManagement/CurrentUserTargetingContextAccessor.cs:54-55)
implements ITargetingContextAccessor and is registered inside AddAPI as
services.AddFeatureManagement().WithTargeting<CurrentUserTargetingContextAccessor>(), preceded by
AddHttpContextAccessor() (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:90-92,
rationale at :84-89). It takes IHttpContextAccessor rather than the scoped ICurrentUserService
precisely because WithTargeting registers the accessor as a singleton. UserId resolves to the JWT
sub claim through user.FindUserIdValue(), falling back to Identity.Name (:86), and Groups
accepts role claims under ClaimTypes.Role, "role" or "roles" (:76-82), so a rollout can target
a role as well as a user. An unauthenticated or absent principal yields an empty context rather than an
exception (:67-74), which keeps anonymous traffic evaluating to the flag's non-targeted result
instead of failing. FindUserIdValue
(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ClaimsPrincipalExtensions.cs:26-28) reads the raw
sub claim (AuthClaimTypes.Subject,
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/AuthClaimTypes.cs:25) and falls back to the mapped
ClaimTypes.NameIdentifier the JWT bearer handler produces, so targeting buckets on exactly the
identifier CurrentUserService and the idempotency filter read.
No decorator changed. FeatureGateCommandDecorator still depends only on IFeatureManager and
still calls IsEnabledAsync(featureGated.FeatureName) with no targeting argument (:20, :51); the
targeting context is resolved inside the filter through the registered accessor. Both enforcement
surfaces therefore inherit consistent bucketing with no change at either call site, which is the
property that made this a registration-only change.
The Trade-offs entry above is narrowed, not removed: bucketing is now consistent across replicas
for any host that goes through AddAPI, but it is only as consistent as the sub claim is
stable, and a flag whose filter is configured without a targeting audience still behaves exactly as
before.
Related
ADR-014 (the decorator pipeline whose outermost slot FeatureGate fills, and the ordering that puts it
first, now with Authorization registered directly inside it so a disabled feature does not leak which
permission guards it), ADR-013 (the Result / Error and ProblemDetails edge the disabled responses
reuse), ADR-019 / ADR-020 / ADR-021 / ADR-026 (the other opt-in, audit-the-inventory capabilities),
ADR-020 (the role vocabulary the targeting accessor reads as Groups).