The Rollout package provides deterministic, percentage-based traffic allocation for gradual feature rollouts. This enables safe deployment of new implementations by controlling what percentage of users receive the new variant.
dotnet add package ExperimentFramework.Rollout// 1. Register rollout support
services.AddExperimentRollout();
// 2. Provide an identity provider for consistent assignment
services.AddScoped<IRolloutIdentityProvider, UserIdProvider>();
// 3. Configure your experiment
var experiments = ExperimentFrameworkBuilder.Create()
.Define<IPaymentProcessor>(exp => exp
.UsingRollout(percentage: 25) // 25% get new implementation
.AddControl<LegacyPaymentProcessor>("legacy")
.AddCondition<NewPaymentProcessor>("new-v2"));
services.AddExperimentFramework(experiments);The rollout allocator uses a hash-based algorithm that ensures:
- Consistency: The same user always gets the same variant
- Determinism: Results are reproducible across application restarts
- Uniform distribution: Traffic is evenly distributed according to the percentage
// The algorithm combines identity + experiment name for assignment
public static bool IsIncluded(string identity, string selectorName, int percentage, string? seed = null)
{
var input = $"{seed ?? selectorName}:{identity}";
var hash = ComputeHash(input);
var bucket = (int)(hash % 100);
return bucket < percentage;
}You must implement IRolloutIdentityProvider to supply the identity used for bucketing:
public interface IRolloutIdentityProvider
{
ValueTask<string?> GetIdentityAsync(CancellationToken cancellationToken = default);
}
// Example implementation
public class UserIdProvider : IRolloutIdentityProvider
{
private readonly IHttpContextAccessor _httpContext;
public UserIdProvider(IHttpContextAccessor httpContext)
{
_httpContext = httpContext;
}
public ValueTask<string?> GetIdentityAsync(CancellationToken cancellationToken = default)
{
var userId = _httpContext.HttpContext?.User.FindFirst("sub")?.Value;
return ValueTask.FromResult(userId);
}
}services.AddExperimentRollout(options =>
{
options.Percentage = 50; // 50% rollout
options.Seed = "my-experiment"; // Optional: override bucket seed
options.IncludedKey = "new"; // Key for included users
options.ExcludedKey = "legacy"; // Key for excluded users
});.Define<ISearchService>(exp => exp
.UsingRollout(percentage: 10, includedKey: "elastic", excludedKey: "legacy")
.AddControl<SqlSearchService>("legacy")
.AddCondition<ElasticSearchService>("elastic"))For gradual rollouts over time, use staged rollout with scheduled percentage increases:
services.AddExperimentStagedRollout(options =>
{
options.Stages.Add(new RolloutStage
{
StartsAt = DateTimeOffset.UtcNow,
Percentage = 5
});
options.Stages.Add(new RolloutStage
{
StartsAt = DateTimeOffset.UtcNow.AddDays(1),
Percentage = 25
});
options.Stages.Add(new RolloutStage
{
StartsAt = DateTimeOffset.UtcNow.AddDays(3),
Percentage = 50
});
options.Stages.Add(new RolloutStage
{
StartsAt = DateTimeOffset.UtcNow.AddDays(7),
Percentage = 100
});
});.Define<INotificationService>(exp => exp
.UsingStagedRollout(stages: new[]
{
(DateTimeOffset.UtcNow, 10),
(DateTimeOffset.UtcNow.AddDays(1), 50),
(DateTimeOffset.UtcNow.AddDays(7), 100)
})
.AddControl<EmailNotificationService>("email")
.AddCondition<PushNotificationService>("push"))Enable YAML/JSON configuration for rollouts:
services.AddExperimentRolloutConfiguration();
services.AddExperimentRollout();
services.AddExperimentFrameworkFromConfiguration(configuration);experimentFramework:
trials:
- serviceType: IPaymentProcessor
selectionMode:
type: rollout
percentage: 50
seed: payment-v2-experiment
includedKey: new-processor
excludedKey: legacy-processor
variants:
- key: legacy-processor
implementationType: LegacyPaymentProcessor
isControl: true
- key: new-processor
implementationType: NewPaymentProcessorexperimentFramework:
trials:
- serviceType: ISearchService
selectionMode:
type: stagedRollout
stages:
- startsAt: "2024-01-01T00:00:00Z"
percentage: 10
- startsAt: "2024-01-08T00:00:00Z"
percentage: 50
- startsAt: "2024-01-15T00:00:00Z"
percentage: 100
variants:
- key: legacy
implementationType: SqlSearchService
isControl: true
- key: elastic
implementationType: ElasticSearchServiceGradually migrate from one database to another:
public class DatabaseMigrationIdentityProvider : IRolloutIdentityProvider
{
private readonly IHttpContextAccessor _httpContext;
public ValueTask<string?> GetIdentityAsync(CancellationToken cancellationToken = default)
{
// Use tenant ID for B2B scenarios
var tenantId = _httpContext.HttpContext?.Request.Headers["X-Tenant-Id"].FirstOrDefault();
return ValueTask.FromResult(tenantId);
}
}
// Configure staged migration
services.AddExperimentStagedRollout(opts =>
{
opts.Stages.Add(new RolloutStage { StartsAt = DateTimeOffset.UtcNow, Percentage = 1 });
opts.Stages.Add(new RolloutStage { StartsAt = DateTimeOffset.UtcNow.AddDays(7), Percentage = 10 });
opts.Stages.Add(new RolloutStage { StartsAt = DateTimeOffset.UtcNow.AddDays(14), Percentage = 50 });
opts.Stages.Add(new RolloutStage { StartsAt = DateTimeOffset.UtcNow.AddMonths(1), Percentage = 100 });
});
var experiments = ExperimentFrameworkBuilder.Create()
.Define<IOrderRepository>(exp => exp
.UsingStagedRollout()
.AddControl<SqlOrderRepository>("sql")
.AddCondition<CosmosOrderRepository>("cosmos"));.Define<IExternalApiClient>(exp => exp
.UsingRollout(percentage: 20)
.AddControl<ApiClientV1>("v1")
.AddCondition<ApiClientV2>("v2"))- Start small: Begin with 1-5% rollout to catch issues early
- Use meaningful seeds: Seed names help with debugging and reproducibility
- Monitor closely: Combine with telemetry to track variant performance
- Plan rollback: Have a strategy to quickly reduce percentage if issues arise
- Consider identity: Choose identity carefully - user ID, session ID, or tenant ID based on your use case
Symptom: Users report inconsistent behavior, seeing different variants across requests.
Cause: Identity provider returning different values for the same user.
Solution: Ensure your identity provider returns a stable, consistent identifier:
// Bad: Session-based identity changes across sessions
return httpContext.Session.Id;
// Good: User ID is consistent
return httpContext.User.FindFirst("sub")?.Value;Symptom: Observing ~45% rollout when configured for 50%.
Cause: Hash distribution has natural variance, especially with small sample sizes.
Solution: This is expected statistical behavior. With larger user bases (10,000+), distribution approaches the configured percentage.
Symptom: All traffic goes to control variant.
Cause: Identity provider returning null.
Solution: Handle anonymous users explicitly:
public ValueTask<string?> GetIdentityAsync(CancellationToken cancellationToken = default)
{
var userId = _httpContext.HttpContext?.User.FindFirst("sub")?.Value;
// Fall back to a session or cookie-based identity for anonymous users
if (string.IsNullOrEmpty(userId))
{
userId = _httpContext.HttpContext?.Request.Cookies["visitor_id"];
}
return ValueTask.FromResult(userId);
}