Skip to main content

Managing feature flags in your codebase

How you manage feature flags in code directly impacts the performance, testability, and long-term maintainability of your applications. Without the right processes and structure, flags quickly become tech debt, making your code harder to understand and risky to change.

In this guide, we explore hands-on strategies for managing feature flags in your code effectively. We'll give you practical recommendations and code examples to help you build a system that's reliable, scalable, and easy to maintain.

We'll cover how to:

Building on a foundation of clean code

Before we dive into specifics, remember that good software design practices make everything easier. Principles like modularity and a clear separation of concerns are your best friends when integrating feature flags.

Here are the goals we're aiming for:

  • Clarity: Your feature flag logic should be easy to find and understand. Any developer on your team should be able to quickly grasp what a flag does and how it affects the system.
  • Maintainability: Adding, changing, and removing flags should be a simple and low-risk process.
  • Testability: Your code under a flag should be easily and reliably testable.
  • Scalability: Your approach needs to handle a growing number of flags and developers without increasing code complexity.

Defining and storing flag names

Your first step is deciding how to represent and store flag names in code. These identifiers are the critical link between your application and your feature flag configurations in the Unleash Admin UI. A disorganized approach here can quickly lead to typos, inconsistencies, and difficulty in tracking down where a flag is used.

We recommend centralizing your flag name definitions using constants or enums. This approach establishes a single source of truth for all flag names in your application.

Why centralize definitions?

  • Avoids inconsistencies or errors: Using constants or enums prevents typos and inconsistencies that arise from scattering string literals ("my-new-feature") throughout the application. Your compiler or linter can catch errors for you.
  • Improves discoverability: A central file acts as a manifest of all flags used in the application, making it easy for developers to see what's available and how flags are named.
  • Simplifies refactoring and cleanup: If you need to change a flag's name in your code (for example, to fix a typo), you only need to update it in one place.

Here is a simple and highly effective pattern using TypeScript's as const feature. It's robust, type-safe, and easy to understand.

// src/feature-flags.ts

// A simple, effective way to centralize flags
export const FeatureFlags = {
NEW_USER_PROFILE_PAGE: 'newUserProfilePage',
DARK_MODE_THEME: 'darkModeTheme',
ADVANCED_REPORTING: 'advancedReportingEngine',
} as const; // 'as const' makes values read-only and types specific

// This automatically creates a type for all possible flag keys.
export type AppFlag = typeof FeatureFlags[keyof typeof FeatureFlags];

For applications that need even stricter type safety or rely heavily on flag variants, you can use a more advanced pattern. This approach, used within the Unleash codebase itself, combines union and mapped types for maximum compile-time checking.

// An alternative approach in: src/feature-flags.ts
import { type Variant, PayloadType } from 'unleash-client';

// 1. Define all possible flag names as a type-safe union
export type AppFlagKey =
| 'newUserProfilePage'
| 'darkModeTheme'
| 'advancedReportingEngine';

// 2. Define a type for the flags object using the official `Variant` type
export type AppFlags = Partial<{
[key in AppFlagKey]: boolean | Variant;
}>;

// 3. Provide explicit default values for each flag
export const defaultFlags: AppFlags = {
// Simple boolean defaults
newUserProfilePage: false,
darkModeTheme: true,

// A complex variant with a payload, defaulted to off
advancedReportingEngine: {
name: 'disabled',
enabled: false,
payload: {
type: PayloadType.JSON,
value: '{}',
},
},
};

Finally, no matter which pattern you choose, you should avoid dynamic flag names. Constructing flag names at runtime (such as, {domain} + "_feature") prevents static analysis, making it nearly impossible to find all references to a flag automatically. It makes clean-up with automated tools more difficult.

Architecting flag evaluation

How and where you check a flag's state is one of the most important architectural decisions you'll make. A well-designed evaluation strategy keeps your code clean and your system's behavior predictable.

Use an abstraction layer

Directly calling the Unleash SDK's unleash.isEnabled() throughout your codebase tightly couples your application to the specific SDK implementation.

Instead, we recommend implementing an abstraction layer, often called a "wrapper", to encapsulate all interactions with the Unleash SDK. This service becomes the single entry point for all feature flag checks in your application.

// src/services/feature-service.ts
import { Unleash, Context as UnleashContext } from 'unleash-client';
import { AppFlag, FeatureFlags } from '../feature-flags'; // Import both the type and the constants

// Define your application's context structure
export interface AppUserContext {
userId?: string;
sessionId?: string;
properties?: {
[key: string]: string;
};
}

class FeatureService {
private unleash: Unleash;

constructor(unleashInstance: Unleash) {
this.unleash = unleashInstance;
}

private buildUnleashContext(appContext?: AppUserContext): UnleashContext {
if (!appContext) return {};
return { ...appContext };
}

public isEnabled(flagName: AppFlag, appContext?: AppUserContext): boolean {
// Always provide a safe, default value (usually `false`)
const defaultValue = false;
try {
const unleashContext = this.buildUnleashContext(appContext);
return this.unleash.isEnabled(flagName, unleashContext, defaultValue);
} catch (error) {
// Log the error for observability
console.error(`Error evaluating flag "${flagName}":`, error);
// Fallback to the safe default
return defaultValue;
}
}

// You can also create more semantic, business-language methods
public canUserSeeNewProfilePage(userContext?: AppUserContext): boolean {
return this.isEnabled(FeatureFlags.NEW_USER_PROFILE_PAGE, userContext);
}
}

// Initialize and export a singleton instance for your app to use
const unleash = initializeUnleashClient(); // ← replace with your real init
export const featureService = new FeatureService(unleash);

Why build an abstraction layer?

  • Vendor abstraction: If you ever switch feature flagging providers, you only need to update your wrapper instead of hunting for SDK calls across the entire codebase.
  • Centralized control: It gives you a single place to manage logging, performance monitoring, and robust error handling for all flag checks.
  • Improved readability: Methods with business-friendly names (canUserSeeNewProfilePage()) make the code's intent clearer than a generic isEnabled("newUserProfilePage").

Handling variant payloads inside your wrapper

This wrapper is also a good place to validate any feature flag payload you receive from Unleash.

While using variant payloads for dynamic configuration enables flexibility and rapid iteration, it also introduces risk. Since the variant payload is managed in a UI, a change can have unintended consequences on the application's behavior or appearance, even if the JSON itself is syntactically valid.

If you decide to use variant payloads, we recommend enforcing a four-eyes approval process, so any change must be reviewed and approved by a second team member before it can be saved. In addition, you should test payloads with internal users first before exposing them to real users.

Then, implement additional guardrails in your wrapper to validate the payload structure and return a safe default value if the data is invalid.

Evaluate flags at the right level and time

For a given user request, evaluate a feature flag once at the highest practical level of your application stack. Propagate the result of that evaluation (the true/false value or the feature flag variant) downstream to other components or functions.

This prevents "flag-aware" logic from spreading deep into your application's components, making them simpler and easier to test.

In a backend application, the highest level is often the controller or the entry point of a service request. The controller evaluates the flag and then directs the application to use either the new or old logic path.

In a frontend framework like React, evaluate the flag in a top-level container component. This component then renders different child components based on the flag's state, passing down data as props. The child components themselves remain unaware of the feature flag.

// src/controllers/checkoutController.js
import { featureService } from '../services/featureService';
import { FeatureFlags } from '../feature-flags'; // Import the centralized flag names

export function handleCheckoutRequest(req, res) {
const userContext = { userId: req.user.id };

// Evaluate once at the highest level
const useNewCheckout = featureService.isEnabled(FeatureFlags.NEW_CHECKOUT_PROCESS, userContext);

// Propagate the result, not the flag check
if (useNewCheckout) {
renderNewCheckoutPage(req, res); // This component tree uses the new logic
} else {
renderOldCheckoutPage(req, res); // This component tree uses the old logic
}
}

Why evaluate once?

  • Consistency: It ensures a user sees the same feature state throughout their interaction. Evaluating the same flag multiple times during a single request could yield different results if the flag's configuration is changed mid-request, leading to a broken or confusing user experience.
  • Simplicity: It prevents "flag-aware" logic from spreading deep into your application's components, making them simpler and easier to test.

Structuring conditional logic

The way you structure your conditional logic for your flags has a major impact on readability and, most importantly, on how easy it is to clean up later.

For the vast majority of cases, a simple if/else statement is the best approach. It's direct, easy to understand, and straightforward to remove.

// A simple, clean conditional statement
public void processPayment(PaymentDetails details, UserContext user) {
if (featureService.isNewPaymentGatewayEnabled(user)) {
newPaymentService.charge(details);
} else {
legacyPaymentService.charge(details);
}
}

The primary goal is to keep the conditional logic localized and simple. When it's time for cleanup, the task is trivial: delete the if and the else block, and the new code path remains.

Using design patterns

Design patterns like the Strategy pattern or the Factory pattern are sometimes used in place of direct conditional logic. For example, the strategy pattern uses a flag to select a concrete implementation of a shared interface at runtime, encapsulating different behaviors into distinct classes.

Strategy-design-pattern

The strategy pattern is well-suited for certain Permission flags that grant premium users access to an advanced feature, or for long-term Kill switches that toggle a core system component. For these complex, multi-faceted features with distinct and interchangeable behaviors, the pattern can be a powerful tool for maintaining a clean, scalable, and testable codebase.

// Define a contract that all payment strategies must follow
export interface PaymentStrategy {
charge(details: PaymentDetails): Promise<void>;
}

// The implementation for the legacy payment system
export class LegacyPaymentService implements PaymentStrategy {
async charge(details: PaymentDetails): Promise<void> {
console.log('Processing payment with legacy system...');
// Legacy logic...
}
}

// The implementation for the new payment system
export class NewPaymentService implements PaymentStrategy {
async charge(details: PaymentDetails): Promise<void> {
console.log('Processing payment with shiny new system!');
// New logic...
}
}

// This factory isolates the flag check, returning the correct strategy
export function getPaymentStrategy(user: UserContext): PaymentStrategy {
// The flag check is isolated here
if (featureService.isEnabled(FeatureFlags.NEW_PAYMENT_GATEWAY, user)) {
return new NewPaymentService();
} else {
return new LegacyPaymentService();
}
}

// The application code uses the factory to get a strategy and execute it
export async function processPayment(details: PaymentDetails, user: UserContext) {
// Get the appropriate strategy based on the flag
const paymentService = getPaymentStrategy(user);

// Execute the payment
await paymentService.charge(details);
}

However, the majority of feature flags control small, temporary changes. For most Release, Experiment, and Operational flags, the strategy pattern introduces unnecessary overhead. It makes the eventual cleanup process far more complex than removing a simple if/else block. Furthermore, because the pattern scales poorly when multiple flags interact, a direct conditional statement is almost always the cleaner and more maintainable choice for these temporary flags.

Managing flags in microservices

Managing feature flags in a microservices architecture requires guaranteeing consistency. When a single user request triggers a chain of calls across multiple services, each service needs to operate on the same feature state.

You might assume that if each service evaluates a flag with the same user context, the result will be consistent. In a perfectly static system, this is true. However, in a live production environment, flag configurations can change. This introduces a critical race condition: a flag can be toggled mid-request, causing different services in the same call chain to get different results.

Imagine a NEW_PRICING_MODEL flag is active:

  1. The gateway-service receives the request, sees the flag is on, and calls the product-service.
  2. The product-service also sees the flag is on and prepares to show a promotional banner. It calls the pricing-service.
  3. In the milliseconds between these calls, an engineer turns off the flag in the Unleash UI due to an issue.
  4. The pricing-service now evaluates the flag, sees it as off, and returns the standard price.

The result? A confused user who sees a promotional banner but gets charged the old price.

Evaluate flags multiple times

The solution is to evaluate a feature flag's state exactly one time at the "edge" of your system—typically in an API Gateway or the first service that receives the external request. Then, you must propagate the result of that evaluation—the true/false or a specific variant—downstream to all other services.

Evaluate flags once

To make this work, downstream services need the initial flag evaluation result and the user context (ID, location, etc.) used to make them. The standard, most robust way to achieve this is with OpenTelemetry Baggage.

While OpenTelemetry is known for distributed tracing, its Baggage specification is purpose-built to carry application-defined key-value pairs across process boundaries. It's the ideal mechanism for this use case.

Here's how it works:

  1. The gateway-service receives a request, authenticates the user, and evaluates all necessary flags.
  2. It uses the OpenTelemetry SDK to add the user context and the flag evaluation result to the current baggage.
  3. When the gateway-service makes an HTTP call to a downstream service, the OpenTelemetry instrumentation automatically serializes the baggage into the baggage HTTP header and sends it.
  4. The downstream service's instrumentation automatically receives this header, deserializes it, and makes the baggage available to your application code.
// Example in Java (`gateway-service`) using OpenTelemetry SDK
import io.opentelemetry.api.baggage.Baggage;

Baggage.current()
.toBuilder()
.put("user.id", "user-123")
.put("user.tier", "premium")
// Propagate the evaluation result, not the flag name
.put("flag.new-checkout.enabled", "true")
.build()
.makeCurrent(); // This context is now active and will be propagated.

Minimizing tech debt and managing the flag lifecycle

Let's face it: stale flags are tech debt. Without a plan, your codebase will fill up with forgotten and risky flags. The only way to win is with a clear process for managing their entire lifecycle.

Naming conventions and flag metadata

The first line of defense against flag debt is clarity. A flag named temp_fix_v2 is a mystery waiting to happen. A good name provides immediate context about the flag's purpose and owner, both in the Unleash UI and in your code. A highly effective pattern is: [team]_[feature-name]. For example: checkout_multistep-payment-flow.

The [team] prefix is invaluable when multiple teams share a single Unleash project, as it clarifies ownership. However, if you organize your Unleash instance with per-team projects, this prefix may be unnecessary, though it can still be helpful for searching through code. [feature-name] should be a short, descriptive slug for the feature.

While you can add more information directly to the name, such as an issue number or a full issue name, this is a trade-off. It can make the flag name long and complicated to work with in code.

A better practice is to keep the name clean and use Unleash's built-in metadata features for richer context:

  • Use external links: Instead of putting an issue number like JIRA-376 in the name, use the external links feature in Unleash to connect the flag directly to the corresponding Jira ticket, GitHub issue, or design document.
  • Write a clear description: Use the description field to explain what the flag does, what the rollout plan is, and any additional context that may be relevant.

This approach keeps flag names readable in your code while ensuring all the necessary context for lifecycle management is available in the Unleash UI. Once you've identified a naming convention that works, you can enforce them at the project level.

Flag cleanup best practices

You can use a flag's lifecycle data and automated reminders to ensure flags are removed from your code once they have served their purpose.

Here's our recommended workflow for flag cleanup:

  • Update the flag's lifecycle status: Once a feature is stable and fully rolled out, mark it as Completed in Unleash. If you forget, Unleash will prompt you to update the status for stale flags that are no longer sending metrics. This moves the flag to the Cleanup lifecycle stage, creating a clear backlog for removal.

  • Clean up the code: Remove the conditional logic, any old code paths, the flag's definition from your central file, and any helper methods from your wrapper. This ensures you remove dead code and reduce complexity.

  • Test and deploy: Run your tests to ensure everything still works as expected, then deploy your changes.

  • Archive the flag in Unleash: Finally, archive the flag in the Unleash UI. Don't delete it—archiving preserves its history for auditing and analysis.

Just hoping that people remember to clean up is not a sustainable strategy. You need to automate your governance process.

Here are some practical tips:

  • Automated ticketing: Use webhooks or integrations to automatically create "Remove Flag" tickets.
  • Scheduled reviews: Make flag reviews a part of your process, for example your planning. Teams should justify a flag's existence or schedule them for removal.
  • Update "Definition of Done": A feature isn't "done" until its associated feature flag has been removed from the code and archived in Unleash.
  • Use AI to speed up the cleanup: Rely on AI coding assistants to automate and fix flag removal issues.

Testing with feature flags

There is no single "right" way to test with feature flags, as every organization has a different testing strategy. The key is to build confidence that your application remains stable regardless of which flags are active. An effective approach typically involves a combination of strategies across different levels of the testing pyramid.

A common fear is that flags will cause a "combinatorial explosion" of test cases. You don't need to test every possible combination. Instead, focus on a few high-value scenarios.

Unit tests

At the unit level, the focus is narrow: does this specific piece of code work correctly when its controlling flag is on and when it's off?

For any unit of code affected by a flag, you should have tests that cover both states:

  • Flag on: Verifies the new code path works as expected.
  • Flag off: Verifies the old code path still works and the new code is not executed.

Component and E2E tests

For broader tests that cover a single service, component, or the entire application, you need a strategy to handle the growing number of flags. Here are some common states to cover:

  • Baseline state = all flags off: These tests run with all feature flags turned off, simulating your stable production environment. It verifies that adding new, dormant flag-protected code hasn't caused regressions in existing functionality.
  • New features state = all flags on: This suite runs with all (or most) feature flags enabled. Its purpose is to catch unexpected, negative interactions between multiple new features that might be developed in parallel. It helps ensure that the application is functioning as expected in its most feature-rich state.
  • Common combinations: For mature applications, it may be useful to test the most common combination of flags that your users experience. This pragmatic approach focuses testing effort on the configurations that have the biggest real-world impact.
  • SDK fallback state: What happens if Unleash is unavailable? Does your wrapper handle it gracefully and fall back to safe defaults?

Testing in production

The real superpower that flags give you is testing in production—safely.

This doesn't mean showing bugs to your customers. It means using targeting rules to enable a feature only for your internal teams in the live production environment. For example, you can set a flag to be "on" only for users with an @your-company.com email address.

This allows your team to interact with the new feature on real production infrastructure, with real data—a context that is impossible to perfectly replicate in a staging environment. If you find a bug, it has zero impact on real users. You can fix it and then release it with confidence.

Key takeaways

To wrap things up, managing feature flags effectively boils down to a few core, hands-on practices.

First, centralize flag definitions in a single file to prevent errors and make them easy to find and remove. Second, always build an abstraction layer or wrapper around the SDK; this gives you a single point for error handling and simplifies future migrations.

For structuring your conditional logic, a simple if/else is usually the best choice for temporary flags, as it's the easiest to clean up.

Finally, evaluate flags once at the highest reasonable level in your application. This is especially crucial in a microservices architecture, where propagating the result of the evaluation downstream ensures a consistent user experience.


Frequently asked questions (FAQs)

This FAQ section addresses common questions about using feature flags in code, focusing on flag evaluation in different architectures, conditional logic implementation, and testing.

Where should I define my feature flag names in the code? Centralize them in a dedicated file using constants or enums. This creates a single source of truth, prevents typos, and makes cleanup easier.

Should I call the Unleash SDK directly everywhere or build a helper service? Build a wrapper (an abstraction layer). It decouples your app from the SDK, gives you a central place for error handling and logging, and makes future migrations painless.

How do I handle code for complex features controlled by flags? Start with a simple if/else statement. This is the cleanest and easiest-to-maintain solution for most cases. The Strategy pattern should be reserved for complex, long-lived flags like kill switches or permissions, as it can introduce unnecessary complexity for short-lived release flags.

How do we avoid flag debt? Have a process! Use strict naming conventions, link flags to tickets in Unleash, make flag removal part of your "Definition of Done," and automate cleanup reminders.

When and how should I remove a feature flag from the code? Once the flag is stable at 100% rollout (or permanently off). The process is: remove the conditional logic and old code, delete the flag definition, and then archive the flag in the Unleash UI.

Can you use feature flags in microservices? Absolutely! Evaluate the flag once in the first service that gets the request (for example, your API gateway). Then, propagate the result of the evaluation (the true/false result or assigned variant) to all downstream services using OpenTelemetry Baggage or custom HTTP headers. This guarantees consistency.

What's the best way to evaluate a feature flag in code? Evaluate it once per request at the highest logical point in your application. Then, pass the boolean result down to the components that need it. This ensures a consistent user experience for that entire interaction.