Komento.OpenFeature 0.2.0-alpha

This is a prerelease version of Komento.OpenFeature.
dotnet add package Komento.OpenFeature --version 0.2.0-alpha
                    
NuGet\Install-Package Komento.OpenFeature -Version 0.2.0-alpha
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Komento.OpenFeature" Version="0.2.0-alpha" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Komento.OpenFeature" Version="0.2.0-alpha" />
                    
Directory.Packages.props
<PackageReference Include="Komento.OpenFeature" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Komento.OpenFeature --version 0.2.0-alpha
                    
#r "nuget: Komento.OpenFeature, 0.2.0-alpha"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Komento.OpenFeature@0.2.0-alpha
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Komento.OpenFeature&version=0.2.0-alpha&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Komento.OpenFeature&version=0.2.0-alpha&prerelease
                    
Install as a Cake Tool

Komento

A storage-agnostic experimentation and feature flag engine for .NET. Its primary operation is the experiment check: given a flag key and a subject, deterministically return which variant they belong to.

var result = await client.GetVariantAsync("checkout-flow", userId, ctx);
if (result == "treatment")
    return NewCheckout();

Design goals

  • Zero I/O in the hot path — all evaluation is in-memory.
  • Near-zero allocations — ValueTask, Span<T>, FrozenDictionary, ArrayPool<T>.
  • Pluggable at every seam — swap in your own config source, segment store, subject resolver, or context enricher.
  • OpenFeature support via the Komento.OpenFeature provider package.

Packages

| Package | Purpose | NuGet | |---|---|---| | Komento | Core engine, all interfaces, DI registration | NuGet | | Komento.AspNetCore | ASP.NET Core integration (filters, subject provider, enrichers) | NuGet | | Komento.OpenFeature | OpenFeature provider adapter | NuGet |

Quick start

1. Register Komento

builder.Services
    .AddKomento(options =>
    {
        // Declare which experiments this service cares about.
        options.Experiments = new HashSet<string> { "checkout-flow", "dark-mode" };

        // Attributes merged into every EvaluationContext at evaluation time.
        options.StaticContext = EvaluationContext.Create()
            .Set("region", "BR")
            .Set("service", "payments")
            .Build();
    })
    .AddSource<AppSettingsExperimentSource>(); // built-in: reads from appsettings.json

2. Load configs at startup

Call InitializeKomentoAsync once before accepting traffic. It calls IExperimentSource.LoadAsync and feeds the result into the engine.

await app.Services.InitializeKomentoAsync();
await app.RunAsync();

3. Evaluate

Inject IExperimentClient anywhere:

public class CheckoutService(IExperimentClient experiments)
{
    public async Task<IActionResult> Checkout(string userId)
    {
        var ctx = EvaluationContext.Create().Set("platform", "web").Build();
        var variant = await experiments.GetVariantAsync("checkout-flow", userId, ctx);

        return variant == "treatment" ? NewFlow() : LegacyFlow();
    }
}

Typed helpers are available for simple flag cases:

bool enabled = await experiments.GetBoolAsync("dark-mode", userId, ctx);
string theme  = await experiments.GetStringAsync("ui-theme", userId, ctx, defaultValue: "default");

4. Use Komento through OpenFeature

If your application already uses the OpenFeature .NET SDK, install Komento.OpenFeature and register KomentoFeatureProvider with the OpenFeature API.

using Komento.OpenFeature;
using OpenFeature;
using OpenFeature.Model;

await app.Services.InitializeKomentoAsync();

var experimentClient = app.Services.GetRequiredService<IExperimentClient>();
Api.Instance.SetProvider(new KomentoFeatureProvider(experimentClient));

var client = Api.Instance.GetClient();
var ctx = EvaluationContext.Builder()
    .SetTargetingKey(userId)
    .Set("platform", new Value("web"))
    .Build();

bool enabled = await client.GetBooleanValueAsync("dark-mode", false, ctx);
string theme = await client.GetStringValueAsync("ui-theme", "default", ctx);

KomentoFeatureProvider maps OpenFeature requests onto IExperimentClient:

OpenFeature result Komento behavior
TARGETING_KEY_MISSING OpenFeature context has no targetingKey
FLAG_NOT_FOUND IExperimentClient.ExperimentExists(flagKey) is false
DEFAULT Subject is ineligible or an outsider
TARGETING_MATCH Subject was assigned a variant and the value type matched
PARSE_ERROR Variant value existed but could not be converted to the requested OpenFeature type

Configuration format (appsettings.json)

{
  "Komento": {
    "Experiments": [
      {
        "id": "checkout-flow",
        "subjectType": "user",
        "variants": [
          { "name": "control",   "allocation": 0.5 },
          { "name": "treatment", "allocation": 0.5, "value": true }
        ],
        "globalFilters": [
          { "type": "trait-equals",    "key": "country", "value": "BR" },
          { "type": "segment-include", "segment": "beta-users" }
        ],
        "overrides": [
          { "type": "subject", "subjectId": "user-42",       "variant": "treatment" },
          { "type": "segment", "segment":  "internal-staff", "variant": "treatment" }
        ]
      }
    ]
  }
}

Allocation is a double in [0.0, 1.0]. Allocations across all variants must sum to ≤ 1.0. Subjects whose hash falls outside all allocations are outsiders — they see control behavior but are excluded from experiment metrics.

Filter types

type Fields Description
trait-equals key, value Subject must have key = value in EvaluationContext
segment-include segment Subject must be a member of the named segment

Override types

type Fields Description
subject subjectId, variant Forces a specific subject into a variant, before bucket assignment
segment segment, variant Forces all members of a segment into a variant, before bucket assignment

Assignment result

GetVariantAsync always returns a VariantResult:

public readonly struct VariantResult
{
    public string  VariantName { get; init; }
    public object? Value       { get; init; }  // optional typed payload from VariantConfig.Value
    public bool    IsEligible  { get; init; }  // false when a global filter excluded the subject
    public bool    IsOutsider  { get; init; }  // true when no variant bucket matched
}

The == operator compares against a variant name string directly:

if (result == "treatment") { ... }

Fallback table

Situation VariantName IsEligible IsOutsider
Experiment not found "control" false false
Subject failed a global filter "control" false false
Subject outside all buckets "control" true true
Normal assignment variant name true false

Extension points

Komento is designed to be extended rather than forked. The six interfaces below are the complete seam set.


IExperimentSource — config loading

public interface IExperimentSource
{
    ValueTask<IReadOnlyDictionary<string, ExperimentConfig>> LoadAsync(
        IReadOnlySet<string> experimentIds,
        CancellationToken ct = default);
}

When to implement: You store experiment definitions somewhere other than appsettings.json — a database, an HTTP API, a Redis key, a gRPC service. Implement IExperimentSource to pull the initial config at startup.

LoadAsync is called once by InitializeKomentoAsync. It receives the set of experiment IDs declared in KomentoOptions.Experiments so you only fetch what this service cares about.

Built-in: AppSettingsExperimentSource reads from IConfiguration. Good for local development and tests; not suitable for production systems where configs live in a database or a dedicated config service.

Registration:

builder.Services
    .AddKomento(o => o.Experiments = [...])
    .AddSource<MyDatabaseExperimentSource>();

Example — HTTP source:

public sealed class HttpExperimentSource(HttpClient http) : IExperimentSource
{
    public async ValueTask<IReadOnlyDictionary<string, ExperimentConfig>> LoadAsync(
        IReadOnlySet<string> experimentIds, CancellationToken ct)
    {
        var response = await http.GetFromJsonAsync<List<ExperimentConfig>>(
            $"/experiments?ids={string.Join(',', experimentIds)}", ct);

        return response?.ToDictionary(e => e.Id)
               ?? (IReadOnlyDictionary<string, ExperimentConfig>)new Dictionary<string, ExperimentConfig>();
    }
}

IConfigUpdater — hot config reload

public interface IConfigUpdater
{
    IReadOnlySet<string> RelevantExperimentIds { get; }

    ValueTask UpdateAsync(IReadOnlyDictionary<string, ExperimentConfig> configs, CancellationToken ct = default);
    ValueTask UpdateAsync(ExperimentConfig config, CancellationToken ct = default);
    ValueTask RemoveAsync(string experimentId, CancellationToken ct = default);
}

When to use: Push config changes to the engine at runtime without restarting. The engine (ExperimentClient) implements this interface — inject it as IConfigUpdater to drive hot reloads from wherever change notifications arrive.

RelevantExperimentIds returns the set declared in KomentoOptions.Experiments. External notifiers (message queues, Kafka consumers, WebSocket listeners) should filter against it so they don't process changes for experiments this service doesn't run.

The update is atomic: the engine builds a new FrozenDictionary and swaps the reference in one step. In-flight evaluations complete against the previous config; all subsequent calls see the new one.

Example — background polling service:

public sealed class ExperimentPollingService(
    IExperimentSource source,
    IConfigUpdater updater,
    ILogger<ExperimentPollingService> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            try
            {
                var configs = await source.LoadAsync(updater.RelevantExperimentIds, ct);
                await updater.UpdateAsync(configs, ct);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                log.LogError(ex, "Failed to refresh experiment configs");
            }

            await Task.Delay(TimeSpan.FromSeconds(30), ct);
        }
    }
}

Register it alongside Komento:

builder.Services.AddHostedService<ExperimentPollingService>();

Example — push notification (e.g., Kafka, Redis Pub/Sub):

// In your message consumer handler:
if (updater.RelevantExperimentIds.Contains(incomingConfig.Id))
    await updater.UpdateAsync(incomingConfig, ct);

ISegmentProvider — set membership

public interface ISegmentProvider
{
    ValueTask<bool> IsInSegmentAsync(string subjectId, string segmentName, CancellationToken ct = default);
}

When to implement: You have large subject ID lists (allowlists, holdouts, beta cohorts) that need membership checks during filter or override evaluation. The engine calls this whenever a SegmentIncludeFilter or SegmentOverride appears in an experiment config.

This method is on the hot path. For static lists loaded at startup, implement it with binary search over a sorted in-memory array. For dynamic lists, a local cache with a short TTL is strongly recommended.

Built-in: InMemorySegmentProvider — loaded at startup with a Dictionary<string, IEnumerable<string>>. Internally uses BinSets: sorted, deduplicated byte arrays where membership is a binary search, O(log n), allocation-free. Ideal for static lists that fit in RAM; millions of IDs are practical.

Registration:

builder.Services
    .AddKomento(o => o.Experiments = [...])
    .AddSegmentProvider<MyRedisSegmentProvider>();

Example — Redis-backed provider with local cache:

public sealed class RedisSegmentProvider(IDatabase redis) : ISegmentProvider
{
    private readonly ConcurrentDictionary<string, (DateTimeOffset Expires, bool Result)> _cache = new();

    public async ValueTask<bool> IsInSegmentAsync(string subjectId, string segmentName, CancellationToken ct)
    {
        var key = $"{segmentName}:{subjectId}";
        if (_cache.TryGetValue(key, out var cached) && cached.Expires > DateTimeOffset.UtcNow)
            return cached.Result;

        var isMember = await redis.SetContainsAsync(segmentName, subjectId);
        _cache[key] = (DateTimeOffset.UtcNow.AddSeconds(30), isMember);
        return isMember;
    }
}

ISubjectProvider (Komento.AspNetCore) — HTTP subject resolution

public interface ISubjectProvider
{
    string  SubjectType { get; }
    string? GetSubject(HttpContext context);
}

When to implement: You need the [RequireVariant] filter or .RequireVariant() endpoint filter to know who the current HTTP request is for. The integration matches providers to experiments by SubjectType — an experiment with subjectType = "user" is evaluated using the provider whose SubjectType is "user".

Return null when no subject can be resolved (unauthenticated request, missing header, etc.). The filter treats a null subject as a miss and returns 404 Not Found.

Register multiple providers to support different subject types:

builder.Services
    .AddKomentoAspNetCore()
    .AddSubjectProvider<UserSubjectProvider>()    // SubjectType = "user"
    .AddSubjectProvider<TenantSubjectProvider>(); // SubjectType = "tenant"

Example — JWT claims provider:

public sealed class UserSubjectProvider : ISubjectProvider
{
    public string SubjectType => "user";

    public string? GetSubject(HttpContext context)
        => context.User.FindFirstValue(ClaimTypes.NameIdentifier);
}

Example — API key header provider:

public sealed class TenantSubjectProvider : ISubjectProvider
{
    public string SubjectType => "tenant";

    public string? GetSubject(HttpContext context)
        => context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
}

IEvaluationContextEnricher (Komento.AspNetCore) — per-request context

public interface IEvaluationContextEnricher
{
    ValueTask EnrichAsync(HttpContext context, EvaluationContextBuilder builder, CancellationToken ct = default);
}

When to implement: You need attributes beyond the static context (region, service name) to evaluate filters for HTTP requests — locale from the Accept-Language header, plan tier from JWT claims, country from a GeoIP lookup. Enrichers run in registration order before every evaluation triggered by [RequireVariant] or .RequireVariant().

Enrichers are synchronous-friendly — return ValueTask.CompletedTask if no async work is needed. If an enricher must call an external service, use async/await as normal.

Registration:

builder.Services
    .AddKomentoAspNetCore()
    .AddEnricher<LocaleEnricher>()
    .AddEnricher<ClaimsEnricher>();

Example — locale from Accept-Language:

public sealed class LocaleEnricher : IEvaluationContextEnricher
{
    public ValueTask EnrichAsync(HttpContext context, EvaluationContextBuilder builder, CancellationToken ct)
    {
        var locale = context.Request.Headers.AcceptLanguage.FirstOrDefault()?.Split(',')[0].Trim();
        if (locale is not null)
            builder.Set("locale", locale);
        return ValueTask.CompletedTask;
    }
}

Example — plan tier from JWT claims:

public sealed class ClaimsEnricher : IEvaluationContextEnricher
{
    public ValueTask EnrichAsync(HttpContext context, EvaluationContextBuilder builder, CancellationToken ct)
    {
        var plan = context.User.FindFirstValue("plan");
        if (plan is not null)
            builder.Set("plan", plan);
        return ValueTask.CompletedTask;
    }
}

IExperimentClient — evaluation

public interface IExperimentClient
{
    ValueTask<VariantResult> GetVariantAsync(string flagKey, string subjectId, in EvaluationContext ctx, CancellationToken ct = default);

    ValueTask<bool>   GetBoolAsync  (string flagKey, string subjectId, in EvaluationContext ctx, bool   defaultValue = default, CancellationToken ct = default);
    ValueTask<string> GetStringAsync(string flagKey, string subjectId, in EvaluationContext ctx, string defaultValue = "",      CancellationToken ct = default);
    ValueTask<int>    GetIntAsync   (string flagKey, string subjectId, in EvaluationContext ctx, int    defaultValue = default, CancellationToken ct = default);
    ValueTask<double> GetDoubleAsync(string flagKey, string subjectId, in EvaluationContext ctx, double defaultValue = default, CancellationToken ct = default);
}

When to implement: Testing — mock or stub IExperimentClient to force variants in unit tests without running the real engine.

The concrete implementation (ExperimentClient) is registered as a singleton under both IExperimentClient and IConfigUpdater. It is internal; use the interfaces.

Example — test stub:

public sealed class StubExperimentClient : IExperimentClient
{
    private readonly Dictionary<string, string> _forced = new(StringComparer.Ordinal);

    public StubExperimentClient Force(string flag, string variant)
    {
        _forced[flag] = variant;
        return this;
    }

    public ValueTask<VariantResult> GetVariantAsync(
        string flagKey, string subjectId, in EvaluationContext ctx, CancellationToken ct)
    {
        var name = _forced.GetValueOrDefault(flagKey, "control");
        return ValueTask.FromResult(new VariantResult { VariantName = name, IsEligible = true });
    }

    public ValueTask<bool>   GetBoolAsync  (string f, string s, in EvaluationContext c, bool   d, CancellationToken ct) => ValueTask.FromResult(d);
    public ValueTask<string> GetStringAsync(string f, string s, in EvaluationContext c, string d, CancellationToken ct) => ValueTask.FromResult(d);
    public ValueTask<int>    GetIntAsync   (string f, string s, in EvaluationContext c, int    d, CancellationToken ct) => ValueTask.FromResult(d);
    public ValueTask<double> GetDoubleAsync(string f, string s, in EvaluationContext c, double d, CancellationToken ct) => ValueTask.FromResult(d);
}

ASP.NET Core integration

Setup

builder.Services
    .AddKomento(o => { o.Experiments = ["checkout-flow"]; })
    .AddSource<AppSettingsExperimentSource>();

builder.Services
    .AddKomentoAspNetCore()
    .AddSubjectProvider<UserSubjectProvider>()
    .AddEnricher<LocaleEnricher>();

[RequireVariant] — MVC action filter

Gate a controller action on a variant assignment. Returns 404 Not Found when the subject is not in the required variant.

[HttpGet("new-checkout")]
[RequireVariant("checkout-flow", "treatment")]
public IActionResult NewCheckout() => View();

Applied to an entire controller to gate all actions:

[RequireVariant("admin-ui", "enabled")]
[ApiController, Route("admin")]
public class AdminController : ControllerBase { ... }

.RequireVariant() — minimal API endpoint filter

app.MapGet("/new-checkout", NewCheckoutHandler)
   .RequireVariant("checkout-flow", "treatment");

EvaluationContext

EvaluationContext is an immutable snapshot of attributes used to evaluate filters. It is passed in everywhere — no struct copy on the hot path.

// Build a context:
var ctx = EvaluationContext.Create()
    .Set("platform", "android")
    .Set("country", "BR")
    .Build();

// Extend an existing context (e.g., layering per-request data onto a base):
var extended = EvaluationContextBuilder.CreateFrom(baseCtx)
    .Set("locale", "pt-BR")
    .Build();

Attributes set via KomentoOptions.StaticContext are merged automatically in Komento.AspNetCore before each evaluation. Request-level attributes from enrichers layer on top.


Performance

All read operations — variant lookup, filter evaluation, segment membership — are allocation-free on the hot path. BenchmarkDotNet (MemoryDiagnoser) shows 0 bytes allocated per GetVariantAsync call across all paths including the async segment filter path.

Key mechanisms:

  • FrozenDictionary<string, CompiledExperiment> — experiment map, lock-free reads.
  • ArrayPool<byte> — XxHash64 input buffer, no heap allocation per hash.
  • ValueTask<T> — synchronous results wrapped without Task allocation.
  • in EvaluationContext — struct passed by reference, never copied.
  • No LINQ, no closures in any hot-path method.

Config updates (UpdateAsync) are the only write operation. They build a new FrozenDictionary on a background thread and atomically swap the reference — in-flight reads are unaffected.


Development

# Build
dotnet build

# Tests
dotnet test

# Single test
dotnet test --filter "FullyQualifiedName~Unknown_experiment_returns_NotFound"

# Benchmarks (Release mode required)
dotnet run --project benchmarks/Komento.Benchmarks/ -c Release
Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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
0.2.0-alpha 76 6/10/2026
0.1.1-alpha 63 5/7/2026
0.1.0-alpha 59 5/1/2026

## Breaking changes

- ** removed** — the static list of experiment IDs is gone. Pass IDs explicitly to  or use  from the new source generator. Pass an empty set to load all experiments.

## New packages

- **** — Roslyn source generator that reads  from  and emits a  class with  and a typed  constant per experiment. Eliminates magic strings at compile time.

 
 

- **** — generic  backed by a named . Supply a mapper from your response type to ; all HTTP concerns (base address, auth, retry) are configured separately via the standard  pattern.

 

## New features

- **** — in-process test double for controlling flag state in tests without mocking . Fluent  /  API, same pattern as LaunchDarkly  and Unleash .

- **** — background service that polls the registered  on a  schedule. Resolves  from DI; inject  in tests for deterministic time control with no .

 

- **Auto-discovery** —  now loads all experiments when an empty set is passed. Small projects no longer need to enumerate IDs anywhere.

## CI

- Coveralls integration (replaced )
- Coverage scoped to  source assemblies only via