Advanced Topics
This guide covers advanced patterns and techniques for customizing and extending ExperimentFramework.
Custom Decorators
Decorators provide cross-cutting concerns without modifying condition implementations. You can create custom decorators to add functionality like caching, retry logic, or custom telemetry.
Implementing IExperimentDecorator
Decorators must implement the IExperimentDecorator interface:
public interface IExperimentDecorator
{
ValueTask<object?> InvokeAsync(
InvocationContext context,
Func<ValueTask<object?>> next);
}
The InvocationContext provides information about the current invocation:
public sealed class InvocationContext
{
public Type ServiceType { get; }
public string MethodName { get; }
public object?[] Arguments { get; }
public string ConditionKey { get; }
}
Example: Caching Decorator
Implement a decorator that caches method results:
public class CachingDecorator : IExperimentDecorator
{
private readonly IMemoryCache _cache;
private readonly TimeSpan _cacheDuration;
public CachingDecorator(IMemoryCache cache, TimeSpan cacheDuration)
{
_cache = cache;
_cacheDuration = cacheDuration;
}
public async ValueTask<object?> InvokeAsync(
InvocationContext context,
Func<ValueTask<object?>> next)
{
var cacheKey = BuildCacheKey(context);
if (_cache.TryGetValue(cacheKey, out object? cachedResult))
{
return cachedResult;
}
var result = await next().ConfigureAwait(false);
_cache.Set(cacheKey, result, _cacheDuration);
return result;
}
private static string BuildCacheKey(InvocationContext context)
{
var argsHash = string.Join(",", context.Arguments.Select(a => a?.GetHashCode() ?? 0));
return $"{context.ServiceType.Name}:{context.MethodName}:{context.ConditionKey}:{argsHash}";
}
}
Decorator Factory
Create a factory to instantiate decorators with dependencies from DI:
public class CachingDecoratorFactory : IExperimentDecoratorFactory
{
private readonly TimeSpan _cacheDuration;
public CachingDecoratorFactory(TimeSpan cacheDuration)
{
_cacheDuration = cacheDuration;
}
public IExperimentDecorator Create(IServiceProvider serviceProvider)
{
var cache = serviceProvider.GetRequiredService<IMemoryCache>();
return new CachingDecorator(cache, _cacheDuration);
}
}
Register the decorator:
var experiments = ExperimentFrameworkBuilder.Create()
.AddDecoratorFactory(new CachingDecoratorFactory(TimeSpan.FromMinutes(5)))
.Trial<IDatabase>(t => t
.UsingFeatureFlag("UseCloudDb")
.AddControl<LocalDatabase>("false")
.AddCondition<CloudDatabase>("true"));
services.AddMemoryCache();
services.AddExperimentFramework(experiments);
Example: Retry Decorator
Implement retry logic for transient failures:
public class RetryDecorator : IExperimentDecorator
{
private readonly int _maxAttempts;
private readonly TimeSpan _delay;
private readonly ILogger<RetryDecorator> _logger;
public RetryDecorator(int maxAttempts, TimeSpan delay, ILogger<RetryDecorator> logger)
{
_maxAttempts = maxAttempts;
_delay = delay;
_logger = logger;
}
public async ValueTask<object?> InvokeAsync(
InvocationContext context,
Func<ValueTask<object?>> next)
{
for (int attempt = 1; attempt <= _maxAttempts; attempt++)
{
try
{
return await next().ConfigureAwait(false);
}
catch (Exception ex) when (IsTransient(ex) && attempt < _maxAttempts)
{
_logger.LogWarning(ex,
"Transient failure in {ServiceType}.{Method} (attempt {Attempt}/{Max})",
context.ServiceType.Name, context.MethodName, attempt, _maxAttempts);
await Task.Delay(_delay).ConfigureAwait(false);
}
}
// Final attempt - let exception propagate
return await next().ConfigureAwait(false);
}
private static bool IsTransient(Exception ex)
{
return ex is TimeoutException
|| ex is HttpRequestException
|| (ex.InnerException != null && IsTransient(ex.InnerException));
}
}
Example: Circuit Breaker Decorator
Implement a circuit breaker pattern:
public class CircuitBreakerDecorator : IExperimentDecorator
{
private readonly int _failureThreshold;
private readonly TimeSpan _resetTimeout;
private int _consecutiveFailures;
private DateTime _lastFailureTime;
private bool _isOpen;
public CircuitBreakerDecorator(int failureThreshold, TimeSpan resetTimeout)
{
_failureThreshold = failureThreshold;
_resetTimeout = resetTimeout;
}
public async ValueTask<object?> InvokeAsync(
InvocationContext context,
Func<ValueTask<object?>> next)
{
// Check if circuit should reset
if (_isOpen && DateTime.UtcNow - _lastFailureTime > _resetTimeout)
{
_isOpen = false;
_consecutiveFailures = 0;
}
if (_isOpen)
{
throw new InvalidOperationException(
$"Circuit breaker is open for {context.ServiceType.Name}.{context.MethodName}");
}
try
{
var result = await next().ConfigureAwait(false);
_consecutiveFailures = 0;
return result;
}
catch (Exception)
{
_consecutiveFailures++;
_lastFailureTime = DateTime.UtcNow;
if (_consecutiveFailures >= _failureThreshold)
{
_isOpen = true;
}
throw;
}
}
}
Decorator Pipeline Order
Decorators execute in registration order. The order matters:
var experiments = ExperimentFrameworkBuilder.Create()
.AddDecoratorFactory(new CircuitBreakerFactory()) // First: Check circuit
.AddDecoratorFactory(new RetryFactory()) // Second: Retry if needed
.AddDecoratorFactory(new CachingFactory()) // Third: Cache successful results
.AddLogger(l => l.AddBenchmarks()) // Fourth: Measure total time
.Trial<IDatabase>(t => t
.UsingFeatureFlag("UseCloudDb")
.AddControl<LocalDatabase>("false")
.AddCondition<CloudDatabase>("true"));
Execution flow:
Request
└─ Circuit Breaker (checks if open)
└─ Retry (handles transient failures)
└─ Caching (checks cache, stores result)
└─ Benchmark (measures time)
└─ Condition Execution
Request-Scoped Consistency
Ensuring consistent condition selection within a request scope is critical for correctness.
Using IFeatureManagerSnapshot
For scoped services, use IFeatureManagerSnapshot to ensure consistent feature evaluation:
public class OrderService
{
private readonly IDatabase _database;
private readonly IPaymentProcessor _payment;
public OrderService(IDatabase database, IPaymentProcessor payment)
{
_database = database;
_payment = payment;
}
public async Task ProcessOrderAsync(Order order)
{
// Both calls within this scope use the same feature flag evaluation
var customer = await _database.GetCustomerAsync(order.CustomerId);
await _database.SaveOrderAsync(order);
// Payment processor also uses consistent evaluation
await _payment.ChargeAsync(order.Total);
}
}
Register services and feature management:
services.AddScoped<OrderService>();
services.AddFeatureManagement();
var experiments = ExperimentFrameworkBuilder.Create()
.Trial<IDatabase>(t => t
.UsingFeatureFlag("UseCloudDb")
.AddControl<LocalDatabase>("false")
.AddCondition<CloudDatabase>("true"))
.Trial<IPaymentProcessor>(t => t
.UsingFeatureFlag("UseNewPayment")
.AddControl<StripePayment>("false")
.AddCondition<NewPaymentProvider>("true"));
services.AddExperimentFramework(experiments);
Scoped Identity Provider
For sticky routing, implement a scoped identity provider:
public class HttpContextIdentityProvider : IExperimentIdentityProvider
{
private readonly IHttpContextAccessor _httpContext;
private string? _cachedIdentity;
public HttpContextIdentityProvider(IHttpContextAccessor httpContext)
{
_httpContext = httpContext;
}
public bool TryGetIdentity(out string identity)
{
if (_cachedIdentity != null)
{
identity = _cachedIdentity;
return true;
}
var userId = _httpContext.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userId))
{
_cachedIdentity = userId;
identity = userId;
return true;
}
identity = string.Empty;
return false;
}
}
Register as scoped:
services.AddHttpContextAccessor();
services.AddScoped<IExperimentIdentityProvider, HttpContextIdentityProvider>();
This ensures the same user identity is used for all sticky routing decisions within a request.
Multi-Tenant Scenarios
ExperimentFramework supports multi-tenant applications through custom naming conventions and identity providers.
Tenant-Aware Naming Convention
public class TenantNamingConvention : IExperimentNamingConvention
{
private readonly ITenantProvider _tenantProvider;
public TenantNamingConvention(ITenantProvider tenantProvider)
{
_tenantProvider = tenantProvider;
}
public string FeatureFlagNameFor(Type serviceType)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
return $"{tenantId}_{serviceType.Name}";
}
public string VariantFlagNameFor(Type serviceType)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
return $"{tenantId}_{serviceType.Name}_Variants";
}
public string ConfigurationKeyFor(Type serviceType)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
return $"Tenants:{tenantId}:Experiments:{serviceType.Name}";
}
}
Configuration per tenant:
{
"Tenants": {
"tenant-a": {
"Experiments": {
"IDatabase": "postgres"
}
},
"tenant-b": {
"Experiments": {
"IDatabase": "mysql"
}
}
}
}
Tenant-Aware Identity Provider
For sticky routing in multi-tenant scenarios:
public class TenantUserIdentityProvider : IExperimentIdentityProvider
{
private readonly ITenantProvider _tenantProvider;
private readonly IHttpContextAccessor _httpContext;
public TenantUserIdentityProvider(
ITenantProvider tenantProvider,
IHttpContextAccessor httpContext)
{
_tenantProvider = tenantProvider;
_httpContext = httpContext;
}
public bool TryGetIdentity(out string identity)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var userId = _httpContext.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(tenantId) && !string.IsNullOrEmpty(userId))
{
identity = $"{tenantId}:{userId}";
return true;
}
identity = string.Empty;
return false;
}
}
This ensures users in different tenants can receive different condition assignments.
Performance Considerations
Minimizing Overhead
The framework is designed for minimal overhead, but you can optimize further:
Use Singleton Services When Possible
// Singleton services avoid proxy creation overhead per request
services.AddSingleton<ICache, InMemoryCache>();
var experiments = ExperimentFrameworkBuilder.Create()
.Trial<ICache>(t => t
.UsingFeatureFlag("UseRedisCache")
.AddControl<InMemoryCache>("false")
.AddCondition<RedisCache>("true"));
Disable Telemetry in Production
If you're not using telemetry, the default no-op implementation has near-zero overhead:
// No need to register OpenTelemetry if not using it
// Default NoopExperimentTelemetry is automatically registered
Cache Configuration Values
For configuration-based selection, .NET's IConfiguration already caches values. No additional caching needed.
Benchmarking
Measure the overhead of your experiment setup:
[MemoryDiagnoser]
public class ExperimentBenchmark
{
private IServiceProvider _serviceProvider;
[GlobalSetup]
public void Setup()
{
var services = new ServiceCollection();
services.AddScoped<LocalDatabase>();
services.AddScoped<CloudDatabase>();
var experiments = ExperimentFrameworkBuilder.Create()
.Trial<IDatabase>(t => t
.UsingFeatureFlag("UseCloudDb")
.AddControl<LocalDatabase>("false")
.AddCondition<CloudDatabase>("true"));
services.AddExperimentFramework(experiments);
_serviceProvider = services.BuildServiceProvider();
}
[Benchmark]
public async Task<string> InvokeExperiment()
{
using var scope = _serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IDatabase>();
return await db.GetConnectionStringAsync();
}
}
Testing Strategies
Testing with Feature Flags
Override feature flag values in tests:
public class OrderServiceTests
{
[Fact]
public async Task ProcessOrder_uses_cloud_database_when_flag_enabled()
{
// Arrange
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["FeatureManagement:UseCloudDb"] = "true"
})
.Build();
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(config);
services.AddFeatureManagement();
services.AddScoped<LocalDatabase>();
services.AddScoped<CloudDatabase>();
services.AddScoped<OrderService>();
var experiments = ExperimentFrameworkBuilder.Create()
.Trial<IDatabase>(t => t
.UsingFeatureFlag("UseCloudDb")
.AddControl<LocalDatabase>("false")
.AddCondition<CloudDatabase>("true"));
services.AddExperimentFramework(experiments);
var serviceProvider = services.BuildServiceProvider();
// Act
using var scope = serviceProvider.CreateScope();
var orderService = scope.ServiceProvider.GetRequiredService<OrderService>();
await orderService.ProcessOrderAsync(new Order());
// Assert
var db = scope.ServiceProvider.GetRequiredService<IDatabase>();
Assert.IsType<CloudDatabase>(db);
}
}
Testing with Mock Identity Provider
For sticky routing tests:
public class StickyRoutingTests
{
[Fact]
public async Task StickyRouting_assigns_same_user_to_same_condition()
{
// Arrange
var services = new ServiceCollection();
services.AddScoped<IExperimentIdentityProvider>(_ =>
new FixedIdentityProvider("user-123"));
services.AddScoped<ContentBased>();
services.AddScoped<CollaborativeFiltering>();
var experiments = ExperimentFrameworkBuilder.Create()
.Trial<IRecommendationEngine>(t => t
.UsingStickyRouting("RecommendationExperiment")
.AddControl<ContentBased>("control")
.AddVariant<CollaborativeFiltering>("variant-a"));
services.AddExperimentFramework(experiments);
var serviceProvider = services.BuildServiceProvider();
// Act - Multiple invocations
string? firstResult, secondResult;
using (var scope = serviceProvider.CreateScope())
{
var engine = scope.ServiceProvider.GetRequiredService<IRecommendationEngine>();
firstResult = await engine.GetRecommendationsAsync("product-1");
}
using (var scope = serviceProvider.CreateScope())
{
var engine = scope.ServiceProvider.GetRequiredService<IRecommendationEngine>();
secondResult = await engine.GetRecommendationsAsync("product-1");
}
// Assert
Assert.Equal(firstResult, secondResult);
}
private sealed class FixedIdentityProvider : IExperimentIdentityProvider
{
private readonly string _identity;
public FixedIdentityProvider(string identity)
{
_identity = identity;
}
public bool TryGetIdentity(out string identity)
{
identity = _identity;
return true;
}
}
}
Testing Condition Implementations Directly
Test condition implementations independently of the framework:
public class CloudDatabaseTests
{
[Fact]
public async Task GetCustomersAsync_returns_all_customers()
{
// Arrange
var logger = new Mock<ILogger<CloudDatabase>>();
var config = new Mock<IConfiguration>();
var database = new CloudDatabase(logger.Object, config.Object);
// Act
var customers = await database.GetCustomersAsync();
// Assert
Assert.NotEmpty(customers);
Assert.All(customers, c =>
{
Assert.NotNull(c.Name);
Assert.NotNull(c.Email);
});
}
}
This approach tests the actual implementation without experiment overhead.
Custom Telemetry Providers
Integrate with your preferred observability platform.
Datadog Integration
public class DatadogExperimentTelemetry : IExperimentTelemetry
{
private readonly IMetrics _metrics;
public DatadogExperimentTelemetry(IMetrics metrics)
{
_metrics = metrics;
}
public IExperimentTelemetryScope StartInvocation(
Type serviceType,
string methodName,
string selectorName,
string conditionKey,
IReadOnlyList<string> candidateKeys)
{
return new DatadogScope(_metrics, serviceType, methodName, conditionKey);
}
private class DatadogScope : IExperimentTelemetryScope
{
private readonly IMetrics _metrics;
private readonly Type _serviceType;
private readonly string _methodName;
private readonly string _conditionKey;
private readonly long _startTimestamp;
private string _outcome = "success";
public DatadogScope(IMetrics metrics, Type serviceType, string methodName, string conditionKey)
{
_metrics = metrics;
_serviceType = serviceType;
_methodName = methodName;
_conditionKey = conditionKey;
_startTimestamp = Stopwatch.GetTimestamp();
}
public void RecordSuccess()
{
_outcome = "success";
}
public void RecordFailure(Exception exception)
{
_outcome = "failure";
}
public void RecordFallback(string fallbackKey)
{
_metrics.Increment("experiment.fallback",
tags: new[] { $"service:{_serviceType.Name}", $"condition:{_conditionKey}" });
}
public void RecordVariant(string variantName, string variantSource)
{
// Record variant metrics if needed
}
public void Dispose()
{
var elapsed = Stopwatch.GetElapsedTime(_startTimestamp).TotalMilliseconds;
_metrics.Histogram("experiment.duration", elapsed,
tags: new[]
{
$"service:{_serviceType.Name}",
$"method:{_methodName}",
$"condition:{_conditionKey}",
$"outcome:{_outcome}"
});
_metrics.Increment("experiment.invocations",
tags: new[]
{
$"service:{_serviceType.Name}",
$"condition:{_conditionKey}",
$"outcome:{_outcome}"
});
}
}
}
Register with DI:
services.AddSingleton<IExperimentTelemetry, DatadogExperimentTelemetry>();
Next Steps
- Samples - Complete working examples of advanced patterns
- Plugin System - Load experiment implementations from external DLLs
- YAML/JSON Configuration - Define experiments declaratively
- Core Concepts - Review fundamental framework concepts
- Telemetry - Learn about built-in telemetry options