A/B Testing Framework in Java

Comprehensive A/B Testing Implementation

1. Core Experiment Configuration

public class ExperimentConfig {
private final String experimentId;
private final String experimentName;
private final Map<String, Double> variantWeights;
private final Set<String> targetAudience;
private final Instant startTime;
private final Instant endTime;
private final boolean isActive;
private final Map<String, Object> customParameters;
public ExperimentConfig(Builder builder) {
this.experimentId = builder.experimentId;
this.experimentName = builder.experimentName;
this.variantWeights = Map.copyOf(builder.variantWeights);
this.targetAudience = Set.copyOf(builder.targetAudience);
this.startTime = builder.startTime;
this.endTime = builder.endTime;
this.isActive = builder.isActive;
this.customParameters = Map.copyOf(builder.customParameters);
}
public static class Builder {
private String experimentId;
private String experimentName;
private Map<String, Double> variantWeights = new HashMap<>();
private Set<String> targetAudience = new HashSet<>();
private Instant startTime;
private Instant endTime;
private boolean isActive = true;
private Map<String, Object> customParameters = new HashMap<>();
public Builder(String experimentId, String experimentName) {
this.experimentId = experimentId;
this.experimentName = experimentName;
}
public Builder addVariant(String variantName, double weight) {
this.variantWeights.put(variantName, weight);
return this;
}
public Builder targetAudience(Set<String> audience) {
this.targetAudience = audience;
return this;
}
public Builder timeRange(Instant start, Instant end) {
this.startTime = start;
this.endTime = end;
return this;
}
public Builder active(boolean active) {
this.isActive = active;
return this;
}
public Builder customParameter(String key, Object value) {
this.customParameters.put(key, value);
return this;
}
public ExperimentConfig build() {
validate();
return new ExperimentConfig(this);
}
private void validate() {
if (experimentId == null || experimentId.trim().isEmpty()) {
throw new IllegalArgumentException("Experiment ID cannot be null or empty");
}
if (variantWeights.isEmpty()) {
throw new IllegalArgumentException("At least one variant must be defined");
}
double totalWeight = variantWeights.values().stream().mapToDouble(Double::doubleValue).sum();
if (Math.abs(totalWeight - 1.0) > 0.001) {
throw new IllegalArgumentException("Variant weights must sum to 1.0");
}
}
}
// Getters
public String getExperimentId() { return experimentId; }
public Map<String, Double> getVariantWeights() { return variantWeights; }
public boolean isActive() { return isActive; }
public boolean isInTimeRange() {
Instant now = Instant.now();
return !now.isBefore(startTime) && !now.isAfter(endTime);
}
}

2. User Assignment Service

@Component
public class VariantAssignmentService {
private final HashFunction hashFunction = Hashing.murmur3_32_fixed();
private final double MAX_TRAFFIC_PERCENTAGE = 1.0;
public Assignment assignVariant(String userId, ExperimentConfig experiment) {
if (!experiment.isActive() || !experiment.isInTimeRange()) {
return Assignment.defaultVariant(experiment.getExperimentId());
}
String assignmentKey = experiment.getExperimentId() + ":" + userId;
int hash = Math.abs(hashFunction.hashUnencodedChars(assignmentKey).asInt());
double trafficAllocation = (hash % 10000) / 10000.0;
if (trafficAllocation > MAX_TRAFFIC_PERCENTAGE) {
return Assignment.defaultVariant(experiment.getExperimentId());
}
String variant = determineVariant(trafficAllocation, experiment.getVariantWeights());
return new Assignment(experiment.getExperimentId(), variant, true);
}
private String determineVariant(double trafficAllocation, Map<String, Double> variantWeights) {
double cumulativeWeight = 0.0;
for (Map.Entry<String, Double> entry : variantWeights.entrySet()) {
cumulativeWeight += entry.getValue();
if (trafficAllocation < cumulativeWeight) {
return entry.getKey();
}
}
return variantWeights.keySet().iterator().next(); // Fallback
}
}
public class Assignment {
private final String experimentId;
private final String variant;
private final boolean assigned;
private final Instant assignmentTime;
public Assignment(String experimentId, String variant, boolean assigned) {
this.experimentId = experimentId;
this.variant = variant;
this.assigned = assigned;
this.assignmentTime = Instant.now();
}
public static Assignment defaultVariant(String experimentId) {
return new Assignment(experimentId, "control", false);
}
// Getters
public String getVariant() { return variant; }
public boolean isAssigned() { return assigned; }
}

3. Experiment Manager

@Component
public class ExperimentManager {
private final Map<String, ExperimentConfig> experiments = new ConcurrentHashMap<>();
private final VariantAssignmentService assignmentService;
private final EventTrackingService eventTrackingService;
public ExperimentManager(VariantAssignmentService assignmentService, 
EventTrackingService eventTrackingService) {
this.assignmentService = assignmentService;
this.eventTrackingService = eventTrackingService;
}
public void registerExperiment(ExperimentConfig experiment) {
experiments.put(experiment.getExperimentId(), experiment);
}
public Assignment getAssignment(String userId, String experimentId) {
ExperimentConfig experiment = experiments.get(experimentId);
if (experiment == null) {
return Assignment.defaultVariant(experimentId);
}
return assignmentService.assignVariant(userId, experiment);
}
public <T> T executeWithVariant(String userId, String experimentId, 
ExperimentFunction<T> function) {
Assignment assignment = getAssignment(userId, experimentId);
try {
T result = function.execute(assignment.getVariant());
trackEvent(userId, experimentId, assignment.getVariant(), "success");
return result;
} catch (Exception e) {
trackEvent(userId, experimentId, assignment.getVariant(), "error");
throw e;
}
}
public void trackEvent(String userId, String experimentId, String variant, String eventType) {
ExperimentEvent event = new ExperimentEvent(userId, experimentId, variant, eventType);
eventTrackingService.trackEvent(event);
}
@FunctionalInterface
public interface ExperimentFunction<T> {
T execute(String variant);
}
}

4. Event Tracking and Analytics

@Component
public class EventTrackingService {
private final MeterRegistry meterRegistry;
private final List<EventStorage> storageBackends;
public EventTrackingService(MeterRegistry meterRegistry, List<EventStorage> storageBackends) {
this.meterRegistry = meterRegistry;
this.storageBackends = storageBackends;
}
public void trackEvent(ExperimentEvent event) {
// Record metrics
meterRegistry.counter("abtesting.events",
"experiment", event.getExperimentId(),
"variant", event.getVariant(),
"event_type", event.getEventType()
).increment();
// Store event in all backends
storageBackends.forEach(backend -> backend.store(event));
}
public ExperimentStats calculateStats(String experimentId, Instant start, Instant end) {
// Implementation for calculating conversion rates, confidence intervals, etc.
return new ExperimentStats(experimentId, start, end);
}
}
public class ExperimentEvent {
private final String userId;
private final String experimentId;
private final String variant;
private final String eventType;
private final Instant timestamp;
private final Map<String, Object> properties;
public ExperimentEvent(String userId, String experimentId, String variant, String eventType) {
this.userId = userId;
this.experimentId = experimentId;
this.variant = variant;
this.eventType = eventType;
this.timestamp = Instant.now();
this.properties = new HashMap<>();
}
public ExperimentEvent withProperty(String key, Object value) {
this.properties.put(key, value);
return this;
}
// Getters
public String getExperimentId() { return experimentId; }
public String getVariant() { return variant; }
public String getEventType() { return eventType; }
}

5. Statistical Analysis Service

@Component
public class StatisticalAnalysisService {
private static final double CONFIDENCE_LEVEL = 0.95;
private static final double SIGNIFICANCE_LEVEL = 0.05;
public ExperimentResult analyzeExperiment(ExperimentData data) {
Map<String, VariantStats> variantStats = calculateVariantStats(data);
boolean isSignificant = calculateSignificance(variantStats);
return new ExperimentResult(data.getExperimentId(), variantStats, isSignificant);
}
private Map<String, VariantStats> calculateVariantStats(ExperimentData data) {
return data.getVariants().stream()
.collect(Collectors.toMap(
VariantData::getVariantName,
this::calculateVariantStatistics
));
}
private VariantStats calculateVariantStatistics(VariantData variantData) {
long visitors = variantData.getTotalVisitors();
long conversions = variantData.getConversions();
double conversionRate = (double) conversions / visitors;
double standardError = calculateStandardError(conversionRate, visitors);
double confidenceInterval = calculateConfidenceInterval(standardError);
return new VariantStats(
visitors,
conversions,
conversionRate,
confidenceInterval
);
}
private boolean calculateSignificance(Map<String, VariantStats> variantStats) {
// Implement chi-squared test or t-test
VariantStats control = variantStats.get("control");
if (control == null) return false;
return variantStats.entrySet().stream()
.filter(entry -> !"control".equals(entry.getKey()))
.anyMatch(entry -> isStatisticallySignificant(control, entry.getValue()));
}
private boolean isStatisticallySignificant(VariantStats control, VariantStats variant) {
// Simplified significance calculation
double zScore = calculateZScore(control, variant);
return Math.abs(zScore) > 1.96; // For 95% confidence
}
private double calculateZScore(VariantStats control, VariantStats variant) {
double p1 = control.getConversionRate();
double p2 = variant.getConversionRate();
long n1 = control.getVisitors();
long n2 = variant.getVisitors();
double p = (p1 * n1 + p2 * n2) / (n1 + n2);
double standardError = Math.sqrt(p * (1 - p) * (1.0 / n1 + 1.0 / n2));
return (p2 - p1) / standardError;
}
private double calculateStandardError(double proportion, long sampleSize) {
return Math.sqrt(proportion * (1 - proportion) / sampleSize);
}
private double calculateConfidenceInterval(double standardError) {
return 1.96 * standardError; // Z-score for 95% confidence
}
}
public class ExperimentResult {
private final String experimentId;
private final Map<String, VariantStats> variantStats;
private final boolean statisticallySignificant;
private final String winningVariant;
public ExperimentResult(String experimentId, Map<String, VariantStats> variantStats, 
boolean statisticallySignificant) {
this.experimentId = experimentId;
this.variantStats = variantStats;
this.statisticallySignificant = statisticallySignificant;
this.winningVariant = determineWinningVariant(variantStats);
}
private String determineWinningVariant(Map<String, VariantStats> variantStats) {
return variantStats.entrySet().stream()
.max(Comparator.comparingDouble(entry -> entry.getValue().getConversionRate()))
.map(Map.Entry::getKey)
.orElse("control");
}
// Getters
public boolean isStatisticallySignificant() { return statisticallySignificant; }
public String getWinningVariant() { return winningVariant; }
}

6. Spring Boot Configuration

@Configuration
@EnableConfigurationProperties(ABTestingProperties.class)
public class ABTestingAutoConfiguration {
@Bean
public VariantAssignmentService variantAssignmentService() {
return new VariantAssignmentService();
}
@Bean
public EventTrackingService eventTrackingService(MeterRegistry meterRegistry) {
List<EventStorage> storageBackends = Arrays.asList(
new DatabaseEventStorage(),
new AnalyticsEventStorage()
);
return new EventTrackingService(meterRegistry, storageBackends);
}
@Bean
public ExperimentManager experimentManager(VariantAssignmentService assignmentService,
EventTrackingService eventTrackingService) {
return new ExperimentManager(assignmentService, eventTrackingService);
}
@Bean
public StatisticalAnalysisService statisticalAnalysisService() {
return new StatisticalAnalysisService();
}
}
@ConfigurationProperties(prefix = "ab-testing")
public class ABTestingProperties {
private boolean enabled = true;
private String defaultVariant = "control";
private int hashSeed = 42;
private Duration analysisPeriod = Duration.ofHours(24);
// Getters and setters
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public String getDefaultVariant() { return defaultVariant; }
public void setDefaultVariant(String defaultVariant) { this.defaultVariant = defaultVariant; }
}

7. REST Controller for Experiment Management

@RestController
@RequestMapping("/api/experiments")
public class ExperimentController {
private final ExperimentManager experimentManager;
private final StatisticalAnalysisService analysisService;
public ExperimentController(ExperimentManager experimentManager,
StatisticalAnalysisService analysisService) {
this.experimentManager = experimentManager;
this.analysisService = analysisService;
}
@PostMapping
public ResponseEntity<ExperimentConfig> createExperiment(@RequestBody ExperimentConfig config) {
experimentManager.registerExperiment(config);
return ResponseEntity.ok(config);
}
@GetMapping("/{experimentId}/assignment")
public ResponseEntity<Assignment> getAssignment(
@PathVariable String experimentId,
@RequestParam String userId) {
Assignment assignment = experimentManager.getAssignment(userId, experimentId);
return ResponseEntity.ok(assignment);
}
@PostMapping("/{experimentId}/track")
public ResponseEntity<Void> trackEvent(
@PathVariable String experimentId,
@RequestParam String userId,
@RequestParam String variant,
@RequestParam String eventType) {
experimentManager.trackEvent(userId, experimentId, variant, eventType);
return ResponseEntity.accepted().build();
}
@GetMapping("/{experimentId}/results")
public ResponseEntity<ExperimentResult> getResults(@PathVariable String experimentId) {
// This would typically fetch data from storage and analyze
ExperimentData data = fetchExperimentData(experimentId);
ExperimentResult result = analysisService.analyzeExperiment(data);
return ResponseEntity.ok(result);
}
private ExperimentData fetchExperimentData(String experimentId) {
// Implementation to fetch experiment data from database
return new ExperimentData(experimentId, List.of());
}
}

8. Feature Toggle Integration

@Component
public class FeatureToggleService {
private final ExperimentManager experimentManager;
public FeatureToggleService(ExperimentManager experimentManager) {
this.experimentManager = experimentManager;
}
public boolean isFeatureEnabled(String featureName, String userId) {
return experimentManager.executeWithVariant(userId, featureName, variant -> {
return "enabled".equals(variant) || "treatment".equals(variant);
});
}
public <T> T getFeatureConfig(String featureName, String userId, 
Function<String, T> configMapper) {
return experimentManager.executeWithVariant(userId, featureName, configMapper::apply);
}
}

9. Example Usage in Business Logic

@Service
public class ProductService {
private final ExperimentManager experimentManager;
private final FeatureToggleService featureToggleService;
public ProductService(ExperimentManager experimentManager,
FeatureToggleService featureToggleService) {
this.experimentManager = experimentManager;
this.featureToggleService = featureToggleService;
}
public ProductRecommendation getRecommendations(String userId, String category) {
// A/B test for recommendation algorithm
return experimentManager.executeWithVariant(userId, "recommendation_algorithm", variant -> {
switch (variant) {
case "collaborative_filtering":
return collaborativeFilteringRecommendations(userId, category);
case "content_based":
return contentBasedRecommendations(userId, category);
default:
return defaultRecommendations(userId, category);
}
});
}
public ProductDetail getProductDetail(String userId, String productId) {
// Feature flag for new UI
if (featureToggleService.isFeatureEnabled("new_product_ui", userId)) {
return getEnhancedProductDetail(productId);
} else {
return getLegacyProductDetail(productId);
}
}
public void trackProductView(String userId, String productId) {
// Get assignment and track view event
Assignment assignment = experimentManager.getAssignment(userId, "product_layout");
experimentManager.trackEvent(userId, "product_layout", assignment.getVariant(), "product_view");
// Additional tracking with properties
experimentManager.trackEvent(userId, "product_layout", assignment.getVariant(), 
"product_view_with_properties")
.withProperty("product_id", productId)
.withProperty("category", getProductCategory(productId));
}
private ProductRecommendation collaborativeFilteringRecommendations(String userId, String category) {
// Implementation
return new ProductRecommendation();
}
private ProductRecommendation contentBasedRecommendations(String userId, String category) {
// Implementation
return new ProductRecommendation();
}
private ProductRecommendation defaultRecommendations(String userId, String category) {
// Implementation
return new ProductRecommendation();
}
}

10. Testing Framework

@ExtendWith(SpringExtension.class)
@SpringBootTest
class ABTestingTest {
@Autowired
private ExperimentManager experimentManager;
@Test
void testVariantAssignmentConsistency() {
String userId = "user123";
String experimentId = "test_experiment";
Assignment firstAssignment = experimentManager.getAssignment(userId, experimentId);
Assignment secondAssignment = experimentManager.getAssignment(userId, experimentId);
assertEquals(firstAssignment.getVariant(), secondAssignment.getVariant());
}
@Test
void testTrafficDistribution() {
Map<String, Integer> variantCounts = new HashMap<>();
int totalUsers = 10000;
for (int i = 0; i < totalUsers; i++) {
String userId = "user_" + i;
Assignment assignment = experimentManager.getAssignment(userId, "distribution_test");
variantCounts.merge(assignment.getVariant(), 1, Integer::sum);
}
// Verify distribution is within expected bounds
double controlPercentage = (double) variantCounts.get("control") / totalUsers;
assertTrue(controlPercentage > 0.48 && controlPercentage < 0.52); // 50% distribution
}
}

Configuration Example

# application.yml
ab-testing:
enabled: true
default-variant: "control"
hash-seed: 42
analysis-period: 24h
experiments:
recommendation_algorithm:
name: "Product Recommendation Algorithm Test"
variants:
control: 0.33
collaborative_filtering: 0.33
content_based: 0.34
start-time: 2024-01-01T00:00:00Z
end-time: 2024-02-01T00:00:00Z
active: true
product_layout:
name: "New Product Page Layout"
variants:
control: 0.5
new_layout: 0.5
start-time: 2024-01-01T00:00:00Z
end-time: 2024-03-01T00:00:00Z
active: true

This comprehensive A/B testing framework provides:

  • Consistent user assignment using hashing algorithms
  • Flexible experiment configuration with traffic allocation
  • Real-time event tracking and analytics
  • Statistical significance calculation
  • Spring Boot integration for easy setup
  • Feature toggle capabilities
  • REST API for experiment management
  • Comprehensive testing support

The framework ensures reliable, statistically sound A/B testing that can scale with your application's needs.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper