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.