Dark Launch Strategy in Java: Complete Implementation Guide

Introduction

Dark Launching (also known as Feature Flagging or Gradual Rollouts) is a deployment strategy where new features are released to a subset of users before making them available to everyone. This allows for testing in production, gathering real-user metrics, and mitigating risks associated with new feature releases.

This guide covers implementing a comprehensive dark launch strategy in Java with feature flags, gradual rollouts, A/B testing, and operational controls.


Architecture Overview

[Application] → [Feature Flag Client] → [Flag Management Service] → [Configuration Store]
↓                  ↓                      ↓                       ↓
Feature Checks    Local Cache            Rules Engine           Redis/Database
User Context      Fallback Logic         Targeting              File System
A/B Testing       Metrics Collection     Gradual Rollouts       Environment Vars

Step 1: Project Dependencies

<properties>
<spring-boot.version>3.2.0</spring-boot.version>
<redis.version>3.2.0</redis.version>
<micrometer.version>1.12.0</micrometer.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-validation</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- Redis for feature flag storage -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- Cache -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- Metrics -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
<version>${micrometer.version}</version>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
<version>${micrometer.version}</version>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.0</version>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>${spring-boot.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

Step 2: Configuration

application.yml

feature:
flags:
enabled: true
cache:
ttl-minutes: 5
max-size: 1000
fallback:
enabled: true
default-enabled: false
refresh:
interval-seconds: 30
metrics:
enabled: true
prefix: "feature_flag"
# Storage configuration
storage:
type: redis # Options: redis, in_memory, database
redis:
key-prefix: "feature_flags:"
database:
table-name: "feature_flags"
# Targeting rules
targeting:
max-rules-per-flag: 50
enable-sticky-sessions: true
# Redis configuration
spring:
data:
redis:
host: localhost
port: 6379
password: ""
database: 0
cache:
type: redis
redis:
time-to-live: 300000 # 5 minutes
# Management endpoints
management:
endpoints:
web:
exposure:
include: health,metrics,featureflags
endpoint:
featureflags:
enabled: true
# Logging
logging:
level:
com.example.featureflags: DEBUG

Step 3: Core Data Models

Feature Flag Models

FeatureFlag.java

package com.example.featureflags.model;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime;
import java.util.*;
public class FeatureFlag {
@NotBlank
private String key;
private String name;
private String description;
@NotNull
private Boolean enabled = false;
private RolloutStrategy rolloutStrategy = RolloutStrategy.GRADUAL;
private Double rolloutPercentage = 0.0;
private List<TargetingRule> targetingRules = new ArrayList<>();
private Map<String, Object> variants = new HashMap<>();
private String defaultVariant = "control";
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updatedAt;
private String createdBy;
private Map<String, Object> metadata = new HashMap<>();
// Constructors
public FeatureFlag() {}
public FeatureFlag(String key, String name, boolean enabled) {
this.key = key;
this.name = name;
this.enabled = enabled;
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
// Getters and Setters
public String getKey() { return key; }
public void setKey(String key) { this.key = key; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Boolean getEnabled() { return enabled; }
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
public RolloutStrategy getRolloutStrategy() { return rolloutStrategy; }
public void setRolloutStrategy(RolloutStrategy rolloutStrategy) { this.rolloutStrategy = rolloutStrategy; }
public Double getRolloutPercentage() { return rolloutPercentage; }
public void setRolloutPercentage(Double rolloutPercentage) { this.rolloutPercentage = rolloutPercentage; }
public List<TargetingRule> getTargetingRules() { return targetingRules; }
public void setTargetingRules(List<TargetingRule> targetingRules) { this.targetingRules = targetingRules; }
public Map<String, Object> getVariants() { return variants; }
public void setVariants(Map<String, Object> variants) { this.variants = variants; }
public String getDefaultVariant() { return defaultVariant; }
public void setDefaultVariant(String defaultVariant) { this.defaultVariant = defaultVariant; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public String getCreatedBy() { return createdBy; }
public void setCreatedBy(String createdBy) { this.createdBy = createdBy; }
public Map<String, Object> getMetadata() { return metadata; }
public void setMetadata(Map<String, Object> metadata) { this.metadata = metadata; }
// Helper methods
public void addTargetingRule(TargetingRule rule) {
if (targetingRules == null) {
targetingRules = new ArrayList<>();
}
targetingRules.add(rule);
}
public void addVariant(String name, Object value) {
if (variants == null) {
variants = new HashMap<>();
}
variants.put(name, value);
}
public boolean hasVariants() {
return variants != null && !variants.isEmpty();
}
}

TargetingRule.java

package com.example.featureflags.model;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import java.util.List;
import java.util.Map;
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = UserIdRule.class, name = "USER_ID"),
@JsonSubTypes.Type(value = PercentageRule.class, name = "PERCENTAGE"),
@JsonSubTypes.Type(value = UserPropertyRule.class, name = "USER_PROPERTY"),
@JsonSubTypes.Type(value = CohortRule.class, name = "COHORT"),
@JsonSubTypes.Type(value = DateTimeRule.class, name = "DATETIME")
})
public abstract class TargetingRule {
private String name;
private Boolean enabled = true;
private String variant;
public abstract boolean evaluate(EvaluationContext context);
// Getters and Setters
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Boolean getEnabled() { return enabled; }
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
public String getVariant() { return variant; }
public void setVariant(String variant) { this.variant = variant; }
}
// Specific rule implementations
class UserIdRule extends TargetingRule {
private List<String> userIds;
private List<String> excludedUserIds;
@Override
public boolean evaluate(EvaluationContext context) {
if (!getEnabled()) return false;
String userId = context.getUserId();
if (userId == null) return false;
if (excludedUserIds != null && excludedUserIds.contains(userId)) {
return false;
}
return userIds != null && userIds.contains(userId);
}
// Getters and Setters
public List<String> getUserIds() { return userIds; }
public void setUserIds(List<String> userIds) { this.userIds = userIds; }
public List<String> getExcludedUserIds() { return excludedUserIds; }
public void setExcludedUserIds(List<String> excludedUserIds) { this.excludedUserIds = excludedUserIds; }
}
class PercentageRule extends TargetingRule {
private Double percentage;
private String salt; // For consistent hashing
@Override
public boolean evaluate(EvaluationContext context) {
if (!getEnabled() || percentage == null) return false;
String userId = context.getUserId();
if (userId == null) {
// Fallback to random if no user ID
return Math.random() * 100 < percentage;
}
// Consistent hashing based on user ID and salt
String hashInput = (salt != null ? salt : "") + userId;
int hash = Math.abs(hashInput.hashCode()) % 100;
return hash < percentage;
}
// Getters and Setters
public Double getPercentage() { return percentage; }
public void setPercentage(Double percentage) { this.percentage = percentage; }
public String getSalt() { return salt; }
public void setSalt(String salt) { this.salt = salt; }
}
class UserPropertyRule extends TargetingRule {
private String property;
private Object value;
private Operator operator = Operator.EQUALS;
@Override
public boolean evaluate(EvaluationContext context) {
if (!getEnabled() || property == null) return false;
Object userValue = context.getUserProperties().get(property);
if (userValue == null) return false;
return operator.compare(userValue, value);
}
// Getters and Setters
public String getProperty() { return property; }
public void setProperty(String property) { this.property = property; }
public Object getValue() { return value; }
public void setValue(Object value) { this.value = value; }
public Operator getOperator() { return operator; }
public void setOperator(Operator operator) { this.operator = operator; }
}
class CohortRule extends TargetingRule {
private List<String> cohorts;
@Override
public boolean evaluate(EvaluationContext context) {
if (!getEnabled() || cohorts == null) return false;
List<String> userCohorts = context.getCohorts();
if (userCohorts == null) return false;
return userCohorts.stream().anyMatch(cohorts::contains);
}
// Getters and Setters
public List<String> getCohorts() { return cohorts; }
public void setCohorts(List<String> cohorts) { this.cohorts = cohorts; }
}
class DateTimeRule extends TargetingRule {
private LocalDateTime startTime;
private LocalDateTime endTime;
@Override
public boolean evaluate(EvaluationContext context) {
if (!getEnabled()) return false;
LocalDateTime now = LocalDateTime.now();
boolean afterStart = startTime == null || now.isAfter(startTime);
boolean beforeEnd = endTime == null || now.isBefore(endTime);
return afterStart && beforeEnd;
}
// Getters and Setters
public LocalDateTime getStartTime() { return startTime; }
public void setStartTime(LocalDateTime startTime) { this.startTime = startTime; }
public LocalDateTime getEndTime() { return endTime; }
public void setEndTime(LocalDateTime endTime) { this.endTime = endTime; }
}

Enums and Supporting Classes

Enums.java

package com.example.featureflags.model;
public enum RolloutStrategy {
GRADUAL,       // Gradually increase percentage
ALL_OR_NOTHING, // Either 0% or 100%
TARGETED,      // Specific users/groups only
A_B_TESTING    // A/B testing with variants
}
public enum Operator {
EQUALS {
@Override
public boolean compare(Object a, Object b) {
return a.equals(b);
}
},
NOT_EQUALS {
@Override
public boolean compare(Object a, Object b) {
return !a.equals(b);
}
},
CONTAINS {
@Override
public boolean compare(Object a, Object b) {
return a.toString().contains(b.toString());
}
},
GREATER_THAN {
@Override
@SuppressWarnings("unchecked")
public boolean compare(Object a, Object b) {
if (a instanceof Comparable && b instanceof Comparable) {
return ((Comparable<Object>) a).compareTo(b) > 0;
}
return false;
}
},
LESS_THAN {
@Override
@SuppressWarnings("unchecked")
public boolean compare(Object a, Object b) {
if (a instanceof Comparable && b instanceof Comparable) {
return ((Comparable<Object>) a).compareTo(b) < 0;
}
return false;
}
};
public abstract boolean compare(Object a, Object b);
}

EvaluationContext.java

package com.example.featureflags.model;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class EvaluationContext {
private String userId;
private String sessionId;
private String environment;
private Map<String, Object> userProperties = new HashMap<>();
private List<String> cohorts;
private Map<String, Object> attributes = new HashMap<>();
// Constructors
public EvaluationContext() {}
public EvaluationContext(String userId) {
this.userId = userId;
}
public EvaluationContext(String userId, Map<String, Object> userProperties) {
this.userId = userId;
this.userProperties = userProperties != null ? userProperties : new HashMap<>();
}
// Builder pattern
public static Builder builder() {
return new Builder();
}
public static class Builder {
private EvaluationContext context = new EvaluationContext();
public Builder userId(String userId) {
context.userId = userId;
return this;
}
public Builder sessionId(String sessionId) {
context.sessionId = sessionId;
return this;
}
public Builder environment(String environment) {
context.environment = environment;
return this;
}
public Builder userProperty(String key, Object value) {
context.userProperties.put(key, value);
return this;
}
public Builder userProperties(Map<String, Object> properties) {
context.userProperties.putAll(properties);
return this;
}
public Builder cohorts(List<String> cohorts) {
context.cohorts = cohorts;
return this;
}
public Builder attribute(String key, Object value) {
context.attributes.put(key, value);
return this;
}
public EvaluationContext build() {
return context;
}
}
// Getters and Setters
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public String getSessionId() { return sessionId; }
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
public String getEnvironment() { return environment; }
public void setEnvironment(String environment) { this.environment = environment; }
public Map<String, Object> getUserProperties() { return userProperties; }
public void setUserProperties(Map<String, Object> userProperties) { this.userProperties = userProperties; }
public List<String> getCohorts() { return cohorts; }
public void setCohorts(List<String> cohorts) { this.cohorts = cohorts; }
public Map<String, Object> getAttributes() { return attributes; }
public void setAttributes(Map<String, Object> attributes) { this.attributes = attributes; }
// Helper methods
public void addUserProperty(String key, Object value) {
userProperties.put(key, value);
}
public Object getUserProperty(String key) {
return userProperties.get(key);
}
}

FeatureEvaluationResult.java

package com.example.featureflags.model;
public class FeatureEvaluationResult {
private final boolean enabled;
private final String variant;
private final Object variantValue;
private final String reason;
private final String flagKey;
public FeatureEvaluationResult(boolean enabled, String variant, Object variantValue, 
String reason, String flagKey) {
this.enabled = enabled;
this.variant = variant;
this.variantValue = variantValue;
this.reason = reason;
this.flagKey = flagKey;
}
// Getters
public boolean isEnabled() { return enabled; }
public String getVariant() { return variant; }
public Object getVariantValue() { return variantValue; }
public String getReason() { return reason; }
public String getFlagKey() { return flagKey; }
// Helper methods
public boolean hasVariant() {
return variant != null;
}
public <T> T getVariantValue(Class<T> type) {
if (variantValue == null) return null;
return type.cast(variantValue);
}
public static FeatureEvaluationResult disabled(String flagKey) {
return new FeatureEvaluationResult(false, null, null, "FEATURE_DISABLED", flagKey);
}
public static FeatureEvaluationResult enabled(String flagKey, String variant, Object variantValue) {
return new FeatureEvaluationResult(true, variant, variantValue, "FEATURE_ENABLED", flagKey);
}
public static FeatureEvaluationResult enabled(String flagKey) {
return new FeatureEvaluationResult(true, null, null, "FEATURE_ENABLED", flagKey);
}
}

Step 4: Feature Flag Service

Core Feature Flag Service

FeatureFlagService.java

package com.example.featureflags.service;
import com.example.featureflags.model.*;
import io.micrometer.core.instrument.MeterRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Service
public class FeatureFlagService {
private static final Logger logger = LoggerFactory.getLogger(FeatureFlagService.class);
private final FeatureFlagStorage storage;
private final MeterRegistry meterRegistry;
public FeatureFlagService(FeatureFlagStorage storage, MeterRegistry meterRegistry) {
this.storage = storage;
this.meterRegistry = meterRegistry;
}
@Cacheable(value = "featureFlags", key = "#key")
public Optional<FeatureFlag> getFlag(String key) {
logger.debug("Fetching feature flag: {}", key);
return storage.getFlag(key);
}
public FeatureEvaluationResult evaluateFlag(String key, EvaluationContext context) {
return evaluateFlag(key, context, null);
}
public FeatureEvaluationResult evaluateFlag(String key, EvaluationContext context, 
Map<String, Object> defaultVariants) {
long startTime = System.currentTimeMillis();
try {
Optional<FeatureFlag> flagOpt = getFlag(key);
if (flagOpt.isEmpty()) {
// Flag not found, check if we have a default variant
if (defaultVariants != null && defaultVariants.containsKey(key)) {
Object defaultValue = defaultVariants.get(key);
if (defaultValue instanceof Boolean && (Boolean) defaultValue) {
return FeatureEvaluationResult.enabled(key);
}
}
return FeatureEvaluationResult.disabled(key);
}
FeatureFlag flag = flagOpt.get();
// Check if feature is globally disabled
if (!flag.getEnabled()) {
return FeatureEvaluationResult.disabled(key);
}
// Evaluate targeting rules in order
for (TargetingRule rule : flag.getTargetingRules()) {
if (rule.evaluate(context)) {
String variant = rule.getVariant() != null ? rule.getVariant() : flag.getDefaultVariant();
Object variantValue = getVariantValue(flag, variant);
recordEvaluationMetrics(key, true, variant, "TARGETING_RULE");
return FeatureEvaluationResult.enabled(key, variant, variantValue);
}
}
// Check rollout percentage
if (flag.getRolloutPercentage() != null && flag.getRolloutPercentage() > 0) {
PercentageRule percentageRule = new PercentageRule();
percentageRule.setPercentage(flag.getRolloutPercentage());
percentageRule.setVariant(flag.getDefaultVariant());
if (percentageRule.evaluate(context)) {
Object variantValue = getVariantValue(flag, flag.getDefaultVariant());
recordEvaluationMetrics(key, true, flag.getDefaultVariant(), "ROLLOUT_PERCENTAGE");
return FeatureEvaluationResult.enabled(key, flag.getDefaultVariant(), variantValue);
}
}
// Feature disabled for this user
recordEvaluationMetrics(key, false, null, "NOT_IN_TARGET");
return FeatureEvaluationResult.disabled(key);
} catch (Exception e) {
logger.error("Error evaluating feature flag: {}", key, e);
recordEvaluationMetrics(key, false, null, "ERROR");
return FeatureEvaluationResult.disabled(key);
} finally {
long duration = System.currentTimeMillis() - startTime;
meterRegistry.timer("feature_flag.evaluation.time", "flag", key)
.record(java.time.Duration.ofMillis(duration));
}
}
public boolean isEnabled(String key, EvaluationContext context) {
return evaluateFlag(key, context).isEnabled();
}
public <T> T getVariant(String key, EvaluationContext context, Class<T> type) {
return getVariant(key, context, type, null);
}
public <T> T getVariant(String key, EvaluationContext context, Class<T> type, T defaultValue) {
FeatureEvaluationResult result = evaluateFlag(key, context);
if (result.isEnabled() && result.hasVariant()) {
T value = result.getVariantValue(type);
return value != null ? value : defaultValue;
}
return defaultValue;
}
private Object getVariantValue(FeatureFlag flag, String variant) {
if (flag.getVariants() != null && flag.getVariants().containsKey(variant)) {
return flag.getVariants().get(variant);
}
return null;
}
private void recordEvaluationMetrics(String flagKey, boolean enabled, String variant, String reason) {
meterRegistry.counter("feature_flag.evaluations",
"flag", flagKey,
"enabled", String.valueOf(enabled),
"variant", variant != null ? variant : "none",
"reason", reason
).increment();
}
// Administrative methods
public FeatureFlag createFlag(FeatureFlag flag) {
return storage.saveFlag(flag);
}
public FeatureFlag updateFlag(String key, FeatureFlag flag) {
flag.setKey(key); // Ensure key consistency
return storage.saveFlag(flag);
}
public void deleteFlag(String key) {
storage.deleteFlag(key);
}
public List<FeatureFlag> getAllFlags() {
return storage.getAllFlags();
}
}

Storage Abstraction

FeatureFlagStorage.java

package com.example.featureflags.service;
import com.example.featureflags.model.FeatureFlag;
import java.util.List;
import java.util.Optional;
public interface FeatureFlagStorage {
Optional<FeatureFlag> getFlag(String key);
FeatureFlag saveFlag(FeatureFlag flag);
void deleteFlag(String key);
List<FeatureFlag> getAllFlags();
}

RedisFeatureFlagStorage.java

package com.example.featureflags.service;
import com.example.featureflags.model.FeatureFlag;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@Repository
public class RedisFeatureFlagStorage implements FeatureFlagStorage {
private static final Logger logger = LoggerFactory.getLogger(RedisFeatureFlagStorage.class);
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
private final String keyPrefix;
public RedisFeatureFlagStorage(RedisTemplate<String, String> redisTemplate,
ObjectMapper objectMapper,
@Value("${feature.flags.storage.redis.key-prefix:feature_flags:}") 
String keyPrefix) {
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
this.keyPrefix = keyPrefix;
}
@Override
public Optional<FeatureFlag> getFlag(String key) {
try {
String redisKey = keyPrefix + key;
String json = redisTemplate.opsForValue().get(redisKey);
if (json == null) {
return Optional.empty();
}
FeatureFlag flag = objectMapper.readValue(json, FeatureFlag.class);
return Optional.of(flag);
} catch (JsonProcessingException e) {
logger.error("Error deserializing feature flag: {}", key, e);
return Optional.empty();
}
}
@Override
public FeatureFlag saveFlag(FeatureFlag flag) {
try {
String redisKey = keyPrefix + flag.getKey();
String json = objectMapper.writeValueAsString(flag);
redisTemplate.opsForValue().set(redisKey, json);
// Also add to index
redisTemplate.opsForSet().add(keyPrefix + "index", flag.getKey());
logger.info("Saved feature flag: {}", flag.getKey());
return flag;
} catch (JsonProcessingException e) {
logger.error("Error serializing feature flag: {}", flag.getKey(), e);
throw new RuntimeException("Failed to save feature flag", e);
}
}
@Override
public void deleteFlag(String key) {
String redisKey = keyPrefix + key;
redisTemplate.delete(redisKey);
redisTemplate.opsForSet().remove(keyPrefix + "index", key);
logger.info("Deleted feature flag: {}", key);
}
@Override
public List<FeatureFlag> getAllFlags() {
List<FeatureFlag> flags = new ArrayList<>();
Set<String> flagKeys = redisTemplate.opsForSet().members(keyPrefix + "index");
if (flagKeys == null) {
return flags;
}
for (String key : flagKeys) {
getFlag(key).ifPresent(flags::add);
}
return flags;
}
}

Step 5: Feature Flag Client

FeatureFlagClient.java

package com.example.featureflags.client;
import com.example.featureflags.model.EvaluationContext;
import com.example.featureflags.model.FeatureEvaluationResult;
import com.example.featureflags.service.FeatureFlagService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class FeatureFlagClient {
private static final Logger logger = LoggerFactory.getLogger(FeatureFlagClient.class);
private final FeatureFlagService featureFlagService;
private final Map<String, Object> defaultVariants;
public FeatureFlagClient(FeatureFlagService featureFlagService) {
this.featureFlagService = featureFlagService;
this.defaultVariants = new ConcurrentHashMap<>();
}
public boolean isEnabled(String featureKey) {
return isEnabled(featureKey, EvaluationContext.builder().build());
}
public boolean isEnabled(String featureKey, String userId) {
return isEnabled(featureKey, EvaluationContext.builder().userId(userId).build());
}
public boolean isEnabled(String featureKey, EvaluationContext context) {
return featureFlagService.isEnabled(featureKey, context);
}
public FeatureEvaluationResult evaluate(String featureKey, EvaluationContext context) {
return featureFlagService.evaluateFlag(featureKey, context, defaultVariants);
}
public <T> T getVariant(String featureKey, EvaluationContext context, Class<T> type) {
return featureFlagService.getVariant(featureKey, context, type);
}
public <T> T getVariant(String featureKey, EvaluationContext context, Class<T> type, T defaultValue) {
return featureFlagService.getVariant(featureKey, context, type, defaultValue);
}
public void setDefaultVariant(String featureKey, Object defaultValue) {
defaultVariants.put(featureKey, defaultValue);
}
// Convenience methods for common use cases
public boolean isEnabledForUser(String featureKey, String userId, Map<String, Object> userProperties) {
EvaluationContext context = EvaluationContext.builder()
.userId(userId)
.userProperties(userProperties)
.build();
return isEnabled(featureKey, context);
}
public String getStringVariant(String featureKey, EvaluationContext context, String defaultValue) {
return getVariant(featureKey, context, String.class, defaultValue);
}
public Integer getIntegerVariant(String featureKey, EvaluationContext context, Integer defaultValue) {
return getVariant(featureKey, context, Integer.class, defaultValue);
}
public Boolean getBooleanVariant(String featureKey, EvaluationContext context, Boolean defaultValue) {
return getVariant(featureKey, context, Boolean.class, defaultValue);
}
public Map<String, Object> getMapVariant(String featureKey, EvaluationContext context, 
Map<String, Object> defaultValue) {
@SuppressWarnings("unchecked")
Map<String, Object> result = getVariant(featureKey, context, Map.class, defaultValue);
return result != null ? result : defaultValue;
}
}

Step 6: Spring Configuration

FeatureFlagConfig.java

package com.example.featureflags.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
@Configuration
@EnableCaching
public class FeatureFlagConfig {
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
return template;
}
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(5))
.disableCachingNullValues();
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
}

Step 7: Usage Examples

1. Basic Feature Toggle

OrderService.java

package com.example.service;
import com.example.featureflags.client.FeatureFlagClient;
import com.example.featureflags.model.EvaluationContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private static final Logger logger = LoggerFactory.getLogger(OrderService.class);
private final FeatureFlagClient featureFlagClient;
public OrderService(FeatureFlagClient featureFlagClient) {
this.featureFlagClient = featureFlagClient;
}
public Order processOrder(OrderRequest request) {
// Check if new payment processing is enabled for this user
EvaluationContext context = EvaluationContext.builder()
.userId(request.getUserId())
.userProperty("tier", request.getUserTier())
.userProperty("country", request.getCountry())
.build();
boolean newPaymentEnabled = featureFlagClient.isEnabled(
"new-payment-processor", context);
PaymentResult paymentResult;
if (newPaymentEnabled) {
logger.info("Using new payment processor for user: {}", request.getUserId());
paymentResult = newPaymentProcessor.process(request);
} else {
logger.info("Using legacy payment processor for user: {}", request.getUserId());
paymentResult = legacyPaymentProcessor.process(request);
}
// Continue with order processing...
return createOrder(request, paymentResult);
}
}

2. A/B Testing with Variants

RecommendationService.java

package com.example.service;
import com.example.featureflags.client.FeatureFlagClient;
import com.example.featureflags.model.EvaluationContext;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
public class RecommendationService {
private final FeatureFlagClient featureFlagClient;
public RecommendationService(FeatureFlagClient featureFlagClient) {
this.featureFlagClient = featureFlagClient;
}
public List<Product> getRecommendations(String userId, String sessionId) {
EvaluationContext context = EvaluationContext.builder()
.userId(userId)
.sessionId(sessionId)
.userProperty("user_segment", getUserSegment(userId))
.build();
// Get the recommendation algorithm variant
String algorithmVariant = featureFlagClient.getStringVariant(
"recommendation-algorithm", 
context, 
"legacy" // default value
);
// Get algorithm parameters
Map<String, Object> algorithmParams = featureFlagClient.getMapVariant(
"recommendation-params",
context,
Map.of("maxResults", 10, "diversity", 0.5)
);
switch (algorithmVariant) {
case "collaborative-filtering":
return collaborativeFilteringAlgorithm.recommend(userId, algorithmParams);
case "content-based":
return contentBasedAlgorithm.recommend(userId, algorithmParams);
case "hybrid":
return hybridAlgorithm.recommend(userId, algorithmParams);
default:
return legacyAlgorithm.recommend(userId, algorithmParams);
}
}
public void trackRecommendationPerformance(String userId, String variant, 
double clickThroughRate) {
// Track A/B test results
// This data can be used to determine winning variant
metricsService.track("recommendation.performance", 
Map.of("userId", userId, "variant", variant, "ctr", clickThroughRate));
}
}

3. Gradual Rollout with Percentage

NewFeatureService.java

package com.example.service;
import com.example.featureflags.client.FeatureFlagClient;
import com.example.featureflags.model.EvaluationContext;
import org.springframework.stereotype.Service;
@Service
public class NewFeatureService {
private final FeatureFlagClient featureFlagClient;
public NewFeatureService(FeatureFlagClient featureFlagClient) {
this.featureFlagClient = featureFlagClient;
}
public void launchNewFeature(FeatureRequest request) {
String userId = request.getUserId();
EvaluationContext context = EvaluationContext.builder()
.userId(userId)
.userProperty("account_age_days", getAccountAgeDays(userId))
.userProperty("feature_usage_count", getFeatureUsageCount(userId))
.build();
// Gradually rollout to 10% of users initially
boolean newUIFeatureEnabled = featureFlagClient.isEnabled("new-ui-feature", context);
if (newUIFeatureEnabled) {
renderNewUI(request);
// Track adoption metrics
metricsService.increment("new_ui.feature.used", 
Map.of("userId", userId));
} else {
renderLegacyUI(request);
}
}
public void trackFeatureUsage(String userId, String featureName, boolean success) {
// Track how users interact with the new feature
metricsService.track("feature.usage",
Map.of("userId", userId, "feature", featureName, "success", success));
}
}

4. Operational Feature Flags

SystemService.java

package com.example.service;
import com.example.featureflags.client.FeatureFlagClient;
import org.springframework.stereotype.Service;
@Service
public class SystemService {
private final FeatureFlagClient featureFlagClient;
public SystemService(FeatureFlagClient featureFlagClient) {
this.featureFlagClient = featureFlagClient;
}
public void processBackgroundJob(JobRequest job) {
// Kill switch for stopping all background processing
if (!featureFlagClient.isEnabled("background-processing-enabled")) {
logger.warn("Background processing is disabled via feature flag");
return;
}
// Circuit breaker for external service
if (!featureFlagClient.isEnabled("external-service-circuit-closed")) {
throw new ServiceUnavailableException("External service temporarily disabled");
}
// Process the job...
processJob(job);
}
public void performMaintenance() {
// Check if maintenance mode is enabled
if (featureFlagClient.isEnabled("maintenance-mode")) {
throw new MaintenanceException("System is under maintenance");
}
// Perform maintenance tasks...
}
}

Step 8: Management API

FeatureFlagController.java

package com.example.featureflags.controller;
import com.example.featureflags.model.FeatureFlag;
import com.example.featureflags.service.FeatureFlagService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v1/feature-flags")
public class FeatureFlagController {
private final FeatureFlagService featureFlagService;
public FeatureFlagController(FeatureFlagService featureFlagService) {
this.featureFlagService = featureFlagService;
}
@GetMapping
public ResponseEntity<List<FeatureFlag>> getAllFlags() {
return ResponseEntity.ok(featureFlagService.getAllFlags());
}
@GetMapping("/{key}")
public ResponseEntity<FeatureFlag> getFlag(@PathVariable String key) {
return featureFlagService.getFlag(key)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<FeatureFlag> createFlag(@RequestBody FeatureFlag flag) {
return ResponseEntity.ok(featureFlagService.createFlag(flag));
}
@PutMapping("/{key}")
public ResponseEntity<FeatureFlag> updateFlag(@PathVariable String key, 
@RequestBody FeatureFlag flag) {
return ResponseEntity.ok(featureFlagService.updateFlag(key, flag));
}
@DeleteMapping("/{key}")
public ResponseEntity<Void> deleteFlag(@PathVariable String key) {
featureFlagService.deleteFlag(key);
return ResponseEntity.ok().build();
}
@PostMapping("/{key}/enable")
public ResponseEntity<FeatureFlag> enableFlag(@PathVariable String key) {
return featureFlagService.getFlag(key)
.map(flag -> {
flag.setEnabled(true);
return ResponseEntity.ok(featureFlagService.updateFlag(key, flag));
})
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/{key}/disable")
public ResponseEntity<FeatureFlag> disableFlag(@PathVariable String key) {
return featureFlagService.getFlag(key)
.map(flag -> {
flag.setEnabled(false);
return ResponseEntity.ok(featureFlagService.updateFlag(key, flag));
})
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/{key}/rollout/{percentage}")
public ResponseEntity<FeatureFlag> updateRollout(@PathVariable String key,
@PathVariable Double percentage) {
return featureFlagService.getFlag(key)
.map(flag -> {
flag.setRolloutPercentage(percentage);
return ResponseEntity.ok(featureFlagService.updateFlag(key, flag));
})
.orElse(ResponseEntity.notFound().build());
}
}

Step 9: Sample Feature Flag Definitions

1. Gradual Rollout Flag

{
"key": "new-checkout-experience",
"name": "New Checkout Experience",
"description": "Gradual rollout of new checkout UI",
"enabled": true,
"rolloutStrategy": "GRADUAL",
"rolloutPercentage": 25.0,
"targetingRules": [
{
"type": "USER_PROPERTY",
"name": "Premium Users",
"enabled": true,
"property": "user_tier",
"value": "premium",
"operator": "EQUALS"
}
],
"variants": {
"control": "old_checkout",
"treatment": "new_checkout"
},
"defaultVariant": "treatment"
}

2. A/B Testing Flag

{
"key": "pricing-page-test",
"name": "Pricing Page A/B Test",
"description": "Test different pricing page layouts",
"enabled": true,
"rolloutStrategy": "A_B_TESTING",
"rolloutPercentage": 100.0,
"targetingRules": [
{
"type": "PERCENTAGE",
"name": "Variant A - 33%",
"enabled": true,
"percentage": 33.0,
"variant": "layout_a"
},
{
"type": "PERCENTAGE", 
"name": "Variant B - 33%",
"enabled": true,
"percentage": 33.0,
"variant": "layout_b"
},
{
"type": "PERCENTAGE",
"name": "Variant C - 34%", 
"enabled": true,
"percentage": 34.0,
"variant": "layout_c"
}
],
"variants": {
"layout_a": {
"layout": "grid",
"highlightedTier": "premium",
"ctaColor": "blue"
},
"layout_b": {
"layout": "list", 
"highlightedTier": "basic",
"ctaColor": "green"
},
"layout_c": {
"layout": "comparison",
"highlightedTier": "standard",
"ctaColor": "orange"
}
},
"defaultVariant": "layout_a"
}

3. Operational Kill Switch

{
"key": "payment-processing-enabled",
"name": "Payment Processing Kill Switch",
"description": "Emergency kill switch for payment processing",
"enabled": true,
"rolloutStrategy": "ALL_OR_NOTHING",
"rolloutPercentage": 100.0,
"targetingRules": [],
"metadata": {
"emergency_contact": "[email protected]",
"impact_level": "critical"
}
}

Best Practices

1. Naming Conventions

  • Use consistent, descriptive names: new-feature-rollout, ab-test-pricing
  • Group related flags: payment-new-processor, payment-fallback-mode
  • Avoid environment-specific names in flag definitions

2. Lifecycle Management

  • Regularly clean up unused flags
  • Document flag purpose and ownership
  • Set up expiration policies for temporary flags

3. Monitoring and Alerting

  • Monitor evaluation latency and error rates
  • Alert on rapid changes in flag evaluation patterns
  • Track business metrics alongside flag usage

4. Security

  • Restrict flag modification to authorized users
  • Audit all flag changes
  • Validate flag configurations before deployment

5. Performance

  • Cache flag evaluations appropriately
  • Use efficient data structures for targeting rules
  • Monitor memory usage with large flag sets

Conclusion

This comprehensive dark launch implementation provides:

  • Flexible targeting with multiple rule types (user ID, properties, percentages, cohorts)
  • A/B testing support with variant management
  • Gradual rollouts with percentage-based deployment
  • Operational controls for kill switches and circuit breakers
  • Performance optimization with caching and efficient evaluation
  • Monitoring integration with metrics and analytics

By implementing this dark launch strategy, you can:

  • Reduce deployment risk by gradually exposing new features
  • Gather real-user feedback before full rollout
  • Run experiments to optimize feature performance
  • Quickly respond to issues with operational controls
  • Make data-driven decisions about feature releases

This approach enables safer, more controlled feature deployments while maximizing learning and minimizing risk.

Leave a Reply

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


Macro Nepal Helper