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:
- Define and store flag names in code.
- Architect flag evaluations with an abstraction layer to keep your code clean.
- Structure conditional logic to simplify flag cleanup.
- Manage flags in microservices.
- Minimize tech debt and manage the flag lifecycle to prevent technical debt.
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 genericisEnabled("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.
- Backend
- Frontend
// 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
}
}
// This top-level component checks the flag and decides what to render.
export function Dashboard() {
const user = useUser();
const userContext = { userId: user.id };
// Evaluate the flag once in the parent component
const showNewAnalytics = featureService.isEnabled(
FeatureFlags.DASHBOARD_ANALYTICS,
userContext
);
return (
<div>
<h1>Your Dashboard</h1>
{/* Other dashboard components */}
{/* Render a child component based on the result */}
{showNewAnalytics ? (
<NewAnalyticsSection data={analyticsData} />
) : (
<OldAnalyticsSection data={analyticsData} />
)}
</div>
);
}
// These child components don't know about feature flags—they just render props
// The new component just focuses on rendering its UI
export function NewAnalyticsSection({ data }) {
return (
<div className="new-analytics">
<h2>✨ New & Improved Analytics</h2>
{/* Renders charts and stats using the new design */}
</div>
);
}
// The old component is similarly unaware of the flag
export function OldAnalyticsSection({ data }) {
return (
<div className="old-analytics">
<h2>Analytics</h2>
{/* Renders charts and stats using the old design */}
</div>
);
}
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.
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.
- TypeScript
- Java
// 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);
}
// Define a contract that all payment strategies must follow
public interface PaymentStrategy {
void charge(PaymentDetails details);
}
// The implementation for the legacy payment system
@Service("legacyPayment")
public class LegacyPaymentService implements PaymentStrategy {
@Override
public void charge(PaymentDetails details) {
System.out.println("Processing payment with legacy system");
// Legacy logic
}
}
// The implementation for the new payment system
@Service("newPayment")
public class NewPaymentService implements PaymentStrategy {
@Override
public void charge(PaymentDetails details) {
System.out.println("Processing payment with shiny new system!");
// New logic
}
}
// This factory isolates the flag check, returning the correct service bean
@Service
public class PaymentStrategyFactory {
private final FeatureService featureService;
private final PaymentStrategy legacyPaymentService;
private final PaymentStrategy newPaymentService;
@Autowired
public PaymentStrategyFactory(
FeatureService featureService,
@Qualifier("legacyPayment") PaymentStrategy legacyPaymentService,
@Qualifier("newPayment") PaymentStrategy newPaymentService
) {
this.featureService = featureService;
this.legacyPaymentService = legacyPaymentService;
this.newPaymentService = newPaymentService;
}
public PaymentStrategy getPaymentStrategy(UserContext user) {
// The flag check is isolated here
if (featureService.isEnabled(FeatureFlags.NEW_PAYMENT_GATEWAY, user)) {
return newPaymentService;
} else {
return legacyPaymentService;
}
}
}
// The controller uses the factory to get a strategy and execute it
@RestController
public class PaymentController {
private final PaymentStrategyFactory paymentStrategyFactory;
@Autowired
public PaymentController(PaymentStrategyFactory factory) {
this.paymentStrategyFactory = factory;
}
@PostMapping("/pay")
public void processPayment(@RequestBody PaymentDetails details, @CurrentUser UserContext user) {
// Get the appropriate strategy based on the flag
PaymentStrategy paymentService = paymentStrategyFactory.getPaymentStrategy(user);
// Execute the payment
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:
- The
gateway-service
receives the request, sees the flag is on, and calls theproduct-service
. - The
product-service
also sees the flag is on and prepares to show a promotional banner. It calls thepricing-service
. - In the milliseconds between these calls, an engineer turns off the flag in the Unleash UI due to an issue.
- 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.
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.
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:
- The
gateway-service
receives a request, authenticates the user, and evaluates all necessary flags. - It uses the OpenTelemetry SDK to add the user context and the flag evaluation result to the current baggage.
- When the
gateway-service
makes an HTTP call to a downstream service, the OpenTelemetry instrumentation automatically serializes the baggage into thebaggage
HTTP header and sends it. - The downstream service's instrumentation automatically receives this header, deserializes it, and makes the baggage available to your application code.
- Java
- Python
// 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.
# Example in Python (Downstream Service)
from opentelemetry import baggage
def handle_request():
# Retrieve the propagated context
all_baggage = baggage.get_all()
user_id = all_baggage.get('user.id')
new_checkout_is_enabled = all_baggage.get('flag.new-checkout.enabled') == 'true'
# Use the consistent, propagated result
if new_checkout_is_enabled:
# ...
else:
# ...
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.