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.