Elarion

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>> { /* ... */ }
FormGate 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 FeatureManagement configuration 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; with reloadOnChange, an ops edit switches at run time.
  • Environment variables — a per-environment choice.
  • The settings bridgeAddElarionSettingsConfiguration() surfaces the runtime settings store as live configuration, so an admin writes Email:Backend = office365 through the settings API and (with AddElarionPostgreSqlSettingsChanges) the next scope on every node resolves Office365EmailSender — 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 bywho is asking — the flag backend's per-user allocationwhat is configured — one IConfiguration value
Answer variesper user / per requestper process, until the value changes
Evaluationasync, once per DI scope — the handler is wrapped in an async-resolving proxy that warms a per-scope cachea synchronous configuration read at resolution — no proxy, no warm-up, no per-scope cache
Inject the contracttransparently in handlers (same compilation); elsewhere use IVariantServiceProvider<T>anywhere — any service, any assembly
Requiresan OpenFeature provider that surfaces variant names (flagd, ConfigCat, LaunchDarkly, …)nothing beyond IConfiguration; add the settings bridge for admin-writable runtime switching
A switch takes effecton the next scope, once the provider re-allocateson the next scope after the value changes — cluster-wide with AddElarionPostgreSqlSettingsChanges
Reach for it whenA/B experiments, gradual rollouts, per-user/percentage targetingadmin- 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 / Platform

The 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:

PatternMenu ownerConsumers useBest for
Module variantthe module's own declarations — registry consts are the vocabularyElarionVariants.X.Y consts, [AllowedValues]in-app strategies (a forecast algorithm)
Port-owned vocabulary ⭐ recommendedapp-declared consts beside the port; adapters reference themthe same app consts in DTOs and attributesproduct-meaningful choices the app's UX presents
Platform offeringinfrastructure declarations; the host aggregatesIVariantCatalog — host-seeded data, no symbolsops-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.Default sentinel; if a consumer needs to pin it, give it a real name with IsDefault = true and 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

On this page