Komento.OpenFeature
0.2.0-alpha
dotnet add package Komento.OpenFeature --version 0.2.0-alpha
NuGet\Install-Package Komento.OpenFeature -Version 0.2.0-alpha
<PackageReference Include="Komento.OpenFeature" Version="0.2.0-alpha" />
<PackageVersion Include="Komento.OpenFeature" Version="0.2.0-alpha" />
<PackageReference Include="Komento.OpenFeature" />
paket add Komento.OpenFeature --version 0.2.0-alpha
#r "nuget: Komento.OpenFeature, 0.2.0-alpha"
#:package Komento.OpenFeature@0.2.0-alpha
#addin nuget:?package=Komento.OpenFeature&version=0.2.0-alpha&prerelease
#tool nuget:?package=Komento.OpenFeature&version=0.2.0-alpha&prerelease
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.OpenFeatureprovider package.
Packages
| Package | Purpose | NuGet |
|---|---|---|
| Komento | Core engine, all interfaces, DI registration |
|
| Komento.AspNetCore | ASP.NET Core integration (filters, subject provider, enrichers) |
|
| Komento.OpenFeature | OpenFeature provider adapter |
|
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 withoutTaskallocation.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 | Versions 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. |
-
net8.0
- Komento (>= 0.2.0-alpha)
- OpenFeature (>= 2.1.0)
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