Overview
A/B testing (also known as split testing) is a method to compare two versions of something to determine which performs better. This framework provides a complete solution for implementing A/B tests in Java applications.
Core Components
- Experiment Management: Define and manage A/B tests
- User Allocation: Deterministic user bucketing
- Metrics Tracking: Capture and analyze results
- Statistical Analysis: Calculate significance and confidence
- Feature Flags: Gradual rollouts and targeting
Basic Framework Structure
1. Core Domain Classes
import java.time.LocalDateTime;
import java.util.*;
// Experiment definition
public class Experiment {
private final String id;
private final String name;
private final String description;
private final List<Variant> variants;
private final LocalDateTime startDate;
private final LocalDateTime endDate;
private final boolean enabled;
private final TargetingRules targetingRules;
private final double trafficAllocation; // 0.0 to 1.0
public Experiment(String id, String name, List<Variant> variants,
LocalDateTime startDate, LocalDateTime endDate,
boolean enabled, TargetingRules targetingRules,
double trafficAllocation) {
this.id = id;
this.name = name;
this.variants = variants;
this.startDate = startDate;
this.endDate = endDate;
this.enabled = enabled;
this.targetingRules = targetingRules;
this.trafficAllocation = trafficAllocation;
}
// Getters
public String getId() { return id; }
public String getName() { return name; }
public List<Variant> getVariants() { return variants; }
public boolean isEnabled() { return enabled; }
public boolean isActive() {
LocalDateTime now = LocalDateTime.now();
return enabled && !now.isBefore(startDate) && !now.isAfter(endDate);
}
}
// Variant (A, B, C, etc.)
public class Variant {
private final String id;
private final String name;
private final double weight; // Allocation weight (0.0 to 1.0)
private final Map<String, Object> parameters;
public Variant(String id, String name, double weight, Map<String, Object> parameters) {
this.id = id;
this.name = name;
this.weight = weight;
this.parameters = parameters != null ? parameters : new HashMap<>();
}
// Getters
public String getId() { return id; }
public String getName() { return name; }
public double getWeight() { return weight; }
public Map<String, Object> getParameters() { return parameters; }
public <T> T getParameter(String key, Class<T> type) {
Object value = parameters.get(key);
return type.isInstance(value) ? type.cast(value) : null;
}
public <T> T getParameter(String key, Class<T> type, T defaultValue) {
T value = getParameter(key, type);
return value != null ? value : defaultValue;
}
}
// User context for targeting
public class UserContext {
private final String userId;
private final String sessionId;
private final Map<String, Object> attributes;
private final String country;
private final String language;
private final String deviceType;
private final String platform;
public UserContext(String userId, String sessionId, Map<String, Object> attributes,
String country, String language, String deviceType, String platform) {
this.userId = userId;
this.sessionId = sessionId;
this.attributes = attributes != null ? attributes : new HashMap<>();
this.country = country;
this.language = language;
this.deviceType = deviceType;
this.platform = platform;
}
// Builder pattern for easy creation
public static class Builder {
private String userId;
private String sessionId;
private Map<String, Object> attributes = new HashMap<>();
private String country;
private String language;
private String deviceType;
private String platform;
public Builder withUserId(String userId) { this.userId = userId; return this; }
public Builder withSessionId(String sessionId) { this.sessionId = sessionId; return this; }
public Builder withAttribute(String key, Object value) { this.attributes.put(key, value); return this; }
public Builder withCountry(String country) { this.country = country; return this; }
public Builder withLanguage(String language) { this.language = language; return this; }
public Builder withDeviceType(String deviceType) { this.deviceType = deviceType; return this; }
public Builder withPlatform(String platform) { this.platform = platform; return this; }
public UserContext build() {
return new UserContext(userId, sessionId, attributes, country, language, deviceType, platform);
}
}
// Getters
public String getUserId() { return userId; }
public String getSessionId() { return sessionId; }
public Map<String, Object> getAttributes() { return attributes; }
public String getCountry() { return country; }
public String getLanguage() { return language; }
public String getDeviceType() { return deviceType; }
public String getPlatform() { return platform; }
public <T> T getAttribute(String key, Class<T> type) {
Object value = attributes.get(key);
return type.isInstance(value) ? type.cast(value) : null;
}
}
// Targeting rules
public class TargetingRules {
private final Set<String> includedCountries;
private final Set<String> excludedCountries;
private final Set<String> includedLanguages;
private final Set<String> includedPlatforms;
private final Set<String> includedDeviceTypes;
private final Map<String, Object> userAttributes;
private final double userPercentage; // Percentage of users to include (0.0 to 1.0)
public TargetingRules(Set<String> includedCountries, Set<String> excludedCountries,
Set<String> includedLanguages, Set<String> includedPlatforms,
Set<String> includedDeviceTypes, Map<String, Object> userAttributes,
double userPercentage) {
this.includedCountries = includedCountries != null ? includedCountries : new HashSet<>();
this.excludedCountries = excludedCountries != null ? excludedCountries : new HashSet<>();
this.includedLanguages = includedLanguages != null ? includedLanguages : new HashSet<>();
this.includedPlatforms = includedPlatforms != null ? includedPlatforms : new HashSet<>();
this.includedDeviceTypes = includedDeviceTypes != null ? includedDeviceTypes : new HashSet<>();
this.userAttributes = userAttributes != null ? userAttributes : new HashMap<>();
this.userPercentage = userPercentage;
}
public boolean matches(UserContext user) {
// Check country
if (!includedCountries.isEmpty() && !includedCountries.contains(user.getCountry())) {
return false;
}
if (!excludedCountries.isEmpty() && excludedCountries.contains(user.getCountry())) {
return false;
}
// Check language
if (!includedLanguages.isEmpty() && !includedLanguages.contains(user.getLanguage())) {
return false;
}
// Check platform
if (!includedPlatforms.isEmpty() && !includedPlatforms.contains(user.getPlatform())) {
return false;
}
// Check device type
if (!includedDeviceTypes.isEmpty() && !includedDeviceTypes.contains(user.getDeviceType())) {
return false;
}
// Check user attributes
for (Map.Entry<String, Object> entry : userAttributes.entrySet()) {
Object userValue = user.getAttributes().get(entry.getKey());
if (!Objects.equals(userValue, entry.getValue())) {
return false;
}
}
// Check user percentage (for gradual rollouts)
if (userPercentage < 1.0 && user.getUserId() != null) {
double userHash = Math.abs(user.getUserId().hashCode() % 10000) / 10000.0;
if (userHash > userPercentage) {
return false;
}
}
return true;
}
}
2. Experiment Service
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
public class ExperimentService {
private final ExperimentRepository repository;
private final MetricsService metricsService;
private final Map<String, Experiment> activeExperiments;
public ExperimentService(ExperimentRepository repository, MetricsService metricsService) {
this.repository = repository;
this.metricsService = metricsService;
this.activeExperiments = new ConcurrentHashMap<>();
loadActiveExperiments();
}
public Assignment assignVariant(String experimentId, UserContext user) {
Experiment experiment = activeExperiments.get(experimentId);
if (experiment == null || !experiment.isActive()) {
return Assignment.notInExperiment(experimentId);
}
// Check targeting rules
if (!experiment.getTargetingRules().matches(user)) {
return Assignment.notTargeted(experimentId);
}
// Check traffic allocation
if (experiment.getTrafficAllocation() < 1.0) {
double trafficHash = hashToRange(user.getUserId() + experimentId, 1.0);
if (trafficHash > experiment.getTrafficAllocation()) {
return Assignment.trafficExcluded(experimentId);
}
}
// Assign variant based on weights
Variant variant = assignVariantByWeight(experiment, user);
return Assignment.assigned(experimentId, variant);
}
private Variant assignVariantByWeight(Experiment experiment, UserContext user) {
List<Variant> variants = experiment.getVariants();
String bucketingKey = user.getUserId() != null ? user.getUserId() : user.getSessionId();
double hash = hashToRange(bucketingKey + experiment.getId(), 1.0);
double cumulativeWeight = 0.0;
for (Variant variant : variants) {
cumulativeWeight += variant.getWeight();
if (hash <= cumulativeWeight) {
return variant;
}
}
// Fallback to first variant
return variants.get(0);
}
private double hashToRange(String input, double max) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes());
long hash = 0;
for (int i = 0; i < 8; i++) {
hash = (hash << 8) | (digest[i] & 0xFF);
}
return (Math.abs(hash) % 10000) / 10000.0 * max;
} catch (NoSuchAlgorithmException e) {
// Fallback to simple hash
return Math.abs(input.hashCode() % 10000) / 10000.0 * max;
}
}
public void trackEvent(String experimentId, String variantId, UserContext user, String event) {
metricsService.trackEvent(experimentId, variantId, user, event);
}
public void trackConversion(String experimentId, String variantId, UserContext user,
String conversionEvent, double value) {
metricsService.trackConversion(experimentId, variantId, user, conversionEvent, value);
}
private void loadActiveExperiments() {
List<Experiment> experiments = repository.findActiveExperiments();
activeExperiments.clear();
for (Experiment experiment : experiments) {
activeExperiments.put(experiment.getId(), experiment);
}
}
public void refreshExperiments() {
loadActiveExperiments();
}
}
// Assignment result
public class Assignment {
private final String experimentId;
private final Variant variant;
private final AssignmentStatus status;
private final String reason;
public enum AssignmentStatus {
ASSIGNED, NOT_IN_EXPERIMENT, NOT_TARGETED, TRAFFIC_EXCLUDED
}
private Assignment(String experimentId, Variant variant, AssignmentStatus status, String reason) {
this.experimentId = experimentId;
this.variant = variant;
this.status = status;
this.reason = reason;
}
public static Assignment assigned(String experimentId, Variant variant) {
return new Assignment(experimentId, variant, AssignmentStatus.ASSIGNED, "User assigned to variant");
}
public static Assignment notInExperiment(String experimentId) {
return new Assignment(experimentId, null, AssignmentStatus.NOT_IN_EXPERIMENT, "Experiment not found or inactive");
}
public static Assignment notTargeted(String experimentId) {
return new Assignment(experimentId, null, AssignmentStatus.NOT_TARGETED, "User does not match targeting rules");
}
public static Assignment trafficExcluded(String experimentId) {
return new Assignment(experimentId, null, AssignmentStatus.TRAFFIC_EXCLUDED, "User excluded by traffic allocation");
}
// Getters
public String getExperimentId() { return experimentId; }
public Variant getVariant() { return variant; }
public AssignmentStatus getStatus() { return status; }
public String getReason() { return reason; }
public boolean isAssigned() { return status == AssignmentStatus.ASSIGNED; }
}
Spring Boot Integration
1. Configuration and Beans
@Configuration
@EnableConfigurationProperties(ABTestingProperties.class)
public class ABTestingConfig {
@Bean
@ConditionalOnMissingBean
public ExperimentRepository experimentRepository() {
return new InMemoryExperimentRepository();
}
@Bean
@ConditionalOnMissingBean
public MetricsService metricsService() {
return new InMemoryMetricsService();
}
@Bean
public ExperimentService experimentService(ExperimentRepository repository,
MetricsService metricsService) {
return new ExperimentService(repository, metricsService);
}
@Bean
public ExperimentManager experimentManager(ExperimentService experimentService) {
return new ExperimentManager(experimentService);
}
}
@ConfigurationProperties(prefix = "ab-testing")
public class ABTestingProperties {
private boolean enabled = true;
private String defaultVariant = "control";
private int refreshIntervalSeconds = 60;
private Map<String, ExperimentConfig> experiments = new HashMap<>();
// Getters and setters
public static class ExperimentConfig {
private String name;
private boolean enabled;
private double trafficAllocation = 1.0;
private List<VariantConfig> variants;
private TargetingConfig targeting;
// Getters and setters
}
public static class VariantConfig {
private String id;
private String name;
private double weight = 1.0;
private Map<String, Object> parameters;
// Getters and setters
}
public static class TargetingConfig {
private Set<String> includedCountries;
private Set<String> excludedCountries;
private Set<String> includedLanguages;
private Set<String> includedPlatforms;
private Set<String> includedDeviceTypes;
private Map<String, Object> userAttributes;
private double userPercentage = 1.0;
// Getters and setters
}
}
2. Experiment Manager
@Service
public class ExperimentManager {
private final ExperimentService experimentService;
private final Map<String, Experiment> experiments = new ConcurrentHashMap<>();
public ExperimentManager(ExperimentService experimentService) {
this.experimentService = experimentService;
}
public <T> T getVariantParameter(String experimentId, UserContext user,
String parameterKey, Class<T> parameterType, T defaultValue) {
Assignment assignment = experimentService.assignVariant(experimentId, user);
if (assignment.isAssigned()) {
Variant variant = assignment.getVariant();
T value = variant.getParameter(parameterKey, parameterType);
return value != null ? value : defaultValue;
}
return defaultValue;
}
public boolean isInExperiment(String experimentId, UserContext user) {
Assignment assignment = experimentService.assignVariant(experimentId, user);
return assignment.isAssigned();
}
public void trackEvent(String experimentId, UserContext user, String event) {
Assignment assignment = experimentService.assignVariant(experimentId, user);
if (assignment.isAssigned()) {
experimentService.trackEvent(experimentId, assignment.getVariant().getId(), user, event);
}
}
public void trackConversion(String experimentId, UserContext user,
String conversionEvent, double value) {
Assignment assignment = experimentService.assignVariant(experimentId, user);
if (assignment.isAssigned()) {
experimentService.trackConversion(experimentId, assignment.getVariant().getId(),
user, conversionEvent, value);
}
}
// Method for common A/B test scenarios
public void trackButtonClick(String experimentId, UserContext user, String buttonId) {
trackEvent(experimentId, user, "button_click." + buttonId);
}
public void trackPageView(String experimentId, UserContext user, String page) {
trackEvent(experimentId, user, "page_view." + page);
}
public void trackPurchase(String experimentId, UserContext user, double amount) {
trackConversion(experimentId, user, "purchase", amount);
}
public void trackSignup(String experimentId, UserContext user) {
trackConversion(experimentId, user, "signup", 1.0);
}
}
Web Application Integration
1. Spring MVC Controller
@RestController
@RequestMapping("/api/experiments")
public class ExperimentController {
private final ExperimentManager experimentManager;
private final UserContextService userContextService;
public ExperimentController(ExperimentManager experimentManager,
UserContextService userContextService) {
this.experimentManager = experimentManager;
this.userContextService = userContextService;
}
@GetMapping("/{experimentId}/variant")
public ResponseEntity<AssignmentResponse> getVariant(
@PathVariable String experimentId,
HttpServletRequest request) {
UserContext userContext = userContextService.createFromRequest(request);
Assignment assignment = experimentManager.getExperimentService()
.assignVariant(experimentId, userContext);
return ResponseEntity.ok(AssignmentResponse.fromAssignment(assignment));
}
@PostMapping("/{experimentId}/events")
public ResponseEntity<Void> trackEvent(
@PathVariable String experimentId,
@RequestBody TrackEventRequest request,
HttpServletRequest httpRequest) {
UserContext userContext = userContextService.createFromRequest(httpRequest);
experimentManager.trackEvent(experimentId, userContext, request.getEvent());
return ResponseEntity.ok().build();
}
@PostMapping("/{experimentId}/conversions")
public ResponseEntity<Void> trackConversion(
@PathVariable String experimentId,
@RequestBody TrackConversionRequest request,
HttpServletRequest httpRequest) {
UserContext userContext = userContextService.createFromRequest(httpRequest);
experimentManager.trackConversion(experimentId, userContext,
request.getConversionEvent(), request.getValue());
return ResponseEntity.ok().build();
}
}
// Request/Response DTOs
public class AssignmentResponse {
private String experimentId;
private String variantId;
private String status;
private String reason;
private Map<String, Object> parameters;
public static AssignmentResponse fromAssignment(Assignment assignment) {
AssignmentResponse response = new AssignmentResponse();
response.setExperimentId(assignment.getExperimentId());
response.setStatus(assignment.getStatus().name());
response.setReason(assignment.getReason());
if (assignment.isAssigned()) {
response.setVariantId(assignment.getVariant().getId());
response.setParameters(assignment.getVariant().getParameters());
}
return response;
}
// Getters and setters
}
public class TrackEventRequest {
private String event;
private Map<String, Object> properties;
// Getters and setters
}
public class TrackConversionRequest {
private String conversionEvent;
private double value;
private Map<String, Object> properties;
// Getters and setters
}
2. User Context Service
@Service
public class UserContextService {
public UserContext createFromRequest(HttpServletRequest request) {
String userId = getUserIdFromRequest(request);
String sessionId = request.getSession().getId();
return new UserContext.Builder()
.withUserId(userId)
.withSessionId(sessionId)
.withCountry(getCountryFromRequest(request))
.withLanguage(getLanguageFromRequest(request))
.withDeviceType(getDeviceTypeFromRequest(request))
.withPlatform(getPlatformFromRequest(request))
.withAttribute("ip", getClientIp(request))
.withAttribute("userAgent", request.getHeader("User-Agent"))
.build();
}
private String getUserIdFromRequest(HttpServletRequest request) {
// Extract from authentication, cookie, or header
Principal principal = request.getUserPrincipal();
if (principal != null) {
return principal.getName();
}
// Fallback to session or generate anonymous ID
String anonymousId = getAnonymousIdFromCookie(request);
if (anonymousId == null) {
anonymousId = generateAnonymousId();
setAnonymousIdCookie(request, anonymousId);
}
return anonymousId;
}
private String getAnonymousIdFromCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("anonymous_id".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
private String generateAnonymousId() {
return UUID.randomUUID().toString();
}
private void setAnonymousIdCookie(HttpServletRequest request, String anonymousId) {
// In real implementation, you'd set this in the response
}
private String getCountryFromRequest(HttpServletRequest request) {
// Use GeoIP or header
return request.getHeader("CF-IPCountry"); // CloudFlare header
}
private String getLanguageFromRequest(HttpServletRequest request) {
return request.getHeader("Accept-Language");
}
private String getDeviceTypeFromRequest(HttpServletRequest request) {
String userAgent = request.getHeader("User-Agent");
if (userAgent == null) return "unknown";
if (userAgent.toLowerCase().contains("mobile")) return "mobile";
if (userAgent.toLowerCase().contains("tablet")) return "tablet";
return "desktop";
}
private String getPlatformFromRequest(HttpServletRequest request) {
String userAgent = request.getHeader("User-Agent");
if (userAgent == null) return "unknown";
if (userAgent.contains("Android")) return "android";
if (userAgent.contains("iPhone") || userAgent.contains("iPad")) return "ios";
if (userAgent.contains("Windows")) return "windows";
if (userAgent.contains("Mac")) return "mac";
return "other";
}
private String getClientIp(HttpServletRequest request) {
String xfHeader = request.getHeader("X-Forwarded-For");
if (xfHeader != null) {
return xfHeader.split(",")[0];
}
return request.getRemoteAddr();
}
}
Metrics and Statistical Analysis
1. Metrics Service
public interface MetricsService {
void trackEvent(String experimentId, String variantId, UserContext user, String event);
void trackConversion(String experimentId, String variantId, UserContext user,
String conversionEvent, double value);
ExperimentStats getExperimentStats(String experimentId);
List<ConversionRate> getConversionRates(String experimentId, String conversionEvent);
StatisticalSignificance calculateSignificance(String experimentId, String conversionEvent);
}
@Service
public class InMemoryMetricsService implements MetricsService {
private final Map<String, List<Event>> events = new ConcurrentHashMap<>();
private final Map<String, List<Conversion>> conversions = new ConcurrentHashMap<>();
@Override
public void trackEvent(String experimentId, String variantId, UserContext user, String event) {
String key = experimentId + ":" + variantId;
events.computeIfAbsent(key, k -> Collections.synchronizedList(new ArrayList<>()))
.add(new Event(experimentId, variantId, user.getUserId(), event, System.currentTimeMillis()));
}
@Override
public void trackConversion(String experimentId, String variantId, UserContext user,
String conversionEvent, double value) {
String key = experimentId + ":" + variantId;
conversions.computeIfAbsent(key, k -> Collections.synchronizedList(new ArrayList<>()))
.add(new Conversion(experimentId, variantId, user.getUserId(),
conversionEvent, value, System.currentTimeMillis()));
}
@Override
public ExperimentStats getExperimentStats(String experimentId) {
ExperimentStats stats = new ExperimentStats(experimentId);
// Calculate stats for each variant
for (String key : events.keySet()) {
if (key.startsWith(experimentId + ":")) {
String variantId = key.split(":")[1];
VariantStats variantStats = calculateVariantStats(experimentId, variantId);
stats.addVariantStats(variantId, variantStats);
}
}
return stats;
}
@Override
public List<ConversionRate> getConversionRates(String experimentId, String conversionEvent) {
List<ConversionRate> rates = new ArrayList<>();
for (String key : conversions.keySet()) {
if (key.startsWith(experimentId + ":")) {
String variantId = key.split(":")[1];
List<Conversion> variantConversions = conversions.get(key).stream()
.filter(c -> conversionEvent.equals(c.getConversionEvent()))
.collect(Collectors.toList());
List<Event> variantEvents = events.getOrDefault(key, new ArrayList<>());
double conversionRate = variantEvents.isEmpty() ? 0 :
(double) variantConversions.size() / variantEvents.size();
rates.add(new ConversionRate(experimentId, variantId, conversionEvent,
conversionRate, variantConversions.size(),
variantEvents.size()));
}
}
return rates;
}
@Override
public StatisticalSignificance calculateSignificance(String experimentId, String conversionEvent) {
List<ConversionRate> rates = getConversionRates(experimentId, conversionEvent);
if (rates.size() < 2) {
return new StatisticalSignificance(experimentId, conversionEvent, 1.0); // Not enough data
}
// Simple chi-squared test for two variants
ConversionRate control = rates.get(0);
ConversionRate treatment = rates.get(1);
double pValue = calculatePValue(control.getConversions(), control.getVisitors(),
treatment.getConversions(), treatment.getVisitors());
return new StatisticalSignificance(experimentId, conversionEvent, pValue);
}
private double calculatePValue(int conversionsA, int visitorsA, int conversionsB, int visitorsB) {
// Simplified chi-squared test implementation
// In production, use a proper statistical library
double rateA = (double) conversionsA / visitorsA;
double rateB = (double) conversionsB / visitorsB;
double pooledRate = (double) (conversionsA + conversionsB) / (visitorsA + visitorsB);
double se = Math.sqrt(pooledRate * (1 - pooledRate) * (1.0/visitorsA + 1.0/visitorsB));
double z = (rateB - rateA) / se;
// Two-tailed test
return 2 * (1 - normalCdf(Math.abs(z)));
}
private double normalCdf(double z) {
// Approximation of normal cumulative distribution function
return 0.5 * (1 + erf(z / Math.sqrt(2)));
}
private double erf(double x) {
// Error function approximation
double a1 = 0.254829592;
double a2 = -0.284496736;
double a3 = 1.421413741;
double a4 = -1.453152027;
double a5 = 1.061405429;
double p = 0.3275911;
double t = 1.0 / (1.0 + p * Math.abs(x));
double y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-x * x);
return x < 0 ? -y : y;
}
private VariantStats calculateVariantStats(String experimentId, String variantId) {
String key = experimentId + ":" + variantId;
List<Event> variantEvents = events.getOrDefault(key, new ArrayList<>());
List<Conversion> variantConversions = conversions.getOrDefault(key, new ArrayList<>());
Set<String> uniqueUsers = variantEvents.stream()
.map(Event::getUserId)
.collect(Collectors.toSet());
Map<String, Long> eventCounts = variantEvents.stream()
.collect(Collectors.groupingBy(Event::getEvent, Collectors.counting()));
Map<String, Double> conversionValues = variantConversions.stream()
.collect(Collectors.groupingBy(Conversion::getConversionEvent,
Collectors.summingDouble(Conversion::getValue)));
return new VariantStats(experimentId, variantId, variantEvents.size(),
uniqueUsers.size(), eventCounts, conversionValues);
}
}
// Statistics classes
public class ExperimentStats {
private final String experimentId;
private final Map<String, VariantStats> variantStats = new HashMap<>();
private final LocalDateTime calculatedAt;
public ExperimentStats(String experimentId) {
this.experimentId = experimentId;
this.calculatedAt = LocalDateTime.now();
}
public void addVariantStats(String variantId, VariantStats stats) {
variantStats.put(variantId, stats);
}
// Getters
}
public class VariantStats {
private final String experimentId;
private final String variantId;
private final int totalEvents;
private final int uniqueUsers;
private final Map<String, Long> eventCounts;
private final Map<String, Double> conversionValues;
public VariantStats(String experimentId, String variantId, int totalEvents,
int uniqueUsers, Map<String, Long> eventCounts,
Map<String, Double> conversionValues) {
this.experimentId = experimentId;
this.variantId = variantId;
this.totalEvents = totalEvents;
this.uniqueUsers = uniqueUsers;
this.eventCounts = eventCounts;
this.conversionValues = conversionValues;
}
// Getters
}
public class ConversionRate {
private final String experimentId;
private final String variantId;
private final String conversionEvent;
private final double rate;
private final int conversions;
private final int visitors;
public ConversionRate(String experimentId, String variantId, String conversionEvent,
double rate, int conversions, int visitors) {
this.experimentId = experimentId;
this.variantId = variantId;
this.conversionEvent = conversionEvent;
this.rate = rate;
this.conversions = conversions;
this.visitors = visitors;
}
// Getters
}
public class StatisticalSignificance {
private final String experimentId;
private final String conversionEvent;
private final double pValue;
private final boolean significant;
public StatisticalSignificance(String experimentId, String conversionEvent, double pValue) {
this.experimentId = experimentId;
this.conversionEvent = conversionEvent;
this.pValue = pValue;
this.significant = pValue < 0.05; // 95% confidence level
}
// Getters
}
Practical Usage Examples
1. E-commerce A/B Test
@Service
public class EcommerceABTestService {
private final ExperimentManager experimentManager;
private static final String BUTTON_COLOR_EXPERIMENT = "button_color_v2";
private static final String PRICING_EXPERIMENT = "pricing_tier_display";
public EcommerceABTestService(ExperimentManager experimentManager) {
this.experimentManager = experimentManager;
}
public String getButtonColor(UserContext user) {
return experimentManager.getVariantParameter(
BUTTON_COLOR_EXPERIMENT, user, "buttonColor", String.class, "#007bff");
}
public String getPricingDisplay(UserContext user) {
return experimentManager.getVariantParameter(
PRICING_EXPERIMENT, user, "displayType", String.class, "monthly");
}
public void trackAddToCart(UserContext user, String productId, int quantity) {
experimentManager.trackEvent(BUTTON_COLOR_EXPERIMENT, user, "add_to_cart");
experimentManager.trackEvent(PRICING_EXPERIMENT, user, "add_to_cart");
// Track additional context
Map<String, Object> properties = new HashMap<>();
properties.put("productId", productId);
properties.put("quantity", quantity);
}
public void trackPurchase(UserContext user, double amount, String currency) {
experimentManager.trackConversion(BUTTON_COLOR_EXPERIMENT, user, "purchase", amount);
experimentManager.trackConversion(PRICING_EXPERIMENT, user, "purchase", amount);
Map<String, Object> properties = new HashMap<>();
properties.put("currency", currency);
properties.put("amount", amount);
}
}
@Controller
public class ProductController {
private final EcommerceABTestService abTestService;
private final UserContextService userContextService;
public ProductController(EcommerceABTestService abTestService,
UserContextService userContextService) {
this.abTestService = abTestService;
this.userContextService = userContextService;
}
@GetMapping("/product/{id}")
public String getProduct(@PathVariable String id, Model model,
HttpServletRequest request) {
UserContext user = userContextService.createFromRequest(request);
// Get experiment variants
String buttonColor = abTestService.getButtonColor(user);
String pricingDisplay = abTestService.getPricingDisplay(user);
model.addAttribute("buttonColor", buttonColor);
model.addAttribute("pricingDisplay", pricingDisplay);
model.addAttribute("productId", id);
// Track page view
abTestService.trackPageView(user, "product_page");
return "product";
}
@PostMapping("/product/{id}/cart")
public ResponseEntity<Void> addToCart(@PathVariable String id,
@RequestParam int quantity,
HttpServletRequest request) {
UserContext user = userContextService.createFromRequest(request);
abTestService.trackAddToCart(user, id, quantity);
return ResponseEntity.ok().build();
}
}
2. Feature Flag System
@Service
public class FeatureFlagService {
private final ExperimentManager experimentManager;
private final Map<String, String> featureExperiments = new HashMap<>();
public FeatureFlagService(ExperimentManager experimentManager) {
this.experimentManager = experimentManager;
initializeFeatureMappings();
}
private void initializeFeatureMappings() {
featureExperiments.put("new_checkout_flow", "checkout_redesign_v3");
featureExperiments.put("recommendation_engine", "rec_engine_v2");
featureExperiments.put("social_login", "social_login_rollout");
featureExperiments.put("dark_mode", "dark_mode_beta");
}
public boolean isEnabled(String featureName, UserContext user) {
String experimentId = featureExperiments.get(featureName);
if (experimentId == null) {
return false; // Feature not found
}
return experimentManager.isInExperiment(experimentId, user);
}
public <T> T getFeatureConfig(String featureName, UserContext user,
String configKey, Class<T> configType, T defaultValue) {
String experimentId = featureExperiments.get(featureName);
if (experimentId == null) {
return defaultValue;
}
return experimentManager.getVariantParameter(experimentId, user, configKey, configType, defaultValue);
}
public void trackFeatureUsage(String featureName, UserContext user, String action) {
String experimentId = featureExperiments.get(featureName);
if (experimentId != null) {
experimentManager.trackEvent(experimentId, user, "feature_usage." + action);
}
}
}
@RestController
@RequestMapping("/api/features")
public class FeatureFlagController {
private final FeatureFlagService featureFlagService;
private final UserContextService userContextService;
public FeatureFlagController(FeatureFlagService featureFlagService,
UserContextService userContextService) {
this.featureFlagService = featureFlagService;
this.userContextService = userContextService;
}
@GetMapping("/{featureName}/enabled")
public ResponseEntity<FeatureFlagResponse> isFeatureEnabled(
@PathVariable String featureName,
HttpServletRequest request) {
UserContext user = userContextService.createFromRequest(request);
boolean enabled = featureFlagService.isEnabled(featureName, user);
FeatureFlagResponse response = new FeatureFlagResponse(featureName, enabled);
return ResponseEntity.ok(response);
}
@GetMapping("/{featureName}/config")
public ResponseEntity<FeatureConfigResponse> getFeatureConfig(
@PathVariable String featureName,
@RequestParam String configKey,
@RequestParam(defaultValue = "null") String defaultValue,
HttpServletRequest request) {
UserContext user = userContextService.createFromRequest(request);
Object value = featureFlagService.getFeatureConfig(featureName, user, configKey, Object.class, defaultValue);
FeatureConfigResponse response = new FeatureConfigResponse(featureName, configKey, value);
return ResponseEntity.ok(response);
}
}
public class FeatureFlagResponse {
private final String featureName;
private final boolean enabled;
private final long timestamp;
public FeatureFlagResponse(String featureName, boolean enabled) {
this.featureName = featureName;
this.enabled = enabled;
this.timestamp = System.currentTimeMillis();
}
// Getters
}
public class FeatureConfigResponse {
private final String featureName;
private final String configKey;
private final Object value;
private final long timestamp;
public FeatureConfigResponse(String featureName, String configKey, Object value) {
this.featureName = featureName;
this.configKey = configKey;
this.value = value;
this.timestamp = System.currentTimeMillis();
}
// Getters
}
Repository Implementations
1. In-Memory Repository
@Repository
public class InMemoryExperimentRepository implements ExperimentRepository {
private final Map<String, Experiment> experiments = new ConcurrentHashMap<>();
public InMemoryExperimentRepository() {
initializeSampleExperiments();
}
private void initializeSampleExperiments() {
// Button color experiment
List<Variant> buttonColorVariants = Arrays.asList(
new Variant("control", "Blue Button", 0.5,
Map.of("buttonColor", "#007bff", "buttonText", "Buy Now")),
new Variant("test", "Green Button", 0.5,
Map.of("buttonColor", "#28a745", "buttonText", "Purchase"))
);
TargetingRules targeting = new TargetingRules(
Set.of("US", "CA", "UK"), // included countries
Set.of(), // excluded countries
Set.of("en", "es"), // included languages
Set.of("web", "mobile"), // included platforms
Set.of("desktop", "mobile", "tablet"), // included device types
Map.of(), // user attributes
1.0 // user percentage
);
Experiment buttonColorExperiment = new Experiment(
"button_color_v2",
"Button Color Test v2",
buttonColorVariants,
LocalDateTime.now().minusDays(1),
LocalDateTime.now().plusDays(30),
true,
targeting,
1.0
);
experiments.put(buttonColorExperiment.getId(), buttonColorExperiment);
}
@Override
public Experiment findById(String id) {
return experiments.get(id);
}
@Override
public List<Experiment> findAll() {
return new ArrayList<>(experiments.values());
}
@Override
public List<Experiment> findActiveExperiments() {
return experiments.values().stream()
.filter(Experiment::isActive)
.collect(Collectors.toList());
}
@Override
public void save(Experiment experiment) {
experiments.put(experiment.getId(), experiment);
}
@Override
public void delete(String id) {
experiments.remove(id);
}
}
2. Database Repository
@Repository
public class JdbcExperimentRepository implements ExperimentRepository {
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper;
public JdbcExperimentRepository(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) {
this.jdbcTemplate = jdbcTemplate;
this.objectMapper = objectMapper;
}
@Override
public Experiment findById(String id) {
String sql = "SELECT * FROM experiments WHERE id = ?";
try {
return jdbcTemplate.queryForObject(sql, this::mapExperiment, id);
} catch (EmptyResultDataAccessException e) {
return null;
}
}
@Override
public List<Experiment> findActiveExperiments() {
String sql = "SELECT * FROM experiments WHERE enabled = true AND start_date <= NOW() AND end_date >= NOW()";
return jdbcTemplate.query(sql, this::mapExperiment);
}
private Experiment mapExperiment(ResultSet rs, int rowNum) throws SQLException {
String id = rs.getString("id");
String name = rs.getString("name");
// Deserialize variants JSON
List<Variant> variants = deserializeVariants(rs.getString("variants"));
LocalDateTime startDate = rs.getTimestamp("start_date").toLocalDateTime();
LocalDateTime endDate = rs.getTimestamp("end_date").toLocalDateTime();
boolean enabled = rs.getBoolean("enabled");
// Deserialize targeting rules
TargetingRules targetingRules = deserializeTargetingRules(rs.getString("targeting_rules"));
double trafficAllocation = rs.getDouble("traffic_allocation");
return new Experiment(id, name, variants, startDate, endDate, enabled, targetingRules, trafficAllocation);
}
private List<Variant> deserializeVariants(String variantsJson) {
try {
return objectMapper.readValue(variantsJson,
objectMapper.getTypeFactory().constructCollectionType(List.class, Variant.class));
} catch (Exception e) {
throw new RuntimeException("Failed to deserialize variants", e);
}
}
private TargetingRules deserializeTargetingRules(String targetingJson) {
try {
return objectMapper.readValue(targetingJson, TargetingRules.class);
} catch (Exception e) {
throw new RuntimeException("Failed to deserialize targeting rules", e);
}
}
// Other methods implementation...
}
This comprehensive A/B testing framework provides everything needed to implement robust experimentation in Java applications, including experiment management, user allocation, metrics tracking, statistical analysis, and integration with web frameworks.