Feature Flags: Ship Code Without Releasing Features
by Eric Hanson, Backend Developer at Clean Systems Consulting
The Problem With "Wait Until It's Ready"
Your team is building a new payment flow. It takes three weeks to complete. For those three weeks, the half-built payment code either lives on a long-lived feature branch (accumulating merge debt daily) or gets deployed behind a condition check that nobody outside the team can trigger. The branch strategy is expensive. The "condition nobody can trigger" is a feature flag — implemented informally, inconsistently, and without the infrastructure to manage it properly.
Feature flags done right solve two distinct problems: they enable trunk-based development by allowing incomplete code to be merged and deployed without being visible, and they enable controlled feature rollout by decoupling the deployment decision from the release decision. Most teams understand the second use case and underestimate the first.
The Two Types of Flags
Release flags are temporary. They wrap code that's under development or being rolled out gradually. Once the feature is fully released and stable, the flag is removed along with the old code path. These should have a defined expiry — a date by which the flag is either removed or the old path is deleted.
Operational flags (also called kill switches) are permanent. They control system behavior that you might need to toggle in production — disable a non-critical feature under load, switch between payment providers, enable debug logging for specific users. These are infrastructure, not scaffolding.
The distinction matters for hygiene. Release flags that are never removed accumulate into a codebase full of dead code paths that nobody knows are safe to delete. A rule: every release flag has an owner and a removal date. When the date passes without removal, the flag becomes a bug.
Implementation Without a Full Platform
For teams not ready for a dedicated feature flag platform (LaunchDarkly, Unleash, Flagsmith), a config-file-based approach is serviceable:
// Flag evaluation: reads from config, which is environment-specific
@Component
public class FeatureFlags {
@Value("${features.new-payment-flow.enabled:false}")
private boolean newPaymentFlowEnabled;
@Value("${features.new-payment-flow.rollout-percentage:0}")
private int rolloutPercentage;
public boolean isNewPaymentFlowEnabled(String userId) {
if (!newPaymentFlowEnabled) return false;
if (rolloutPercentage >= 100) return true;
// Deterministic per-user assignment: same user always gets same bucket
int userBucket = Math.abs(userId.hashCode()) % 100;
return userBucket < rolloutPercentage;
}
}
# application-production.yml
features:
new-payment-flow:
enabled: true
rollout-percentage: 10 # Start with 10% of users
// Usage: flag wraps the new code path, old path is fallback
public PaymentResult processPayment(PaymentRequest request) {
if (featureFlags.isNewPaymentFlowEnabled(request.getUserId())) {
return newPaymentService.process(request);
}
return legacyPaymentService.process(request);
}
The deterministic hash ensures a given user always sees the same code path — avoiding the confusion of a user experiencing different behavior on consecutive requests.
When to Use a Flag Platform
Config-file flags have a critical limitation: changing the flag requires a config change and redeployment (or at minimum a config reload). For kill switches that need to fire in response to a production incident, that's too slow.
A flag platform — Unleash (self-hosted, open source), LaunchDarkly (managed), or Flagsmith (open source with managed option) — evaluates flags at runtime via an SDK that polls for changes. Flag changes take effect in seconds without deployment.
For release flags, config-file-based is usually fine — the flag changes happen at deployment time anyway. For operational kill switches, a platform is worth the operational cost.
The Hygiene Problem
Feature flags are the technical debt that feels like an asset. A flag makes it easy to ship. Removing the flag requires understanding which code path is now canonical, deleting the other, and being confident the old path will never be needed again. That's more work than shipping, so flags accumulate.
The discipline:
// Every flag has a ticket number and a removal date in the comment
// @FeatureFlag(ticket="PROJ-1234", removeBy="2026-06-01")
if (featureFlags.isNewPaymentFlowEnabled(userId)) {
// New path
} else {
// Old path — remove this block after 2026-06-01
}
Add a CI check that fails if a flag's removal date has passed:
# Check for expired flags in CI
grep -r "@FeatureFlag" src/ | while read line; do
removeBy=$(echo "$line" | grep -oP 'removeBy="\K[^"]+')
if [[ "$removeBy" < "$(date +%Y-%m-%d)" ]]; then
echo "Expired feature flag found: $line"
exit 1
fi
done
This sounds draconian but it works: expired flags get removed promptly because they block CI. Without enforcement, removal dates are aspirational.
The Bigger Win
The teams that get the most value from feature flags aren't using them primarily for gradual rollout — they're using them to enable continuous deployment. Every new feature lives behind a flag from day one, which means it can be merged to main and deployed at any time without affecting users. Feature branches shrink from weeks to days. Integration debt evaporates. The pipeline runs against real, integrated code continuously.
The flag is not just a release mechanism. It's the thing that makes trunk-based development viable for teams building features that take more than a day.