Feature flags & variants
Every feature flag is declared once with exactly one owner — a resolver class or the host's backend — and gates, the client snapshot, and variants all evaluate through the same catalog.
Feature flags in Elarion gate a handler behind one or more flags. You annotate the handler with
[FeatureGate(...)]; a generated decorator evaluates the flags before the handler runs, under every
transport (JSON-RPC, MCP, HTTP) identically. A disabled feature short-circuits to a 404 Not Found — a
gated-off operation is indistinguishable from one that doesn't exist, so a flag doubles as a hide-the-roadmap
switch.
A flag is not a string you sprinkle around. It is declared once — name, description, whether the frontend may see it, and who owns its value — and the source generators build a catalog from those declarations, check every use against it at compile time, and wire it into DI. Exactly one owner answers for each flag, so there is no provider precedence to reason about. See ADR-0079.
Handler gates vs. module flags
- Module flags (
Modules:{Name}:Enabled) are compose-time: a disabled feature module disappears entirely — its services, endpoints, operations, jobs, consumers, and declared feature flags are never registered. See modules. - Feature gates (
[FeatureGate]) are runtime: the handler is always registered, but each call is evaluated against the flag's owner, so you get gradual rollouts, targeting, and kill switches that flip without a redeploy.
Declaring a flag
There are two kinds of owner. Declare each flag with exactly one of them, under the module that owns it.
Code-defined — the class that carries [FeatureFlag] is the owner and implements IFeatureFlagResolver:
[FeatureFlag("beta-reports", Description = "Early access to the reports redesign.", ExposeToClient = true)]
public sealed class BetaReportsFlag(IBetaRoster roster) : IFeatureFlagResolver {
public async ValueTask<bool> IsEnabledAsync(FeatureEvaluationContext context, CancellationToken ct) =>
context.UserId is { } id && await roster.ContainsAsync(id, ct);
}The resolver is a scoped service, so it can inject a DbContext, settings, or a clock.
Backend-evaluated — the value lives in an external system (Microsoft.FeatureManagement, LaunchDarkly, …) and
the host's IBackendFeatureFlagEvaluator owns it. Declare the name on any class under the module:
[AppModule("Billing")]
[BackendFeatureFlag("new-export", Description = "The streaming invoice export.")]
[BackendFeatureFlag("dashboard-v2", ExposeToClient = true)]
public static class BillingModule { }A flag is a variant flag when its owner allocates a variant (IFeatureFlagResolver.GetVariantAsync, or the
backend's allocation); [FeatureVariant] then selects service implementations by it (below).
What the compiler checks
| Mistake | Diagnostic |
|---|---|
A name in [FeatureGate]/[FeatureVariant] that nothing declares | ELFLAG001 |
| The same name declared twice — in one assembly or across referenced assemblies | ELFLAG002 |
[FeatureFlag] on a class that is not a concrete IFeatureFlagResolver (no owner) | ELFLAG003 |
A declaration outside every [AppModule] namespace of its assembly | ELFLAG004 |
| A blank flag name | ELFLAG005 |
| Two names mapping to the same generated accessor | ELFLAG006 (warning) |
Declarations of referenced assemblies are read from the Elarion manifest, never by scanning symbols, so the checks cross assembly boundaries: declare a flag in the assembly that gates on it, or in one it references.
Gating handlers
Annotate the handler class. The attribute mirrors the familiar ASP.NET MVC [FeatureGate]:
[Handler("billing.export")]
[FeatureGate("new-export")]
public sealed class ExportInvoices(AppDbContext db)
: IHandler<ExportInvoices.Command, Result<ExportInvoices.Response>> { /* ... */ }| Form | Gate is satisfied when |
|---|---|
[FeatureGate("a")] | a is enabled |
[FeatureGate("a", "b")] | both a and b are enabled (the default, FeatureRequirement.All) |
[FeatureGate(FeatureRequirement.Any, "a", "b")] | either a or b is enabled |
[FeatureGate("legacy", Negate = true)] | legacy is disabled (fence off a legacy path during rollout) |
The attribute is AllowMultiple, so stacking several [FeatureGate] attributes ANDs them. When any gate is
unsatisfied the call returns AppError.NotFound and the handler never runs. The gate sits just inside the
authorization gate, so an unauthenticated caller is never told whether a gated feature exists, and a disabled
feature never touches caching, validation, or the handler.
A handler whose response type cannot represent failure is reported as ELFEAT001; a [FeatureGate] with no
feature name is ELFEAT002.
Checking a flag at runtime
IFeatureFlagService is the one entry point. It looks the flag up in the catalog and dispatches to its owner:
public sealed class Dashboard(IFeatureFlagService flags) {
public async Task<View> RenderAsync(CancellationToken ct) =>
await flags.IsEnabledAsync(ElarionFeatureFlags.DashboardV2, ct) ? RenderNew() : RenderClassic();
}[assembly: GenerateFeatureFlags] (included in [assembly: UseElarion]) emits the ElarionFeatureFlags
registry: a typed FeatureFlagKey per declared flag (so a misspelled or removed flag fails the build), a
Names class of const strings for attribute arguments, and the descriptors aggregated across referenced
assemblies. A raw string still works; a name the catalog does not contain — undeclared, or declared by a
disabled module — evaluates as disabled and is logged once.
Explicit evaluation context
Owners never reach into the ambient DI scope for who is asking; they receive a FeatureEvaluationContext:
| Member | Meaning |
|---|---|
UserId / IsAuthenticated | the subject (null for an anonymous caller) |
Roles | the subject's roles |
TenantId | the tenant, when tenant scoping resolved one and the scope is not a system scope |
Attributes | extra targeting attributes you supply |
Services | the scope's services, for resolvers that need them; optional — a context built by hand (new FeatureEvaluationContext { UserId = "u-1" }) carries an empty provider, GetRequiredService on it throws a message saying so, and it is never null. flags.CreateContext() always carries the real scope. |
The overloads without a context use flags.CreateContext() (the current request). Pass your own to evaluate for
someone else — an admin previewing another user's experience, a job acting for a user:
var asVip = flags.CreateContext() with { UserId = "u-vip" };
var enabled = await flags.IsEnabledAsync("beta-reports", asVip, ct);[FeatureGate], the session snapshot (client capabilities), and your own
checks all evaluate through the same service and catalog, so a flag cannot answer differently depending on the
entry point.
Choosing a backend
Backend-evaluated flags need exactly one IBackendFeatureFlagEvaluator. The host fails at startup if it
declares backend flags and registers none. Two opt-in packages provide one; see
feature flag providers:
Elarion.FeatureFlags.FeatureManagement—builder.Services.AddElarionFeatureManagement(builder.Configuration);reads flags from theFeatureManagementconfiguration section.Elarion.FeatureFlags.OpenFeature—AddOpenFeature(...)+AddElarionOpenFeature()over any OpenFeature provider (LaunchDarkly, ConfigCat, flagd, …).
The backend only ever sees flags declared with [BackendFeatureFlag]; it is never a fallback for a code-owned or
undeclared name.
Variant service injection
Beyond on/off gating, a feature flag can allocate a variant to each user, and you can ship a different service implementation per variant (the A/B-tested-algorithm pattern). The consuming handler stays transparent — it injects the contract like any service; only the implementations are variant-aware:
[Service] // declare it like any service
[FeatureVariant("ForecastAlgorithm")] // the default (no Variant)
public sealed class LinearForecast : IForecastAlgorithm { /* ... */ }
[Service]
[FeatureVariant("ForecastAlgorithm", Variant = "neural")]
public sealed class NeuralForecast : IForecastAlgorithm { /* ... */ }
public sealed class RunForecast(IForecastAlgorithm algorithm) // no variant awareness
: IHandler<RunForecast.Command, Result<Forecast>> {
public ValueTask<Result<Forecast>> HandleAsync(Command c, CancellationToken ct)
=> algorithm.RunAsync(c.Input, ct);
}The user allocated the neural variant gets NeuralForecast; everyone else gets the default LinearForecast.
The named flag (ForecastAlgorithm) must be declared like any other (ELFLAG001 otherwise), and whoever owns it
allocates the variant: its resolver's GetVariantAsync, or the backend.
Vary behavior with a variant service, not a variant handler. There is no "pick handler A vs handler B by
flag" — a handler is chosen by its request type, so authorization, the route/transport contract, and the pipeline
are fixed for that request. To make a command behave differently per variant, keep one handler and inject a
variant strategy service (the IForecastAlgorithm above); only that one dependency changes between variants.
This is the recommended way to apply variants to handler logic.
[FeatureVariant] is a modifier on [Service]. A variant implementation is declared like every other
service — with [Service] — and [FeatureVariant] only changes how it is resolved. The contract is not
repeated on [FeatureVariant]: it is whatever [Service] registers under (the implemented interface, or explicit
[Service(typeof(IX))] types), so a class that registers under several contracts is variant-resolved on each. So
[Service] is required (a [FeatureVariant] without it is reported as ELVAR007) and stays the single,
consistent way to register a service: with the modifier you get variant-keyed resolution, without it a plain
registration. The service generator yields the contract to the variant path (keyed implementations + the binding +
the imperative provider + the transparent registration the proxy reads), so the two never double-register. The DI
lifetime comes from [Service] (Scope = ServiceScope.Singleton, etc.), and variant implementations may take their
own constructor dependencies.
How it stays transparent. Variant selection is an async flag evaluation, but constructor injection is
synchronous. When a handler's constructor depends on a variant contract, the generator registers it behind an
async-resolving proxy (AsyncResolvedHandler) that, on the first call, awaits the variant for the current user
into a per-scope cache and then builds the handler normally — so the handler injects the contract with no async
ceremony, and handlers that use no variant pay nothing (their registration is unchanged). For cases the proxy
doesn't cover — a variant injected transitively through another service, or needed outside a handler — inject the
imperative escape hatch instead:
public sealed class Pricing(IVariantServiceProvider<IForecastAlgorithm> algorithms) {
public async Task RunAsync(CancellationToken ct) {
var algorithm = await algorithms.GetAsync(ct); // variant for the current user, or the default
// ...
}
}Variant service injection needs an OpenFeature provider that surfaces the allocated variant name (per spec
§1.4.6) — flagd, LaunchDarkly, ConfigCat, and the in-memory provider do. The current
OpenFeature.Contrib.Provider.FeatureManagement (preview) evaluates the variant's value but does not yet
surface its name, so variant injection is unavailable through the AddElarionFeatureManagement default (boolean
[FeatureGate] is unaffected). See ADR-0019.
Configuration-selected variants
[FeatureVariant] selects per user — the answer can differ per request, so it is evaluated per scope and
awaited. When the selection is a process-global choice an admin or operator makes — "which e-mail backend
are we using?" — that machinery is pure overhead: the answer changes on a configuration edit, not per caller.
Declare the selection axis instead with [ConfigurationVariant], and a plain configuration value picks the
implementation:
[Service]
[ConfigurationVariant("Email:Backend", Value = "smtp", IsDefault = true)] // the named default
public sealed class SmtpEmailSender : IEmailSender { /* ... */ }
[Service]
[ConfigurationVariant("Email:Backend", Value = "office365")]
public sealed class Office365EmailSender : IEmailSender { /* ... */ }
public sealed class SendInvoice(IEmailSender sender) // still no variant awareness
: IHandler<SendInvoice.Command, Result<Unit>> { /* ... */ }A named default (Value + IsDefault = true) is both the fallback and explicitly selectable by its
value — so the default state has a writable name (an admin switches back to SMTP by writing "smtp", not by
removing the key), and the switch's whole vocabulary is declared. Prefer it for admin-facing switches; a
default with no Value still works and simply has no selectable name.
Because the configuration read is synchronous, there is no async-resolving proxy and no per-scope warm-up:
the contract is injectable anywhere (handlers, services, across assemblies), each new DI scope observes the
current value, and work already in flight keeps the implementation it started with. The configured value is
matched case-insensitively; an absent key or a value matching no variant resolves the default implementation. A
contract is selected by exactly one axis — mixing [FeatureVariant] and [ConfigurationVariant] on one
contract is rejected (ELVAR008).
Any configuration provider drives the switch, which is the point — the attribute knows nothing about where the value comes from:
appsettings.json— pinned per deployment; withreloadOnChange, an ops edit switches at run time.- Environment variables — a per-environment choice.
- The settings bridge —
AddElarionSettingsConfiguration()surfaces the runtime settings store as live configuration, so an admin writesEmail:Backend = office365through the settings API and (withAddElarionPostgreSqlSettingsChanges) the next scope on every node resolvesOffice365EmailSender— no restart, and no feature-flag provider involved.
Feature-selected or configuration-selected?
The two attributes are the same substrate — keyed implementations, a default, transparent injection — with different selection axes, and the differences follow from one fact: a feature variant's answer can differ per caller, a configuration variant's cannot.
[FeatureVariant] | [ConfigurationVariant] | |
|---|---|---|
| Selected by | who is asking — the flag backend's per-user allocation | what is configured — one IConfiguration value |
| Answer varies | per user / per request | per process, until the value changes |
| Evaluation | async, once per DI scope — the handler is wrapped in an async-resolving proxy that warms a per-scope cache | a synchronous configuration read at resolution — no proxy, no warm-up, no per-scope cache |
| Inject the contract | transparently in handlers (same compilation); elsewhere use IVariantServiceProvider<T> | anywhere — any service, any assembly |
| Requires | an OpenFeature provider that surfaces variant names (flagd, ConfigCat, LaunchDarkly, …) | nothing beyond IConfiguration; add the settings bridge for admin-writable runtime switching |
| A switch takes effect | on the next scope, once the provider re-allocates | on the next scope after the value changes — cluster-wide with AddElarionPostgreSqlSettingsChanges |
| Reach for it when | A/B experiments, gradual rollouts, per-user/percentage targeting | admin- or ops-chosen backends, environment-shaped choices, kill-switch-style implementation swaps |
The decision rule: ask who decides the answer. If two concurrent requests could legitimately get
different implementations, that is per-caller selection — [FeatureVariant]. If there is one answer for
the whole process, that is configuration — [ConfigurationVariant].
When in doubt, start configuration-selected. It is the smaller machine — no flag provider to run, no
async proxy, injectable anywhere — and an admin-writable switch already covers most "we might want to change
this later" needs. If a switch later turns out to need per-user allocation, the migration is confined to the
implementation classes' attributes (handler consumers are untouched; consumers outside handlers move to
IVariantServiceProvider<T>).
The variant registry
The generator already knows every switch you ship — its key, its value vocabulary, its default, its owning
module — so it also emits that knowledge as the ElarionVariants registry (triggered by
[assembly: UseElarion] or [assembly: GenerateVariantCatalog]), the variant analog of ElarionPermissions:
one accessor class per switch with const string values, plus VariantDescriptor data, aggregated across
referenced assemblies from the Elarion manifest — so the host's registry is the complete menu:
// generated into the assembly root namespace
ElarionVariants.EmailBackend.Key // "Email:Backend"
ElarionVariants.EmailBackend.Smtp // "smtp" — consts, usable in attributes
ElarionVariants.EmailBackend.Office365 // "office365"
ElarionVariants.All // every VariantDescriptor (axis, key, values, default, contract, module)
ElarionVariants.ByKey / ByModule / PlatformThe host seeds runtime consumers explicitly — the generator registers nothing in DI:
builder.Services.AddElarionVariantCatalog(ElarionVariants.All); // IVariantCatalog for handlers/UI
builder.Services.AddElarionVariantValidation(); // startup + reload validation (see below)AddElarionVariantValidation() checks, at startup and again on every configuration reload, that each
configuration-selected switch's current value is one the registry offers (an unknown value otherwise falls
back to the default silently — the fallback keeps requests serving; the validator makes the mismatch loud),
and that each platform switch's contract actually resolves from DI (catching a forgotten host wiring call
at boot). Strict = true fails startup on findings instead of warning.
Because adding a variant implementation is the declaration, the write-DTO allowed set, the admin dropdown
data, the exported schema enum, the client-side validation, and the startup checks all follow with zero
central edits — [AllowedValues(...)] is exported as the JSON Schema enum keyword to the JSON-RPC schema,
MCP tool schemas, and the OpenAPI document, and the generated TypeScript client turns it into z.enum(...)
with string-union types (see Validation).
Who owns the switch
Variant implementations often live in the infrastructure project — the adapter half of the port/adapter
pattern, under no module. That is first-class: such descriptors carry Module = null and group under
ElarionVariants.Platform; the host wires them with the generated per-contract call
(builder.Services.AddIEmailSenderVariantService()). It also raises the real design question: who owns the
switch's vocabulary? Three patterns, all of which keep the application in charge of its own settings APIs:
| Pattern | Menu owner | Consumers use | Best for |
|---|---|---|---|
| Module variant | the module's own declarations — registry consts are the vocabulary | ElarionVariants.X.Y consts, [AllowedValues] | in-app strategies (a forecast algorithm) |
| Port-owned vocabulary ⭐ recommended | app-declared consts beside the port; adapters reference them | the same app consts in DTOs and attributes | product-meaningful choices the app's UX presents |
| Platform offering | infrastructure declarations; the host aggregates | IVariantCatalog — host-seeded data, no symbols | ops-flavored parameters, generic settings consoles |
The happy path: declare the menu where the port lives. For an in-module strategy there is nothing to
decide — the module's declarations are the vocabulary. For a switchable adapter, default to port-owned
vocabulary: the port already owns the interface, so it owns the menu, and one set of consts then feeds the
adapters' attributes, the admin DTO's [AllowedValues], the 400s, every exported schema's enum, the
generated client's z.enum, and the registry — convention over configuration, end to end. Reach for the
platform offering pattern only when you genuinely want zero per-switch code (a generic settings console)
or the application must not know the choices exist.
Port-owned vocabulary resolves the layering constraint head-on: the application cannot reference infrastructure, so when it wants to name the choices (typed DTOs, schema enums), it declares the consts beside the port and the adapters reference them — the dependency direction that works:
// Application — the port owns the menu, so the application owns the API
public static class EmailBackends {
public const string Key = "Email:Backend";
public const string Smtp = "smtp";
public const string Office365 = "office365";
}
public sealed record ConfigureEmailCommand : ICommand {
[AllowedValues(EmailBackends.Smtp, EmailBackends.Office365)] // 400 + schema enum + client z.enum
public required string Backend { get; init; }
}
// Infrastructure — adapters implement the menu
[Service]
[ConfigurationVariant(EmailBackends.Key, Value = EmailBackends.Smtp, IsDefault = true)]
public sealed class SmtpEmailSender : IEmailSender { /* ... */ }Platform offering covers switches the application handles without naming them: the host seeds the catalog, and an application handler validates against "what the platform offers" at runtime — its own route, DTO, authorization, and audit, with zero per-switch code:
[Handler("admin.platform.setSwitch")]
[RequirePermission("settings", Verbs.Update)]
public sealed class SetPlatformSwitch(IVariantCatalog catalog, ISettingsManager settings)
: IHandler<SetPlatformSwitchCommand> {
public async ValueTask<Result> HandleAsync(SetPlatformSwitchCommand command, CancellationToken ct) {
var descriptors = catalog.FindByKey(command.Key);
if (descriptors.Count == 0)
return AppError.NotFound($"No switch '{command.Key}' is offered.");
if (!descriptors[0].Values.Contains(command.Value.ToLowerInvariant()))
return AppError.Validation($"'{command.Value}' is not offered for '{command.Key}'.");
// PlatformSwitches is a declared setting (see Settings) holding the chosen value per switch key.
var chosen = new Dictionary<string, string>(
await settings.GetAsync(AppSettings.PlatformSwitches, cancellationToken: ct)) {
[command.Key] = command.Value.ToLowerInvariant()
};
var write = await settings.SetAsync(AppSettings.PlatformSwitches, chosen, cancellationToken: ct);
return write.IsSuccess ? Result.Success() : write.Error;
}
}The tradeoff between the last two is compile-time versus runtime knowledge: port-owned vocabularies buy typed DTOs, schema-exported enums, and client-side pre-validation at the cost of the application updating its consts when the menu grows; the platform offering buys zero-per-switch generality at the cost of dynamic-only validation (the values reach the UI through an enumerate call, not the schema). Elarion deliberately ships no admin endpoint — the registry is data handed to your code, which decides how it is exposed.
Pinning a specific implementation
The variant registrations are ordinary keyed services, so a consumer can deliberately opt out of selection and pin one implementation — on either axis:
// This job must always use the SMTP relay, regardless of what the switch currently selects.
public sealed class ComplianceExport(
[FromKeyedServices(EmailBackends.Smtp)] IEmailSender alwaysSmtp) { /* ... */ }Pinning bypasses the selection machinery entirely: no flag evaluation, no configuration read, and — on the
feature axis — no async proxy or per-scope warm-up, so a pinned feature-variant implementation is injectable
anywhere (the generator recognizes [FromKeyedServices] parameters and keeps such handlers on the plain
synchronous registration). Lifetimes come from the implementation's [Service], and when the pinned key
matches what the switch currently selects, both consumers share the same scoped instance.
Two rules keep pinning safe:
- Pin via the vocabulary consts (
ElarionVariants.EmailBackend.Smtp, or the port-owned consts) — never a raw string. Configuration-axis keys are the lower-cased declared values, and a direct keyed lookup does no normalization:[FromKeyedServices("Office365")]silently misses where the switch itself would have matched. The consts make a typo or rename a compile error. - Name the default. An unnamed default is keyed under the internal
VariantServiceKeys.Defaultsentinel; if a consumer needs to pin it, give it a real name withIsDefault = trueand pin that.
Pinning is the escape hatch, not the path: it is invisible to the switch — the admin flips the value, the registry and validation say everything is consistent, and the pinned consumer doesn't follow. Reach for it only when a consumer semantically requires one backend (a compliance job that must always use the relay), for explicit fallback chains (try the selected implementation, fall back to a pinned one), and in tests. Everything else injects the contract and follows the switch.
See also
- ADR-0079: Every feature flag is declared and has exactly one owner — declaration as the single source, one owner per flag, explicit evaluation context.
- ADR-0016: Feature-flag gating — why a thin Elarion seam over OpenFeature rather than
IFeatureManagerdirectly. - ADR-0019: Variant service injection — transparent per-variant implementation injection via the async-resolving handler proxy.
- ADR-0028: Configuration-selected service variants — the synchronous, proxy-free
[ConfigurationVariant]axis and why it readsIConfigurationrather than the settings API orIOptionsMonitor. - ADR-0029: The variant registry —
ElarionVariants, named defaults, the host-seededIVariantCatalog, the validation worker, and the switch-ownership patterns. - Decorator pipelines — how the gate composes with authorization, caching, and resilience.
- Authorization — the sibling declarative gate the feature gate is modeled on.
Resource authorization
Per-resource (read/write) access control and efficient database-level filtering — owner, tenant, and role-based sharing — composed from one declarative source.
Client capabilities
One bootstrap snapshot — modules, feature flags/variants, and the user's grants — projected to the frontend over OpenFeature so the UI can hide or adapt itself.