Feature flags (feature toggles) are a powerful technique that allows you to deploy code changes safely, perform A/B testing, and manage feature releases without redeploying your application. Split.io is a leading feature flag and experimentation platform that provides sophisticated targeting, real-time control, and detailed metrics.
What are Feature Flags?
Feature flags are conditional statements that control whether features are enabled for specific users, environments, or conditions. They enable:
- Trunk-based development without feature branches
- Gradual rollouts and canary releases
- Instant kill switches for problematic features
- A/B testing and experimentation
- User-specific targeting and personalization
Split.io Architecture
[Java Application] → [Split SDK] → [Split Cloud] ← [Split UI] | | | | Check feature Local cache Rule storage Management flags locally with polling and targeting console
Hands-On Tutorial: Implementing Split.io in Spring Boot
Let's build a complete e-commerce application with feature flags for controlled rollouts, experimentation, and operational control.
Step 1: Project Setup and Dependencies
Maven Dependencies (pom.xml):
<properties>
<spring-boot.version>3.2.0</spring-boot.version>
<split.version>4.4.2</split.version>
</properties>
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- Split.io SDK -->
<dependency>
<groupId>io.split.client</groupId>
<artifactId>split-client-java</artifactId>
<version>${split.version}</version>
</dependency>
<!-- Configuration -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<version>${spring-boot.version}</version>
<optional>true</optional>
</dependency>
<!-- Caching -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- Monitoring -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
</dependency>
</dependencies>
Step 2: Configuration
application.yml:
split:
io:
api-key: ${SPLIT_IO_API_KEY:localhost}
# Use 'localhost' for development without API key
environment: ${SPLIT_IO_ENVIRONMENT:development}
# Configuration options
features-refresh-rate: 30
segments-refresh-rate: 60
impressions-refresh-rate: 30
metrics-refresh-rate: 30
connection-timeout: 15000
read-timeout: 15000
# Localhost mode (for development)
localhost-feature-flags:
new-checkout-ui: on
premium-features: off
recommendation-engine: on:user_premium
search-v2: off
promotional-banner: on:50%
app:
feature-flags:
# Feature names
new-checkout-ui: new_checkout_ui
premium-features: premium_features
recommendation-engine: recommendation_engine_v2
search-v2: search_algorithm_v2
promotional-banner: promotional_banner_holiday
dark-mode: dark_mode_toggle
payment-methods: new_payment_methods
Split.io Configuration Class:
@Configuration
@ConfigurationProperties(prefix = "split.io")
@Validated
public class SplitIOConfig {
@NotBlank
private String apiKey;
private String environment = "development";
private int featuresRefreshRate = 30;
private int segmentsRefreshRate = 60;
private int impressionsRefreshRate = 30;
private int metricsRefreshRate = 30;
private int connectionTimeout = 15000;
private int readTimeout = 15000;
private Map<String, String> localhostFeatureFlags = new HashMap<>();
// Getters and setters
public String getApiKey() { return apiKey; }
public void setApiKey(String apiKey) { this.apiKey = apiKey; }
public String getEnvironment() { return environment; }
public void setEnvironment(String environment) { this.environment = environment; }
public int getFeaturesRefreshRate() { return featuresRefreshRate; }
public void setFeaturesRefreshRate(int featuresRefreshRate) { this.featuresRefreshRate = featuresRefreshRate; }
public int getSegmentsRefreshRate() { return segmentsRefreshRate; }
public void setSegmentsRefreshRate(int segmentsRefreshRate) { this.segmentsRefreshRate = segmentsRefreshRate; }
public int getImpressionsRefreshRate() { return impressionsRefreshRate; }
public void setImpressionsRefreshRate(int impressionsRefreshRate) { this.impressionsRefreshRate = impressionsRefreshRate; }
public int getMetricsRefreshRate() { return metricsRefreshRate; }
public void setMetricsRefreshRate(int metricsRefreshRate) { this.metricsRefreshRate = metricsRefreshRate; }
public int getConnectionTimeout() { return connectionTimeout; }
public void setConnectionTimeout(int connectionTimeout) { this.connectionTimeout = connectionTimeout; }
public int getReadTimeout() { return readTimeout; }
public void setReadTimeout(int readTimeout) { this.readTimeout = readTimeout; }
public Map<String, String> getLocalhostFeatureFlags() { return localhostFeatureFlags; }
public void setLocalhostFeatureFlags(Map<String, String> localhostFeatureFlags) { this.localhostFeatureFlags = localhostFeatureFlags; }
}
Step 3: Split.io Client Factory
@Configuration
@EnableCaching
public class SplitIOConfiguration {
private static final Logger logger = LoggerFactory.getLogger(SplitIOConfiguration.class);
private final SplitIOConfig config;
public SplitIOConfiguration(SplitIOConfig config) {
this.config = config;
}
@Bean
@Primary
public SplitClient splitClient() {
try {
SplitClientConfig clientConfig = SplitClientConfig.builder()
.setBlockUntilReadyTimeout(10000)
.featuresRefreshRate(config.getFeaturesRefreshRate())
.segmentsRefreshRate(config.getSegmentsRefreshRate())
.impressionsRefreshRate(config.getImpressionsRefreshRate())
.metricsRefreshRate(config.getMetricsRefreshRate())
.connectionTimeout(config.getConnectionTimeout())
.readTimeout(config.getReadTimeout())
.build();
SplitFactory splitFactory;
if ("localhost".equals(config.getApiKey())) {
logger.info("Initializing Split.io in LOCALHOST mode");
splitFactory = localhostSplitFactory();
} else {
logger.info("Initializing Split.io in PRODUCTION mode");
splitFactory = SplitFactoryBuilder.build(config.getApiKey(), clientConfig);
}
splitFactory.client().blockUntilReady();
logger.info("Split.io client initialized successfully");
return splitFactory.client();
} catch (Exception e) {
logger.error("Failed to initialize Split.io client", e);
throw new RuntimeException("Split.io initialization failed", e);
}
}
@Bean
public SplitManager splitManager(SplitClient splitClient) {
return splitClient.getSplitManager();
}
/**
* Localhost factory for development without API key
*/
private SplitFactory localhostSplitFactory() {
Map<String, String> featureFlags = config.getLocalhostFeatureFlags();
LocalhostSplitFactory localhostFactory = new LocalhostSplitFactory();
// Set up local feature flags
featureFlags.forEach((featureName, treatment) -> {
localhostFactory.getFeatureFlagRegistry().register(
featureName,
parseLocalhostTreatment(treatment)
);
});
return localhostFactory;
}
/**
* Parse localhost treatment strings like "on", "off", "on:50%", "on:user_premium"
*/
private Consumer<LocalhostSplit> parseLocalhostTreatment(String treatment) {
if ("on".equalsIgnoreCase(treatment)) {
return split -> split.setTreatment("on");
} else if ("off".equalsIgnoreCase(treatment)) {
return split -> split.setTreatment("off");
} else if (treatment.startsWith("on:")) {
String target = treatment.substring(3);
if (target.endsWith("%")) {
// Percentage rollout
int percentage = Integer.parseInt(target.substring(0, target.length() - 1));
return split -> split.setTreatment("on", percentage);
} else {
// Targeted rollout
return split -> split.setTreatment("on", Collections.singleton(target));
}
}
return split -> split.setTreatment("off");
}
@PreDestroy
public void destroy() {
try {
if (splitClient() != null) {
splitClient().destroy();
logger.info("Split.io client destroyed successfully");
}
} catch (Exception e) {
logger.error("Error destroying Split.io client", e);
}
}
}
Step 4: Feature Flag Service
@Service
@Slf4j
public class FeatureFlagService {
private final SplitClient splitClient;
private final SplitManager splitManager;
private final ObjectMapper objectMapper;
// Feature flag names (should match Split.io dashboard)
public static final String NEW_CHECKOUT_UI = "new_checkout_ui";
public static final String PREMIUM_FEATURES = "premium_features";
public static final String RECOMMENDATION_ENGINE_V2 = "recommendation_engine_v2";
public static final String SEARCH_ALGORITHM_V2 = "search_algorithm_v2";
public static final String PROMOTIONAL_BANNER = "promotional_banner_holiday";
public static final String DARK_MODE = "dark_mode_toggle";
public static final String NEW_PAYMENT_METHODS = "new_payment_methods";
public FeatureFlagService(SplitClient splitClient,
SplitManager splitManager,
ObjectMapper objectMapper) {
this.splitClient = splitClient;
this.splitManager = splitManager;
this.objectMapper = objectMapper;
}
/**
* Basic feature flag check with user context
*/
public boolean isFeatureEnabled(String featureFlag, UserContext userContext) {
return "on".equals(getTreatment(featureFlag, userContext));
}
/**
* Get treatment with user context
*/
public String getTreatment(String featureFlag, UserContext userContext) {
try {
String treatment = splitClient.getTreatment(
userContext.getUserId(),
featureFlag,
userContext.getAttributes()
);
log.debug("Feature flag '{}' treatment for user {}: {}",
featureFlag, userContext.getUserId(), treatment);
return treatment;
} catch (Exception e) {
log.error("Error getting treatment for feature flag: {}", featureFlag, e);
return "off"; // Fail safe
}
}
/**
* Get treatment with JSON configuration
*/
public <T> T getTreatmentWithConfig(String featureFlag,
UserContext userContext,
Class<T> configType) {
try {
SplitResult result = splitClient.getTreatmentWithConfig(
userContext.getUserId(),
featureFlag,
userContext.getAttributes()
);
log.debug("Feature flag '{}' treatment with config for user {}: {}",
featureFlag, userContext.getUserId(), result.treatment());
// Parse configuration if provided
if ("on".equals(result.treatment()) &&
result.config() != null &&
!result.config().trim().isEmpty()) {
try {
return objectMapper.readValue(result.config(), configType);
} catch (Exception e) {
log.error("Failed to parse feature flag config for {}: {}",
featureFlag, result.config(), e);
}
}
return null;
} catch (Exception e) {
log.error("Error getting treatment with config for feature flag: {}", featureFlag, e);
return null;
}
}
/**
* Track feature flag exposure for analytics
*/
public void trackExposure(String featureFlag, UserContext userContext, String treatment) {
try {
splitClient.track(
userContext.getUserId(),
"user",
"feature_flag_exposure",
null, // value
createEventProperties(featureFlag, treatment, userContext)
);
log.debug("Tracked feature flag exposure: {} - {} for user {}",
featureFlag, treatment, userContext.getUserId());
} catch (Exception e) {
log.error("Error tracking feature flag exposure: {}", featureFlag, e);
}
}
/**
* Get all available feature flags
*/
public List<SplitView> getAllFeatureFlags() {
try {
return splitManager.splits();
} catch (Exception e) {
log.error("Error getting feature flags list", e);
return Collections.emptyList();
}
}
/**
* Check if feature flag exists
*/
public boolean featureFlagExists(String featureFlag) {
try {
return splitManager.split(featureFlag) != null;
} catch (Exception e) {
log.error("Error checking if feature flag exists: {}", featureFlag, e);
return false;
}
}
/**
* Bulk check multiple feature flags
*/
public Map<String, Boolean> bulkCheckFeatures(UserContext userContext, String... featureFlags) {
Map<String, Boolean> results = new HashMap<>();
for (String featureFlag : featureFlags) {
results.put(featureFlag, isFeatureEnabled(featureFlag, userContext));
}
return results;
}
/**
* Feature flag with percentage rollout
*/
public boolean isFeatureEnabledForPercentage(String featureFlag,
UserContext userContext,
int fallbackPercentage) {
String treatment = getTreatment(featureFlag, userContext);
if ("on".equals(treatment)) {
return true;
} else if ("off".equals(treatment)) {
return false;
} else {
// Handle percentage-based treatments
return isUserInPercentage(userContext.getUserId(), fallbackPercentage);
}
}
private boolean isUserInPercentage(String userId, int percentage) {
// Simple hash-based percentage calculation
int hash = Math.abs(userId.hashCode()) % 100;
return hash < percentage;
}
private Map<String, Object> createEventProperties(String featureFlag,
String treatment,
UserContext userContext) {
Map<String, Object> properties = new HashMap<>();
properties.put("feature_flag", featureFlag);
properties.put("treatment", treatment);
properties.put("user_id", userContext.getUserId());
properties.put("timestamp", Instant.now().toString());
properties.put("environment", getEnvironment());
// Add user attributes for segmentation
if (userContext.getAttributes() != null) {
properties.putAll(userContext.getAttributes());
}
return properties;
}
private String getEnvironment() {
return System.getenv().getOrDefault("SPLIT_ENV", "development");
}
}
Step 5: User Context Model
public class UserContext {
private final String userId;
private final Map<String, Object> attributes;
public UserContext(String userId) {
this(userId, new HashMap<>());
}
public UserContext(String userId, Map<String, Object> attributes) {
this.userId = userId;
this.attributes = attributes != null ? new HashMap<>(attributes) : new HashMap<>();
}
// Builder pattern for fluent creation
public static UserContext of(String userId) {
return new UserContext(userId);
}
public UserContext withAttribute(String key, Object value) {
this.attributes.put(key, value);
return this;
}
public UserContext withAttributes(Map<String, Object> additionalAttributes) {
this.attributes.putAll(additionalAttributes);
return this;
}
public UserContext withPlan(String plan) {
return withAttribute("plan", plan);
}
public UserContext withCountry(String country) {
return withAttribute("country", country);
}
public UserContext withDevice(String device) {
return withAttribute("device", device);
}
public UserContext withRegistrationDate(LocalDate registrationDate) {
return withAttribute("registration_date", registrationDate.toString());
}
// Getters
public String getUserId() { return userId; }
public Map<String, Object> getAttributes() { return Collections.unmodifiableMap(attributes); }
// Common attribute keys
public static class Attributes {
public static final String PLAN = "plan";
public static final String COUNTRY = "country";
public static final String DEVICE = "device";
public static final String REGISTRATION_DATE = "registration_date";
public static final String USER_AGE = "user_age";
public static final String SEGMENT = "segment";
}
// Common user segments
public static class Segments {
public static final String BETA_TESTERS = "beta_testers";
public static final String PREMIUM_USERS = "premium_users";
public static final String NEW_USERS = "new_users";
public static final String POWER_USERS = "power_users";
}
}
Step 6: Feature-Specific Services
Checkout Service with Feature Flags:
@Service
@Slf4j
public class CheckoutService {
private final FeatureFlagService featureFlagService;
private final PaymentService paymentService;
public CheckoutService(FeatureFlagService featureFlagService,
PaymentService paymentService) {
this.featureFlagService = featureFlagService;
this.paymentService = paymentService;
}
/**
* Process checkout with feature-flagged improvements
*/
public CheckoutResult processCheckout(CheckoutRequest request, UserContext userContext) {
log.info("Processing checkout for user: {}", userContext.getUserId());
// Check if new checkout UI is enabled for this user
boolean newCheckoutEnabled = featureFlagService.isFeatureEnabled(
FeatureFlagService.NEW_CHECKOUT_UI, userContext
);
// Track exposure for analytics
featureFlagService.trackExposure(
FeatureFlagService.NEW_CHECKOUT_UI,
userContext,
newCheckoutEnabled ? "on" : "off"
);
CheckoutResult result;
if (newCheckoutEnabled) {
result = processCheckoutV2(request, userContext);
} else {
result = processCheckoutV1(request, userContext);
}
return result;
}
/**
* New checkout flow with additional features
*/
private CheckoutResult processCheckoutV2(CheckoutRequest request, UserContext userContext) {
log.info("Using new checkout flow for user: {}", userContext.getUserId());
// Check for additional feature flags
boolean newPaymentMethods = featureFlagService.isFeatureEnabled(
FeatureFlagService.NEW_PAYMENT_METHODS, userContext
);
CheckoutConfig config = featureFlagService.getTreatmentWithConfig(
FeatureFlagService.NEW_CHECKOUT_UI,
userContext,
CheckoutConfig.class
);
// Apply configuration from feature flag
if (config != null) {
applyCheckoutConfig(request, config);
}
// Process with new payment methods if enabled
if (newPaymentMethods) {
return paymentService.processWithNewMethods(request, userContext);
} else {
return paymentService.processWithLegacyMethods(request, userContext);
}
}
/**
* Legacy checkout flow
*/
private CheckoutResult processCheckoutV1(CheckoutRequest request, UserContext userContext) {
log.info("Using legacy checkout flow for user: {}", userContext.getUserId());
return paymentService.processWithLegacyMethods(request, userContext);
}
private void applyCheckoutConfig(CheckoutRequest request, CheckoutConfig config) {
if (config.getMaxCartSize() > 0 && request.getItems().size() > config.getMaxCartSize()) {
throw new CartSizeExceededException("Cart size exceeds maximum allowed");
}
if (config.isExpressCheckoutEnabled()) {
request.setExpressCheckout(true);
}
}
// Configuration DTO for feature flag
public static class CheckoutConfig {
private boolean expressCheckoutEnabled;
private int maxCartSize = 50;
private double discountThreshold = 100.0;
private List<String> allowedPaymentMethods;
// Getters and setters
public boolean isExpressCheckoutEnabled() { return expressCheckoutEnabled; }
public void setExpressCheckoutEnabled(boolean expressCheckoutEnabled) { this.expressCheckoutEnabled = expressCheckoutEnabled; }
public int getMaxCartSize() { return maxCartSize; }
public void setMaxCartSize(int maxCartSize) { this.maxCartSize = maxCartSize; }
public double getDiscountThreshold() { return discountThreshold; }
public void setDiscountThreshold(double discountThreshold) { this.discountThreshold = discountThreshold; }
public List<String> getAllowedPaymentMethods() { return allowedPaymentMethods; }
public void setAllowedPaymentMethods(List<String> allowedPaymentMethods) { this.allowedPaymentMethods = allowedPaymentMethods; }
}
}
Recommendation Service with Experimentation:
@Service
@Slf4j
public class RecommendationService {
private final FeatureFlagService featureFlagService;
private final LegacyRecommendationEngine legacyEngine;
private final AIV2RecommendationEngine aiV2Engine;
public RecommendationService(FeatureFlagService featureFlagService,
LegacyRecommendationEngine legacyEngine,
AIV2RecommendationEngine aiV2Engine) {
this.featureFlagService = featureFlagService;
this.legacyEngine = legacyEngine;
this.aiV2Engine = aiV2Engine;
}
/**
* Get recommendations with A/B testing between algorithms
*/
public List<Product> getRecommendations(UserContext userContext, String context) {
String treatment = featureFlagService.getTreatment(
FeatureFlagService.RECOMMENDATION_ENGINE_V2,
userContext
);
// Track which algorithm was used
featureFlagService.trackExposure(
FeatureFlagService.RECOMMENDATION_ENGINE_V2,
userContext,
treatment
);
List<Product> recommendations;
switch (treatment) {
case "on":
recommendations = aiV2Engine.getRecommendations(userContext.getUserId(), context);
break;
case "off":
recommendations = legacyEngine.getRecommendations(userContext.getUserId(), context);
break;
case "v3": // Experimental third algorithm
recommendations = getExperimentalRecommendations(userContext, context);
break;
default:
recommendations = legacyEngine.getRecommendations(userContext.getUserId(), context);
break;
}
// Track recommendation performance
trackRecommendationMetrics(userContext, treatment, recommendations.size());
return recommendations;
}
/**
* Get personalized recommendations with configuration
*/
public List<Product> getPersonalizedRecommendations(UserContext userContext,
RecommendationRequest request) {
RecommendationConfig config = featureFlagService.getTreatmentWithConfig(
FeatureFlagService.RECOMMENDATION_ENGINE_V2,
userContext,
RecommendationConfig.class
);
if (config != null) {
return getRecommendationsWithConfig(userContext, request, config);
} else {
return getRecommendations(userContext, request.getContext());
}
}
private List<Product> getRecommendationsWithConfig(UserContext userContext,
RecommendationRequest request,
RecommendationConfig config) {
// Apply configuration from feature flag
request.setMaxResults(config.getMaxRecommendations());
request.setDiversityFactor(config.getDiversityFactor());
if (config.isPersonalizationEnabled()) {
return aiV2Engine.getPersonalizedRecommendations(userContext.getUserId(), request);
} else {
return legacyEngine.getRecommendations(userContext.getUserId(), request.getContext());
}
}
private List<Product> getExperimentalRecommendations(UserContext userContext, String context) {
// Experimental algorithm - could be another service
log.info("Using experimental recommendation algorithm for user: {}", userContext.getUserId());
return legacyEngine.getRecommendations(userContext.getUserId(), context);
}
private void trackRecommendationMetrics(UserContext userContext, String treatment, int count) {
try {
Map<String, Object> properties = new HashMap<>();
properties.put("treatment", treatment);
properties.put("recommendation_count", count);
properties.put("user_segment", userContext.getAttributes().get("segment"));
featureFlagService.track(
"recommendations_generated",
userContext,
properties
);
} catch (Exception e) {
log.error("Failed to track recommendation metrics", e);
}
}
// Configuration DTO
public static class RecommendationConfig {
private int maxRecommendations = 10;
private double diversityFactor = 0.3;
private boolean personalizationEnabled = true;
private double confidenceThreshold = 0.7;
// Getters and setters
public int getMaxRecommendations() { return maxRecommendations; }
public void setMaxRecommendations(int maxRecommendations) { this.maxRecommendations = maxRecommendations; }
public double getDiversityFactor() { return diversityFactor; }
public void setDiversityFactor(double diversityFactor) { this.diversityFactor = diversityFactor; }
public boolean isPersonalizationEnabled() { return personalizationEnabled; }
public void setPersonalizationEnabled(boolean personalizationEnabled) { this.personalizationEnabled = personalizationEnabled; }
public double getConfidenceThreshold() { return confidenceThreshold; }
public void setConfidenceThreshold(double confidenceThreshold) { this.confidenceThreshold = confidenceThreshold; }
}
}
Step 7: REST Controllers
@RestController
@RequestMapping("/api")
@Slf4j
public class EcommerceController {
private final CheckoutService checkoutService;
private final RecommendationService recommendationService;
private final FeatureFlagService featureFlagService;
public EcommerceController(CheckoutService checkoutService,
RecommendationService recommendationService,
FeatureFlagService featureFlagService) {
this.checkoutService = checkoutService;
this.recommendationService = recommendationService;
this.featureFlagService = featureFlagService;
}
@PostMapping("/checkout")
public ResponseEntity<CheckoutResult> checkout(@RequestBody CheckoutRequest request,
@RequestHeader("X-User-Id") String userId) {
UserContext userContext = UserContext.of(userId)
.withPlan("premium")
.withCountry("US")
.withDevice("web");
CheckoutResult result = checkoutService.processCheckout(request, userContext);
return ResponseEntity.ok(result);
}
@GetMapping("/recommendations")
public ResponseEntity<List<Product>> getRecommendations(
@RequestParam(defaultValue = "homepage") String context,
@RequestHeader("X-User-Id") String userId) {
UserContext userContext = UserContext.of(userId)
.withAttribute("context", context);
List<Product> recommendations = recommendationService.getRecommendations(userContext, context);
return ResponseEntity.ok(recommendations);
}
@GetMapping("/features")
public ResponseEntity<Map<String, Object>> getFeatureFlags(@RequestHeader("X-User-Id") String userId) {
UserContext userContext = UserContext.of(userId);
Map<String, Boolean> features = featureFlagService.bulkCheckFeatures(userContext,
FeatureFlagService.NEW_CHECKOUT_UI,
FeatureFlagService.PREMIUM_FEATURES,
FeatureFlagService.RECOMMENDATION_ENGINE_V2,
FeatureFlagService.SEARCH_ALGORITHM_V2,
FeatureFlagService.PROMOTIONAL_BANNER,
FeatureFlagService.DARK_MODE
);
Map<String, Object> response = new HashMap<>();
response.put("userId", userId);
response.put("features", features);
response.put("timestamp", Instant.now().toString());
return ResponseEntity.ok(response);
}
@GetMapping("/features/{featureName}")
public ResponseEntity<FeatureFlagResponse> getFeatureFlag(
@PathVariable String featureName,
@RequestHeader("X-User-Id") String userId) {
UserContext userContext = UserContext.of(userId);
String treatment = featureFlagService.getTreatment(featureName, userContext);
FeatureFlagResponse response = new FeatureFlagResponse();
response.setFeatureName(featureName);
response.setUserId(userId);
response.setTreatment(treatment);
response.setEnabled("on".equals(treatment));
response.setTimestamp(Instant.now().toString());
return ResponseEntity.ok(response);
}
}
// Response DTOs
class FeatureFlagResponse {
private String featureName;
private String userId;
private String treatment;
private boolean enabled;
private String timestamp;
// Getters and setters
public String getFeatureName() { return featureName; }
public void setFeatureName(String featureName) { this.featureName = featureName; }
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public String getTreatment() { return treatment; }
public void setTreatment(String treatment) { this.treatment = treatment; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public String getTimestamp() { return timestamp; }
public void setTimestamp(String timestamp) { this.timestamp = timestamp; }
}
Step 8: Monitoring and Health Checks
@Component
public class SplitIOHealthIndicator implements HealthIndicator {
private final SplitClient splitClient;
private final SplitManager splitManager;
public SplitIOHealthIndicator(SplitClient splitClient, SplitManager splitManager) {
this.splitClient = splitClient;
this.splitManager = splitManager;
}
@Override
public Health health() {
try {
// Check if client is ready
if (!splitClient.isReady()) {
return Health.down()
.withDetail("service", "split.io")
.withDetail("status", "not_ready")
.build();
}
// Get some basic metrics
List<SplitView> splits = splitManager.splits();
long activeSplits = splits.stream()
.filter(split -> split.status.equals("ACTIVE"))
.count();
Map<String, Object> details = new HashMap<>();
details.put("total_splits", splits.size());
details.put("active_splits", activeSplits);
details.put("ready", true);
return Health.up()
.withDetail("service", "split.io")
.withDetails(details)
.build();
} catch (Exception e) {
return Health.down()
.withDetail("service", "split.io")
.withDetail("error", e.getMessage())
.build();
}
}
}
@RestController
@RequestMapping("/api/admin")
@Slf4j
public class FeatureFlagAdminController {
private final FeatureFlagService featureFlagService;
private final SplitManager splitManager;
public FeatureFlagAdminController(FeatureFlagService featureFlagService,
SplitManager splitManager) {
this.featureFlagService = featureFlagService;
this.splitManager = splitManager;
}
@GetMapping("/features")
public ResponseEntity<List<SplitView>> getAllFeatures() {
try {
List<SplitView> features = featureFlagService.getAllFeatureFlags();
return ResponseEntity.ok(features);
} catch (Exception e) {
log.error("Failed to get feature flags", e);
return ResponseEntity.status(500).build();
}
}
@PostMapping("/features/{featureName}/test")
public ResponseEntity<Map<String, Object>> testFeatureFlag(
@PathVariable String featureName,
@RequestBody TestFeatureRequest request) {
try {
UserContext userContext = UserContext.of(request.getUserId())
.withAttributes(request.getAttributes());
String treatment = featureFlagService.getTreatment(featureName, userContext);
Map<String, Object> response = new HashMap<>();
response.put("featureName", featureName);
response.put("userId", request.getUserId());
response.put("treatment", treatment);
response.put("enabled", "on".equals(treatment));
response.put("attributes", request.getAttributes());
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Failed to test feature flag: {}", featureName, e);
return ResponseEntity.status(500).build();
}
}
}
class TestFeatureRequest {
private String userId;
private Map<String, Object> attributes;
// Getters and setters
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public Map<String, Object> getAttributes() { return attributes; }
public void setAttributes(Map<String, Object> attributes) { this.attributes = attributes; }
}
Step 9: Testing Utilities
@Configuration
@Profile("test")
public class TestSplitIOConfiguration {
@Bean
public SplitClient testSplitClient() {
return new TestSplitClient();
}
@Bean
public SplitManager testSplitManager() {
return new TestSplitManager();
}
/**
* Test implementation for unit tests
*/
public static class TestSplitClient implements SplitClient {
private final Map<String, String> featureFlags = new HashMap<>();
public TestSplitClient() {
// Default test flags
featureFlags.put("new_checkout_ui", "off");
featureFlags.put("premium_features", "on");
featureFlags.put("recommendation_engine_v2", "off");
}
@Override
public String getTreatment(String key, String splitName) {
return featureFlags.getOrDefault(splitName, "off");
}
@Override
public String getTreatment(String key, String splitName, Map<String, Object> attributes) {
return getTreatment(key, splitName);
}
@Override
public SplitResult getTreatmentWithConfig(String key, String splitName, Map<String, Object> attributes) {
String treatment = getTreatment(key, splitName);
return new SplitResult(treatment, null);
}
// Implement other methods with default behavior...
@Override
public void destroy() {}
@Override
public boolean track(String key, String trafficType, String eventType) { return true; }
@Override
public boolean track(String key, String trafficType, String eventType, double value) { return true; }
@Override
public boolean track(String key, String trafficType, String eventType, Map<String, Object> properties) { return true; }
@Override
public boolean track(String key, String trafficType, String eventType, double value, Map<String, Object> properties) { return true; }
@Override
public void blockUntilReady() {}
@Override
public boolean isReady() { return true; }
// Helper method for tests
public void setTreatment(String featureFlag, String treatment) {
featureFlags.put(featureFlag, treatment);
}
}
public static class TestSplitManager implements SplitManager {
@Override
public List<SplitView> splits() { return Collections.emptyList(); }
@Override
public SplitView split(String splitName) { return null; }
@Override
public List<String> splitNames() { return Collections.emptyList(); }
@Override
public void blockUntilReady() {}
}
}
Running the Application
- Get Split.io API Key:
- Sign up at Split.io
- Create your feature flags in the dashboard
- Get your API key from workspace settings
- Set environment variable:
export SPLIT_IO_API_KEY=your_api_key_here
- Run the application:
mvn spring-boot:run
- Test feature flags:
curl -H "X-User-Id: user123" http://localhost:8080/api/features/new_checkout_ui
Best Practices
1. Naming Conventions
// Good naming "checkout_ui_v2" "search_algorithm_2024" "promo_banner_holiday" // Avoid "new_feature" // Too vague "test_flag" // Not descriptive
2. Flag Lifecycle Management
- Development: Localhost mode with hardcoded treatments
- Staging: Targeted to internal users
- Production: Gradual rollout with metrics
- Cleanup: Remove flags after full rollout
3. Monitoring and Alerting
- Track feature flag exposure rates
- Monitor performance impact of new features
- Set up alerts for unexpected treatment distributions
- Log feature flag decisions for debugging
Conclusion
Implementing Split.io feature flags in Java provides:
- Safe deployments with instant rollback capabilities
- Gradual feature rollouts to mitigate risk
- A/B testing infrastructure for data-driven decisions
- User segmentation for personalized experiences
- Operational control without code deployments
By following this comprehensive implementation, you can build a robust feature flag system that enables continuous delivery while maintaining stability and control over your production environment.