Java dominates enterprise software. It consistently ranks among the most widely used languages in large organizations, and nearly every one of those organizations uses feature flags. The combination is predictable: massive Spring Boot monoliths and microservice fleets carrying hundreds of stale flags woven into annotation-driven dependency injection, aspect-oriented interceptors, and configuration hierarchies that span YAML files, environment variables, and runtime evaluation.
In large Java codebases, stale feature flags accumulate faster than most teams realize. Each stale flag introduces multiple dead code paths, most invisible to static analysis because the branching is driven by runtime configuration rather than compile-time constants. In a language that already struggles with verbosity, stale flags are gasoline on a fire.
Why Java flag debt hits differently in enterprise
Java's enterprise ecosystem -- Spring Boot, dependency injection, annotation processing, and layers of abstraction -- creates a uniquely challenging environment for feature flag management. Flags do not just live in if statements. They live inside @ConditionalOnProperty annotations, @Profile selectors, factory beans, AOP advice, and configuration property classes that are loaded at startup and cached for the lifetime of the application.
The Spring Boot configuration trap
Spring Boot's property-driven configuration makes it trivially easy to add a flag and almost impossible to know when one is stale:
// application.yml
features:
new-checkout: true
enhanced-search: true
legacy-auth: false
experiment-pricing: true
@Component
public class CheckoutService {
@Value("${features.new-checkout:false}")
private boolean newCheckoutEnabled;
public OrderReceipt processCheckout(Cart cart) {
if (newCheckoutEnabled) {
return newCheckoutFlow(cart);
}
return legacyCheckoutFlow(cart);
}
}
This pattern is everywhere. The flag lives in a YAML file, is injected via @Value, and controls a runtime branch. There is no expiration, no ownership metadata, and no mechanism to detect that features.new-checkout has been true across every environment for the last nine months. The property stays in YAML because removing it might break something. The @Value injection stays because the property exists. The if statement stays because the injection exists. Each layer justifies the next in a circular dependency of inertia.
The annotation-driven complexity
Spring Boot's annotation system creates flag patterns that are invisible to naive text search:
@Configuration
public class SearchConfiguration {
@Bean
@ConditionalOnProperty(name = "features.enhanced-search", havingValue = "true")
public SearchEngine enhancedSearchEngine(ElasticsearchClient client) {
return new ElasticsearchSearchEngine(client);
}
@Bean
@ConditionalOnProperty(
name = "features.enhanced-search",
havingValue = "false",
matchIfMissing = true
)
public SearchEngine legacySearchEngine(JdbcTemplate jdbc) {
return new JdbcSearchEngine(jdbc);
}
}
When features.enhanced-search has been true for six months, the legacySearchEngine bean is never instantiated. But the class, its dependencies, and its tests all survive because @ConditionalOnProperty is evaluated at startup, not at compile time. The dead bean definition is invisible to the Java compiler, invisible to most static analysis tools, and invisible to every developer who is not actively looking at the configuration class.
Implementing flags in Spring Boot: Three approaches
Approach 1: LaunchDarkly SDK
LaunchDarkly is the most common third-party flag provider in enterprise Java. The SDK integrates cleanly with Spring Boot's dependency injection:
@Configuration
public class LaunchDarklyConfig {
@Bean
public LDClient ldClient(@Value("${launchdarkly.sdk-key}") String sdkKey)
throws IOException {
LDConfig config = new LDConfig.Builder()
.events(Components.sendEvents()
.capacity(10000)
.flushInterval(Duration.ofSeconds(5)))
.dataStore(Components.persistentDataStore(
Redis.dataStore().uri(URI.create("redis://localhost:6379"))))
.build();
return new LDClient(sdkKey, config);
}
}
@Service
public class PaymentService {
private final LDClient ldClient;
private final StripeGateway stripeGateway;
private final LegacyPaymentGateway legacyGateway;
public PaymentService(LDClient ldClient,
StripeGateway stripeGateway,
LegacyPaymentGateway legacyGateway) {
this.ldClient = ldClient;
this.stripeGateway = stripeGateway;
this.legacyGateway = legacyGateway;
}
public PaymentResult processPayment(User user, Money amount) {
LDContext context = LDContext.builder(user.getId())
.set("plan", user.getPlan().name())
.set("country", user.getCountry())
.build();
boolean useStripe = ldClient.boolVariation(
"stripe-payment-gateway", context, false);
if (useStripe) {
return stripeGateway.charge(user, amount);
}
return legacyGateway.charge(user, amount);
}
}
Cleanup challenge with LaunchDarkly: The LDContext construction is often shared across multiple flag checks. Removing one flag does not necessarily mean the context builder can be simplified. You need to trace every boolVariation, stringVariation, and jsonValueVariation call that uses the same context to determine what context attributes are still needed.
Approach 2: Unleash SDK
Unleash's Java SDK takes a strategy-based approach that fits well with enterprise Java's preference for explicit configuration:
@Configuration
public class UnleashConfig {
@Bean
public Unleash unleash(
@Value("${unleash.api-url}") String apiUrl,
@Value("${unleash.api-key}") String apiKey,
@Value("${spring.application.name}") String appName) {
return new DefaultUnleash(
UnleashConfig.builder()
.appName(appName)
.instanceId(InetAddress.getLocalHost().getHostName())
.unleashAPI(apiUrl)
.apiKey(apiKey)
.fetchTogglesInterval(10)
.synchronousFetchOnInitialisation(true)
.build()
);
}
}
@Service
public class NotificationService {
private final Unleash unleash;
private final EmailSender emailSender;
private final PushNotificationSender pushSender;
public NotificationService(Unleash unleash,
EmailSender emailSender,
PushNotificationSender pushSender) {
this.unleash = unleash;
this.emailSender = emailSender;
this.pushSender = pushSender;
}
public void notifyUser(User user, Notification notification) {
UnleashContext context = UnleashContext.builder()
.userId(user.getId())
.addProperty("tier", user.getTier().name())
.build();
if (unleash.isEnabled("push-notifications", context)) {
pushSender.send(user, notification);
} else {
emailSender.send(user, notification);
}
if (unleash.isEnabled("notification-analytics", context)) {
analyticsService.track("notification_sent",
Map.of("channel",
unleash.isEnabled("push-notifications", context)
? "push" : "email",
"type", notification.getType()));
}
}
}
Cleanup challenge with Unleash: Notice how notification-analytics references the push-notifications flag inline. Nested flag dependencies like this are common in Java services and make cleanup ordering critical. You must remove notification-analytics first (or update its analytics payload) before removing push-notifications.
Approach 3: Custom implementation with Spring Boot properties
Many enterprise teams build their own flag system using Spring Boot's configuration properties. This gives full control but creates the most cleanup debt:
@ConfigurationProperties(prefix = "features")
@Validated
public class FeatureFlagProperties {
@NotNull
private Map<String, Boolean> flags = new HashMap<>();
private Map<String, FlagMetadata> metadata = new HashMap<>();
public boolean isEnabled(String flagName) {
return flags.getOrDefault(flagName, false);
}
// Getters and setters...
public static class FlagMetadata {
private String owner;
private LocalDate created;
private LocalDate expires;
private String jiraTicket;
// Getters and setters...
}
}
@Service
public class ReportingService {
private final FeatureFlagProperties flags;
private final ReportGenerator legacyGenerator;
private final ReportGeneratorV2 newGenerator;
public ReportingService(FeatureFlagProperties flags,
ReportGenerator legacyGenerator,
ReportGeneratorV2 newGenerator) {
this.flags = flags;
this.legacyGenerator = legacyGenerator;
this.newGenerator = newGenerator;
}
public Report generateReport(ReportRequest request) {
if (flags.isEnabled("reporting-v2")) {
return newGenerator.generate(request);
}
return legacyGenerator.generate(request);
}
public byte[] exportReport(Report report, String format) {
if (flags.isEnabled("pdf-export-v2")) {
return newGenerator.exportToPdf(report);
}
if ("csv".equals(format)) {
return legacyGenerator.exportToCsv(report);
}
return legacyGenerator.exportToPdf(report);
}
}
Cleanup challenge with custom flags: The FeatureFlagProperties class becomes a graveyard. Flags accumulate in the YAML, the metadata map grows, and the class itself becomes a dependency in dozens of services. Removing a flag means updating YAML across every environment profile (application.yml, application-dev.yml, application-staging.yml, application-prod.yml), removing the property from the map, and cleaning up every service that references it.
Common anti-patterns in Java flag code
Enterprise Java codebases are particularly susceptible to flag anti-patterns because the language's verbosity and abstraction layers make bad patterns feel "normal." Here are the most damaging ones.
Anti-pattern 1: Flag spaghetti in service methods
// DON'T: Multiple flags creating exponential code paths
public OrderResponse placeOrder(OrderRequest request) {
Cart cart = cartService.getCart(request.getUserId());
if (flags.isEnabled("new-pricing-engine")) {
cart = pricingEngineV2.applyPricing(cart);
} else {
cart = pricingEngineV1.applyPricing(cart);
}
if (flags.isEnabled("tax-service-v3")) {
if (flags.isEnabled("new-pricing-engine")) {
cart = taxServiceV3.calculateWithNewPricing(cart);
} else {
cart = taxServiceV3.calculateWithLegacyPricing(cart);
}
} else {
cart = taxServiceV2.calculate(cart);
}
if (flags.isEnabled("async-inventory-check")) {
inventoryService.reserveAsync(cart);
} else {
inventoryService.reserveSync(cart);
}
return orderRepository.save(cart.toOrder());
}
Three flags produce eight possible execution paths in a single method. If all three are permanently enabled, only one path is reachable -- but all eight must be tested, maintained, and mentally parsed by every developer who reads this code.
Fix: Use the Strategy pattern to isolate flag decisions:
// DO: Isolate flag decisions at the composition root
@Configuration
public class OrderProcessingConfig {
@Bean
public PricingEngine pricingEngine(FeatureFlagProperties flags,
PricingEngineV2 v2,
PricingEngineV1 v1) {
return flags.isEnabled("new-pricing-engine") ? v2 : v1;
}
@Bean
public TaxCalculator taxCalculator(FeatureFlagProperties flags,
TaxServiceV3 v3,
TaxServiceV2 v2) {
return flags.isEnabled("tax-service-v3") ? v3 : v2;
}
@Bean
public InventoryReserver inventoryReserver(FeatureFlagProperties flags,
AsyncReserver async,
SyncReserver sync) {
return flags.isEnabled("async-inventory-check") ? async : sync;
}
}
@Service
public class OrderService {
private final PricingEngine pricingEngine;
private final TaxCalculator taxCalculator;
private final InventoryReserver inventoryReserver;
public OrderResponse placeOrder(OrderRequest request) {
Cart cart = cartService.getCart(request.getUserId());
cart = pricingEngine.applyPricing(cart);
cart = taxCalculator.calculate(cart);
inventoryReserver.reserve(cart);
return orderRepository.save(cart.toOrder());
}
}
Now the OrderService has zero flag references. Cleanup means updating the configuration class and deleting the unused implementation. The service itself never changes.
Anti-pattern 2: Flags in annotations
// DON'T: Using Spring profiles as feature flags
@Profile("new-auth")
@Component
public class OAuth2AuthProvider implements AuthProvider { /* ... */ }
@Profile("!new-auth")
@Component
public class LegacyAuthProvider implements AuthProvider { /* ... */ }
Spring profiles are for environment differentiation (dev, staging, prod), not feature toggling. Using them as flags means activating a "feature" requires restarting the application with different profile arguments, which defeats the purpose of runtime feature flagging.
Anti-pattern 3: Flag state in the database
// DON'T: Storing flag state in business tables
@Entity
public class Tenant {
@Id
private Long id;
private String name;
// These are feature flags masquerading as entity fields
private boolean enableBetaDashboard;
private boolean enableAdvancedReporting;
private boolean enableSsoIntegration;
private boolean enableCustomBranding;
}
When flags live as entity columns, cleanup requires a database migration, a data backfill, and updates to every query, DTO, and serializer that references the field. What should be a one-hour code cleanup becomes a multi-day effort coordinated across the data, backend, and frontend teams.
Anti-pattern 4: String-based flag keys without constants
// DON'T: Hardcoded string literals everywhere
if (ldClient.boolVariation("new-checkout-flow", context, false)) { /* ... */ }
// ...in another file...
if (ldClient.boolVariation("new-checkout-flow ", context, false)) { /* ... */ }
// ^ trailing space = different flag
A single typo in a flag key creates a silent failure -- the SDK returns the default value instead of the evaluated value. In a codebase with hundreds of flag references, typos are statistically inevitable.
Fix: Use a constants class:
public final class FeatureFlags {
private FeatureFlags() {}
public static final String NEW_CHECKOUT_FLOW = "new-checkout-flow";
public static final String ENHANCED_SEARCH = "enhanced-search";
public static final String PUSH_NOTIFICATIONS = "push-notifications";
public static final String REPORTING_V2 = "reporting-v2";
}
if (ldClient.boolVariation(FeatureFlags.NEW_CHECKOUT_FLOW, context, false)) {
// Compile-time verification of the flag key
}
Now removing a flag from the constants class triggers compile errors at every usage site. The compiler becomes your cleanup assistant.
Testing strategies with JUnit
Testing flag branches with parameterized tests
JUnit 5's @ParameterizedTest is the natural fit for testing both branches of a flag:
@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {
@Mock private LDClient ldClient;
@Mock private StripeGateway stripeGateway;
@Mock private LegacyPaymentGateway legacyGateway;
private PaymentService paymentService;
@BeforeEach
void setUp() {
paymentService = new PaymentService(ldClient, stripeGateway, legacyGateway);
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void processPayment_routesToCorrectGateway(boolean stripeEnabled) {
User user = TestFixtures.createUser();
Money amount = Money.of(99_99, "USD");
when(ldClient.boolVariation(
eq(FeatureFlags.STRIPE_GATEWAY), any(LDContext.class), eq(false)))
.thenReturn(stripeEnabled);
if (stripeEnabled) {
when(stripeGateway.charge(user, amount))
.thenReturn(PaymentResult.success("txn_123"));
} else {
when(legacyGateway.charge(user, amount))
.thenReturn(PaymentResult.success("legacy_456"));
}
PaymentResult result = paymentService.processPayment(user, amount);
assertThat(result.isSuccessful()).isTrue();
if (stripeEnabled) {
verify(stripeGateway).charge(user, amount);
verifyNoInteractions(legacyGateway);
} else {
verify(legacyGateway).charge(user, amount);
verifyNoInteractions(stripeGateway);
}
}
}
Testing with Spring Boot test slices
For integration tests that need the full Spring context with specific flags:
@SpringBootTest
@TestPropertySource(properties = {
"features.flags.new-checkout=true",
"features.flags.enhanced-search=false"
})
class CheckoutIntegrationTest {
@Autowired
private CheckoutService checkoutService;
@Test
void newCheckoutFlow_createsOrder() {
Cart cart = TestFixtures.createCartWithItems(3);
OrderReceipt receipt = checkoutService.processCheckout(cart);
assertThat(receipt).isNotNull();
assertThat(receipt.getItems()).hasSize(3);
assertThat(receipt.getPaymentMethod()).isEqualTo("stripe-v2");
}
}
The characterization test pattern
Before removing a stale flag, write tests that capture the behavior of the winning branch without any flag dependency. These tests should pass both before and after the flag removal:
class CheckoutCharacterizationTest {
/**
* These tests capture the permanent behavior after new-checkout is fully
* rolled out. They should pass before and after flag removal.
*/
@Test
void checkout_usesStripV2PaymentMethod() {
// This behavior is the "new" path. After flag removal, it should
// be the only path.
Cart cart = TestFixtures.createCart();
OrderReceipt receipt = checkoutService.processCheckout(cart);
assertThat(receipt.getPaymentMethod()).isEqualTo("stripe-v2");
}
@Test
void checkout_includesDetailedLineItems() {
Cart cart = TestFixtures.createCartWithItems(3);
OrderReceipt receipt = checkoutService.processCheckout(cart);
assertThat(receipt.getLineItems()).hasSize(3);
assertThat(receipt.getLineItems())
.allSatisfy(item -> {
assertThat(item.getUnitPrice()).isPositive();
assertThat(item.getTaxAmount()).isNotNull();
});
}
}
After the flag is removed, these tests continue to pass unchanged. Then you delete the flag-specific tests:
// DELETE after cleanup: Tests that reference the removed flag
// - testLegacyCheckoutFlow()
// - testLegacyCheckoutDoesNotIncludeTax()
// - testPaymentMethodIsLegacyWhenFlagDisabled()
Cleanup patterns: Removing flag conditionals safely
Pattern 1: Simple if/else removal
The most straightforward cleanup. Identify the winning branch, remove the conditional, and flatten:
Before:
public SearchResults search(String query, SearchContext context) {
if (flags.isEnabled("elasticsearch-v8")) {
return elasticsearchV8Client.search(query, context);
}
return elasticsearchV7Client.search(query, context);
}
After (flag permanently enabled):
public SearchResults search(String query, SearchContext context) {
return elasticsearchV8Client.search(query, context);
}
Then trace the impact: can elasticsearchV7Client be removed from the constructor? Is ElasticsearchV7Client used anywhere else? Can the ES_V7_URL configuration property be removed?
Pattern 2: Configuration bean cleanup
When flags control which beans are instantiated, cleanup means consolidating the configuration:
Before:
@Configuration
public class CacheConfig {
@Bean
@ConditionalOnProperty(name = "features.redis-cluster", havingValue = "true")
public CacheManager clusterCacheManager(RedisClusterConfiguration clusterConfig) {
return RedisCacheManager.builder(
RedisConnectionFactory.create(clusterConfig))
.cacheDefaults(defaultCacheConfig())
.build();
}
@Bean
@ConditionalOnProperty(
name = "features.redis-cluster",
havingValue = "false",
matchIfMissing = true)
public CacheManager singleNodeCacheManager(RedisStandaloneConfiguration config) {
return RedisCacheManager.builder(
RedisConnectionFactory.create(config))
.cacheDefaults(defaultCacheConfig())
.build();
}
}
After (cluster mode permanently enabled):
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisClusterConfiguration clusterConfig) {
return RedisCacheManager.builder(
RedisConnectionFactory.create(clusterConfig))
.cacheDefaults(defaultCacheConfig())
.build();
}
}
After this cleanup: remove the features.redis-cluster property from all YAML profiles, remove RedisStandaloneConfiguration if unused elsewhere, and update any tests that verified the conditional bean creation.
Pattern 3: Interface-based strategy cleanup
This is the cleanest pattern and the one that makes Java's type system work for you:
Before:
public interface ShippingCalculator {
ShippingCost calculate(Order order, Address destination);
}
@Component("legacyShipping")
public class FlatRateShippingCalculator implements ShippingCalculator {
@Override
public ShippingCost calculate(Order order, Address destination) {
return new ShippingCost(Money.of(9_99, "USD"));
}
}
@Component("newShipping")
public class ZoneBasedShippingCalculator implements ShippingCalculator {
@Override
public ShippingCost calculate(Order order, Address destination) {
Zone zone = zoneResolver.resolve(destination);
BigDecimal weight = order.getTotalWeight();
return shippingRateTable.lookup(zone, weight);
}
}
@Configuration
public class ShippingConfig {
@Bean
public ShippingCalculator shippingCalculator(
FeatureFlagProperties flags,
@Qualifier("newShipping") ShippingCalculator newCalc,
@Qualifier("legacyShipping") ShippingCalculator legacyCalc) {
return flags.isEnabled("zone-shipping") ? newCalc : legacyCalc;
}
}
After (zone shipping permanently enabled):
@Component
public class ShippingCalculator {
private final ZoneResolver zoneResolver;
private final ShippingRateTable shippingRateTable;
public ShippingCost calculate(Order order, Address destination) {
Zone zone = zoneResolver.resolve(destination);
BigDecimal weight = order.getTotalWeight();
return shippingRateTable.lookup(zone, weight);
}
}
The interface may no longer be necessary if only one implementation remains. The configuration class can be deleted entirely. The @Qualifier annotations and the FlatRateShippingCalculator class can be removed. This is a significant simplification that reduces the codebase's abstraction overhead.
Pattern 4: AOP-driven flag cleanup
Some enterprise teams use AOP to intercept methods based on flags. This is the hardest pattern to clean up because the flag's effect is invisible at the call site:
Before:
@Aspect
@Component
public class FeatureFlagAspect {
private final FeatureFlagProperties flags;
@Around("@annotation(flagGated)")
public Object checkFlag(ProceedingJoinPoint joinPoint,
FlagGated flagGated) throws Throwable {
if (flags.isEnabled(flagGated.value())) {
return joinPoint.proceed();
}
// Return default or call fallback
return flagGated.fallbackMethod().isEmpty()
? getDefaultReturn(joinPoint)
: invokeFallback(joinPoint, flagGated.fallbackMethod());
}
}
@Service
public class AnalyticsService {
@FlagGated(value = "real-time-analytics",
fallbackMethod = "getBatchAnalytics")
public AnalyticsReport getRealTimeAnalytics(String dashboardId) {
return realTimeEngine.query(dashboardId);
}
public AnalyticsReport getBatchAnalytics(String dashboardId) {
return batchEngine.query(dashboardId);
}
}
After (real-time analytics permanently enabled):
@Service
public class AnalyticsService {
public AnalyticsReport getAnalytics(String dashboardId) {
return realTimeEngine.query(dashboardId);
}
}
Remove the @FlagGated annotation, remove the fallback method, simplify the method name (it is no longer "real-time" versus "batch" -- it is just the analytics method), and check whether the FeatureFlagAspect itself still has any remaining flag annotations to intercept.
Managing flag lifecycle in enterprise teams
Enterprise teams need more than code patterns. They need process.
The flag lifecycle stages
| Stage | Duration | Owner Action | Tooling Check |
|---|---|---|---|
| Created | Day 0 | Register in constants class with JIRA ticket | CI verifies flag is registered |
| Rolling out | Days 1-30 | Monitor error rates per flag segment | Dashboard tracks rollout % |
| Fully enabled | Day 30+ | Schedule cleanup sprint task | Alert when flag is 100% for 14+ days |
| Cleanup | 1-4 hours | Remove code, update tests, remove config | PR review with flag cleanup label |
| Archived | Post-cleanup | Remove from flag service, close JIRA ticket | Verify no code references remain |
Flag hygiene metrics for enterprise teams
Track these metrics at the team and organization level:
| Metric | Healthy | Warning | Critical |
|---|---|---|---|
| Stale flags (100% enabled > 30 days) | < 10 | 10-30 | > 30 |
| Average flag age | < 45 days | 45-90 days | > 90 days |
| Flags without JIRA ticket | 0 | 1-5 | > 5 |
| Dead code behind stale flags | < 3% of codebase | 3-8% | > 8% |
| Flag references per 100k LOC | < 40 | 40-80 | > 80 |
The cost of Java flag debt
Java's verbosity amplifies flag debt. Every stale flag in a Spring Boot service typically generates more dead code than the same flag in a dynamically typed language, because Java requires explicit types, interface implementations, configuration classes, and test fixtures for each code path.
The cost scales with team size and codebase complexity. A small team might have a few dozen stale flags adding thousands of lines of dead code. A large enterprise team can easily accumulate hundreds of stale flags with tens of thousands of lines of dead code across configuration beans, service implementations, and test fixtures. The time engineers spend reading, testing, reviewing, and navigating this dead code adds up to real productivity loss -- time that could be spent building features instead of working around code that should not exist.
Automation and detection
Tree-sitter for Java flag detection
Tree-sitter's Java grammar can identify flag patterns across all three implementation approaches -- LaunchDarkly, Unleash, and custom properties -- without requiring the code to compile:
;; Tree-sitter query for Java flag detection (S-expression syntax)
;; LaunchDarkly: client.boolVariation("key", context, default)
(method_invocation
object: (identifier) @object
name: (identifier) @method
arguments: (argument_list
(string_literal) @flag_key
(_)
(_))
(#any-of? @method "boolVariation" "stringVariation"
"intVariation" "doubleVariation" "jsonValueVariation"))
;; Unleash: unleash.isEnabled("key", context)
(method_invocation
object: (identifier) @object
name: (identifier) @method
arguments: (argument_list
(string_literal) @flag_key)
(#eq? @method "isEnabled"))
;; Spring @ConditionalOnProperty
(annotation
name: (identifier) @annotation
arguments: (annotation_argument_list
(element_value_pair
key: (identifier) @key
value: (string_literal) @flag_key))
(#eq? @annotation "ConditionalOnProperty")
(#eq? @key "name"))
This is the detection approach that FlagShark uses for Java codebases. Tree-sitter parses the full syntax tree without needing a working build, which matters for large enterprise monorepos where a branch may not compile cleanly. It detects flags in service classes, configuration beans, annotations, and test files with the same grammar.
CI pipeline integration
# .github/workflows/flag-hygiene.yml
name: Flag Hygiene
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- name: Check for unregistered flag keys
run: |
# Find all flag key usages not going through FeatureFlags constants
grep -rn 'boolVariation\|isEnabled\|flag_is_active' \
--include="*.java" \
--exclude="FeatureFlags.java" \
src/ | grep -v 'FeatureFlags\.' && \
echo "::error::Found flag references not using FeatureFlags constants" && \
exit 1 || echo "All flag references use constants"
- name: Count total flag references
run: |
count=$(grep -rn 'FeatureFlags\.' --include="*.java" src/ | wc -l)
echo "Total flag references: $count"
if [ "$count" -gt 200 ]; then
echo "::warning::Flag reference count ($count) exceeds threshold"
fi
- name: Build and test
run: ./mvnw verify -q
For teams that want automated detection and lifecycle tracking across every pull request, FlagShark integrates with GitHub to monitor Java repositories for flag additions and removals. It tracks each flag from the PR that introduces it through to the cleanup PR that removes it, and can automatically generate cleanup PRs when flags have been fully enabled beyond a configurable threshold.
Java-specific pitfalls to avoid
1. Leaving flag-dependent bean definitions alive. When a @ConditionalOnProperty flag is always true, the conditional bean and its fallback bean definition both survive in the application context scan. The fallback is never instantiated but still contributes to startup time and classpath scanning.
2. Forgetting Spring profile-specific YAML files. A flag removed from application.yml may still exist in application-dev.yml, application-staging.yml, or application-prod.yml. Always search every profile.
3. Ignoring Lombok-generated code. If a flag property is part of a @Data or @Builder class, Lombok generates getters, setters, builders, and equals/hashCode methods for it. Removing the field removes all generated code automatically, but developers sometimes keep the field "just in case" because they do not realize how much generated code it produces.
4. Missing flag references in Kafka consumers and scheduled tasks. Flags in @KafkaListener methods and @Scheduled tasks are executed asynchronously and often overlooked during cleanup audits. These dead code paths are particularly dangerous because they may process messages or run jobs using a code path that no longer matches the rest of the system.
5. Not cleaning up integration test configuration. Spring Boot integration tests with @TestPropertySource often override flag values. After cleanup, these test properties become meaningless but continue to set configuration that no code reads.
Java's enterprise ecosystem makes feature flag debt both more pervasive and more costly than in most languages. The abstraction layers that make Spring Boot productive -- dependency injection, annotation processing, configuration hierarchies, AOP -- also make it possible for stale flags to hide in places that no developer thinks to look. The teams that manage this well treat flag hygiene as infrastructure: they build constants classes, enforce registration in CI, track lifecycle metrics, and schedule cleanup as regular sprint work rather than a quarterly heroic effort. In a language where a single stale flag can touch configuration YAML, bean definitions, service implementations, test fixtures, and AOP advice simultaneously, automation is not a luxury. It is the only way to keep the codebase honest.