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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Monitoring Is Not Optional. It Is How You Know Your App Is Alive.

A service without meaningful monitoring is a service you're flying blind on. You don't know if it's working, degrading, or failing — until a user tells you. That is not an acceptable operational posture.

Read more

Why Your Docker Image Works Locally But Breaks in Production

Local Docker and production environments differ in architecture, user permissions, resource limits, networking, and secret injection. Most "it works on my machine" container failures trace back to a small set of fixable mismatches.

Read more

Surviving Your First Year as a Contractor Without Crying

Your first year as a contractor is equal parts excitement and existential dread. Here’s how to make it through without losing your sanity—or your sleep.

Read more

The Evolving Role of a Tech Lead With Modern Tools

Modern development tools are transforming how tech leads do their work. From code review automation to team collaboration, the role is shifting—but not disappearing.

Read more