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.