Feature flags & variants
Declarative, transport-neutral feature-flag gating for handlers — [FeatureGate] over an OpenFeature-backed IFeatureFlagService, with any provider behind it.
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, exactly like Microsoft's MVC [FeatureGate], but as a transport-neutral handler gate.
The flag backend is not baked in. The gate evaluates against a thin IFeatureFlagService seam whose default
implementation targets OpenFeature — the CNCF vendor-neutral standard — so the same
[FeatureGate] works against Microsoft.FeatureManagement, LaunchDarkly, ConfigCat, flagd, Flagsmith, or any
other OpenFeature provider without touching your handlers.
Handler gates vs. module flags
Elarion has two distinct flag mechanisms; reach for the right one:
- Module flags (
Modules:{Name}:Enabled) are compose-time: a disabled feature module disappears entirely — its services, endpoints, JSON-RPC/MCP operations, jobs, and consumers are never registered. See modules. - Feature gates (
[FeatureGate]) are runtime: the handler is always registered, but each call is evaluated against the flag provider, so you get gradual rollouts, targeting, percentage rollouts, and kill switches that flip without a redeploy.
Declaring gates
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 in the decorator pipeline just inside the authorization gate (authorization stays the outermost functional gate), so authorization runs first — an unauthenticated caller is never told whether a gated feature exists — and a disabled feature still never touches caching, validation, or the handler.
A handler whose response type cannot represent failure (no IResultFailureFactory<T>, i.e. not a Result<T>
/ Result) is reported at build time as ELFEAT001 — the gate could not short-circuit, so it would be
silently skipped. A [FeatureGate] with no feature name is ELFEAT002 (the gate has no effect).
Checking a flag at runtime
The same seam is injectable, so application code can branch on a flag imperatively:
public sealed class Dashboard(IFeatureFlagService features) {
public async Task<View> RenderAsync(CancellationToken ct) =>
await features.IsEnabledAsync("new-dashboard", ct)
? RenderNew()
: RenderClassic();
}public interface IFeatureFlagService {
ValueTask<bool> IsEnabledAsync(string feature, CancellationToken ct = default);
}Targeting is ambient: the default provider derives the OpenFeature evaluation context from the current
ICurrentUser — the user id becomes the targeting key (and the UserId/Groups
attributes the Microsoft.FeatureManagement provider reads), so percentage and targeting rollouts work off-HTTP
the same as on, with no HttpContext.
Choosing a provider
The seam (IFeatureFlagService, [FeatureGate], FeatureGateDecorator) lives in Elarion.Abstractions and
carries no feature-management dependency. Two opt-in packages provide a backend:
-
Elarion.FeatureFlags.FeatureManagement— the batteries-included default. One call wires the OpenFeature Microsoft.FeatureManagement provider so[FeatureGate]reads config-driven flags out of the box:builder.Services.AddElarionFeatureManagement(builder.Configuration);Flags are defined in the conventional
FeatureManagementconfiguration section. -
Elarion.FeatureFlags.OpenFeature— the provider-neutral base. Bring your own OpenFeature provider and register our service over it:builder.Services.AddOpenFeature(b => b.AddProvider(/* LaunchDarkly, ConfigCat, flagd, ... */)); builder.Services.AddElarionOpenFeature();
AddElarionFeatureManagement is just sugar over AddOpenFeature(...) + AddElarionOpenFeature(). Because both
go through the same IFeatureFlagService seam, switching providers never touches a [FeatureGate]. To replace
the backend wholesale (a custom store, a test double), register your own IFeatureFlagService — the gate and
all imperative checks follow.
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.
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}'.");
await settings.SetStringAsync(command.Key, command.Value, cancellationToken: ct);
return Result.Success();
}
}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-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.