Loading learning content...
There's a telling difference between codebases that use feature flags well and those that don't. In well-designed systems, flags are nearly invisible—a clean abstraction that developers rarely think about. In poorly designed systems, flags are everywhere: nested conditionals, duplicated checks, tangled dependencies that nobody dares to touch.
The difference isn't in whether you use flags—it's in how you design their integration. This page covers the architectural patterns that separate elegant flag implementations from unmaintainable messes. These patterns determine whether your flags will be a productivity multiplier or a technical debt time bomb.
By the end of this page, you will understand the core design patterns for feature flags: where to place flag checks, how to abstract flag logic, how to design targeting rules, and how to maintain testability. You'll learn patterns used by elite engineering teams to keep their flag implementations clean and sustainable.
The most fundamental design decision is where to place your flag checks. Get this wrong, and you'll end up with the same flag checked dozens of times throughout your codebase, each instance slightly different, each a potential bug.
Consider this common mistake:
123456789101112131415161718192021222324252627282930313233343536
// ❌ ANTI-PATTERN: Flag checks scattered throughout the codebase // In the controllerasync function getCheckoutPage(req, res) { if (featureFlags.isEnabled("new-checkout-v2", req.user)) { return renderNewCheckout(req, res); } return renderLegacyCheckout(req, res);} // In the service layer (same flag, different check)class CheckoutService { async processPayment(order: Order) { if (featureFlags.isEnabled("new-checkout-v2", order.user)) { return this.processWithNewPaymentFlow(order); } return this.processWithLegacyPaymentFlow(order); }} // In the frontend (yet another check)function CheckoutButton({ user }) { const isNewCheckout = useFeatureFlag("new-checkout-v2", user); return isNewCheckout ? <NewCheckoutButton /> : <LegacyCheckoutButton />;} // In the analytics (and another...)function trackCheckoutEvent(event, user) { if (featureFlags.isEnabled("new-checkout-v2", user)) { trackNewCheckoutMetrics(event); } else { trackLegacyCheckoutMetrics(event); }}This code has four separate evaluations of the same flag. If someone removes the flag in one place but not others, behavior becomes inconsistent. If the flag name changes, each location must be updated. If a targeting rule needs modification, there's no single place to change it. The flag has metastasized throughout the codebase.
A flag should be evaluated in exactly one place, as high in the call stack as possible. The decision is made once, and the result flows down through the system.
Rule of thumb: If you find yourself checking the same flag in multiple layers (controller, service, repository, frontend), you've placed the flag too deep. Lift it higher.
The Entry Point Flag pattern places the flag check at the system boundary—the point where a request enters your system. This is the most common and often most appropriate pattern.
The flag is evaluated once at the entry point (controller, route handler, API gateway), and the decision determines which code path executes for the entire request.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// ✅ PATTERN: Entry Point Flag - decision made once at the top // The flag is checked once at the controller level@Controller('/checkout')class CheckoutController { constructor( private readonly newCheckoutService: NewCheckoutService, private readonly legacyCheckoutService: LegacyCheckoutService, private readonly featureFlags: FeatureFlagService, ) {} @Get('/') async getCheckoutPage(@User() user: User): Promise<Response> { // Single flag check for the entire checkout flow const checkoutService = this.featureFlags.isEnabled("new-checkout-v2", user) ? this.newCheckoutService : this.legacyCheckoutService; // The service doesn't know about the flag—it just does its job return await checkoutService.renderCheckout(user); } @Post('/process') async processCheckout( @User() user: User, @Body() order: OrderDto ): Promise<Response> { const checkoutService = this.featureFlags.isEnabled("new-checkout-v2", user) ? this.newCheckoutService : this.legacyCheckoutService; return await checkoutService.processPayment(order); }} // The services are flag-agnostic—they implement a common interfaceinterface CheckoutService { renderCheckout(user: User): Promise<Response>; processPayment(order: OrderDto): Promise<Response>;} class NewCheckoutService implements CheckoutService { async renderCheckout(user: User): Promise<Response> { // New checkout implementation—no flag checks here return this.buildNewCheckoutPage(user); } async processPayment(order: OrderDto): Promise<Response> { // New payment flow—no flag checks here return this.processWithStripeV2(order); }}Use this pattern when the flag affects the entire transaction or request flow. If the new and legacy paths are fundamentally different implementations, entry point flags are ideal. They're especially clean when combined with dependency injection for selecting between implementations.
The Strategy Pattern with Flags encapsulates the flag-based selection logic in a factory or resolver that returns the appropriate strategy implementation. This is a formalization of the entry point pattern that adds explicit abstraction.
A factory or resolver evaluates the flag and returns the appropriate implementation. Consumer code doesn't know or care about flags—it just uses the strategy it's given.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
// ✅ PATTERN: Strategy Pattern with Flags // 1. Define the strategy interfaceinterface PricingStrategy { calculatePrice(order: Order): Money; applyDiscounts(order: Order, discounts: Discount[]): Money; formatPriceDisplay(amount: Money): string;} // 2. Implement concrete strategiesclass LegacyPricingStrategy implements PricingStrategy { calculatePrice(order: Order): Money { // Legacy pricing logic return order.items.reduce( (sum, item) => sum.add(item.price.multiply(item.quantity)), Money.zero(order.currency) ); } // ... other methods} class DynamicPricingStrategy implements PricingStrategy { constructor(private readonly pricingEngine: PricingEngine) {} calculatePrice(order: Order): Money { // New dynamic pricing with demand-based adjustments return this.pricingEngine.calculateDynamicPrice(order); } // ... other methods} // 3. Create a flag-aware factory (the ONLY place flags appear)@Injectable()class PricingStrategyFactory { constructor( private readonly featureFlags: FeatureFlagService, private readonly legacyPricing: LegacyPricingStrategy, private readonly dynamicPricing: DynamicPricingStrategy, ) {} getStrategy(context: PricingContext): PricingStrategy { if (this.featureFlags.isEnabled("dynamic-pricing-v2", context)) { return this.dynamicPricing; } return this.legacyPricing; }} // 4. Consumer code is completely flag-agnostic@Injectable()class OrderService { constructor(private readonly pricingFactory: PricingStrategyFactory) {} async calculateOrderTotal(order: Order): Promise<OrderTotal> { // Get the appropriate strategy—no flag knowledge here const pricingStrategy = this.pricingFactory.getStrategy({ userId: order.userId, region: order.shippingAddress.region, }); const subtotal = pricingStrategy.calculatePrice(order); const discounted = pricingStrategy.applyDiscounts(order, order.discounts); return { subtotal, total: discounted, display: pricingStrategy.formatPriceDisplay(discounted), }; }}This pattern shines when:
The factory becomes the single source of truth for flag evaluation. When the flag is retired, you modify one class: the factory. All consumers automatically get the new (now only) behavior.
In DI-heavy frameworks (NestJS, Spring, ASP.NET Core), the factory pattern integrates naturally. The factory is injected into consumers, and the factory itself has the flag service injected. This keeps flag evaluation cleanly separated from business logic.
The Feature Component Pattern encapsulates feature-flagged behavior into self-contained components that handle both the flag check and the conditional rendering/execution. This is especially powerful in frontend frameworks but applies to backends too.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// ✅ PATTERN: Feature Component Pattern (React) /** * Generic feature gate component * Encapsulates flag checking and conditional rendering */interface FeatureGateProps { flagKey: string; children: React.ReactNode; fallback?: React.ReactNode;} function FeatureGate({ flagKey, children, fallback = null }: FeatureGateProps) { const { isEnabled, isLoading } = useFeatureFlag(flagKey); if (isLoading) return <FeatureGateSkeleton />; return isEnabled ? <>{children}</> : <>{fallback}</>;} // Usage: Clean, declarative, self-documentingfunction Dashboard() { return ( <div className="dashboard"> <Header /> <MainContent /> {/* New analytics widget—behind flag */} <FeatureGate flagKey="advanced-analytics-widget" fallback={<BasicAnalyticsWidget />} > <AdvancedAnalyticsWidget /> </FeatureGate> {/* Premium feature—no fallback needed */} <FeatureGate flagKey="ai-insights-beta"> <AIInsightsPanel /> </FeatureGate> <Footer /> </div> );} // Alternative: Typed feature component for better DXinterface AdvancedAnalyticsProps { userId: string; dateRange: DateRange;} const AdvancedAnalytics = withFeatureFlag<AdvancedAnalyticsProps>( "advanced-analytics-widget", ({ userId, dateRange }) => ( <AdvancedAnalyticsWidget userId={userId} dateRange={dateRange} /> ), // Fallback component ({ userId, dateRange }) => ( <BasicAnalyticsWidget userId={userId} dateRange={dateRange} /> ));1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// ✅ PATTERN: Feature Component Pattern (Backend) /** * Feature-gated capability that encapsulates both the flag check * and the capability execution */abstract class FeatureCapability<TInput, TOutput> { constructor(protected readonly featureFlags: FeatureFlagService) {} abstract readonly flagKey: string; abstract readonly fallbackBehavior: (input: TInput) => Promise<TOutput>; abstract readonly featureBehavior: (input: TInput) => Promise<TOutput>; async execute(input: TInput, context: EvaluationContext): Promise<TOutput> { if (this.featureFlags.isEnabled(this.flagKey, context)) { return this.featureBehavior(input); } return this.fallbackBehavior(input); }} // Concrete implementationclass EnhancedSearchCapability extends FeatureCapability<SearchQuery, SearchResults> { readonly flagKey = "enhanced-search-v2"; constructor( featureFlags: FeatureFlagService, private readonly enhancedSearchEngine: EnhancedSearchEngine, private readonly legacySearchEngine: LegacySearchEngine, ) { super(featureFlags); } fallbackBehavior = async (query: SearchQuery): Promise<SearchResults> => { return this.legacySearchEngine.search(query); }; featureBehavior = async (query: SearchQuery): Promise<SearchResults> => { return this.enhancedSearchEngine.search(query); };} // Usage: Clean, encapsulatedclass SearchController { constructor(private readonly searchCapability: EnhancedSearchCapability) {} async search(query: SearchQuery, user: User): Promise<SearchResults> { return this.searchCapability.execute(query, { userId: user.id }); }}The Decorator Pattern wraps existing behavior with flag-conditional logic. This is particularly useful when you want to add feature-flagged behavior to existing code without modifying it—following the Open/Closed Principle.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
// ✅ PATTERN: Decorator Pattern for Feature Flags // Original service (remains unchanged)interface NotificationService { send(notification: Notification): Promise<void>;} class EmailNotificationService implements NotificationService { async send(notification: Notification): Promise<void> { // Existing email implementation await this.emailClient.send({ to: notification.recipient, subject: notification.title, body: notification.message, }); }} // Decorator that adds feature-flagged behaviorclass MultiChannelNotificationDecorator implements NotificationService { constructor( private readonly wrapped: NotificationService, private readonly featureFlags: FeatureFlagService, private readonly pushService: PushNotificationService, private readonly smsService: SMSNotificationService, ) {} async send(notification: Notification): Promise<void> { // Always execute wrapped behavior (original) await this.wrapped.send(notification); // Conditionally add new behavior const context = { userId: notification.recipientId }; if (this.featureFlags.isEnabled("push-notifications", context)) { await this.pushService.send(notification); } if (this.featureFlags.isEnabled("sms-notifications", context) && notification.priority === "urgent") { await this.smsService.send(notification); } }} // Composition: Wire up the decorator chainconst emailService = new EmailNotificationService(emailClient);const multiChannelService = new MultiChannelNotificationDecorator( emailService, featureFlags, pushService, smsService,); // Consumers get enhanced behavior transparentlyclass OrderService { constructor(private readonly notifications: NotificationService) {} async confirmOrder(order: Order): Promise<void> { // ... order processing await this.notifications.send({ recipientId: order.userId, recipient: order.userEmail, title: "Order Confirmed", message: `Your order ${order.id} has been confirmed.`, priority: "normal", }); }}The decorator pattern is ideal when: (1) you're adding behavior to existing code without modifying it, (2) the feature-flagged behavior is additive rather than replacement, or (3) you need to compose multiple flag-controlled behaviors. It's less suitable when the new behavior completely replaces the old—use Strategy pattern instead.
Beyond where to place flags, how you design targeting rules determines the power and maintainability of your flag system. Targeting rules define who sees what variant of a flagged feature.
1. Percentage Rollout
The most common targeting: randomly assign users to cohorts based on percentage.
123456789101112131415161718192021222324252627
// Percentage-based targetinginterface PercentageRule { type: "percentage"; percentage: number; // 0-100 // Sticky assignment: same user always gets same result // Uses consistent hashing on userId + flagKey} // Implementation conceptfunction evaluatePercentageRule( rule: PercentageRule, context: { userId: string }, flagKey: string): boolean { // Consistent hash ensures same user gets same result const hash = consistentHash(context.userId + flagKey); const bucket = hash % 100; return bucket < rule.percentage;} // Usage in flag configurationconst newCheckoutFlag = { key: "new-checkout-v2", rules: [ { type: "percentage", percentage: 25 }, // 25% of users ],};2. Attribute-Based Targeting
Target users based on their attributes: subscription tier, region, company, etc.
12345678910111213141516171819202122232425262728293031323334353637
// Attribute-based targetinginterface AttributeRule { type: "attribute"; attribute: string; operator: "equals" | "contains" | "in" | "matches" | "gt" | "lt"; value: any;} const enterpriseFeatureFlag = { key: "enterprise-sso", rules: [ { type: "attribute", attribute: "subscriptionTier", operator: "in", value: ["enterprise", "enterprise-plus"], }, ],}; const betaAccessFlag = { key: "beta-features", rules: [ { type: "attribute", attribute: "email", operator: "matches", value: ".*@company\.com$", // Internal users }, { type: "attribute", attribute: "betaProgram", operator: "equals", value: true, // Beta program members }, ],};3. Segment-Based Targeting
Target predefined user segments for reusability across flags.
123456789101112131415161718192021222324252627282930313233343536373839404142
// Segment-based targeting// Segments are reusable groups defined once, used by many flags interface Segment { key: string; name: string; rules: AttributeRule[];} const segments: Segment[] = [ { key: "internal-users", name: "Internal Company Users", rules: [ { type: "attribute", attribute: "email", operator: "matches", value: ".*@company\.com$" }, ], }, { key: "premium-users", name: "Premium Subscription Users", rules: [ { type: "attribute", attribute: "subscriptionTier", operator: "in", value: ["pro", "enterprise"] }, ], }, { key: "mobile-users", name: "Mobile App Users", rules: [ { type: "attribute", attribute: "platform", operator: "in", value: ["ios", "android"] }, ], },]; // Flags reference segments by keyconst newFeatureFlag = { key: "new-dashboard", rules: [ { type: "segment", segment: "internal-users" }, // All internal users { type: "segment", segment: "premium-users" }, // All premium users { type: "percentage", percentage: 10 }, // 10% of everyone else ],};Most flag systems evaluate rules in order, returning on the first match. Design rules from most specific to least specific: (1) specific user IDs, (2) segments, (3) attributes, (4) percentage rollout. If no rule matches, the default value is returned.
A critical architectural decision is how to abstract your flag system. Never couple your application code directly to a third-party flag SDK. Always introduce an abstraction layer.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
// ✅ PATTERN: Abstraction Layer for Feature Flags // 1. Define your own interface (Port)interface FeatureFlagService { isEnabled(flagKey: string, context?: EvaluationContext): boolean; getString(flagKey: string, context?: EvaluationContext, defaultValue?: string): string; getNumber(flagKey: string, context?: EvaluationContext, defaultValue?: number): number; getJSON<T>(flagKey: string, context?: EvaluationContext, defaultValue?: T): T;} interface EvaluationContext { userId?: string; email?: string; attributes?: Record<string, any>;} // 2. Implement adapters for each provider (Adapters) // LaunchDarkly adapterclass LaunchDarklyFeatureFlagService implements FeatureFlagService { private client: LDClient; constructor(sdkKey: string) { this.client = LDClient.init(sdkKey); } isEnabled(flagKey: string, context?: EvaluationContext): boolean { const ldContext = this.mapToLDContext(context); return this.client.variation(flagKey, ldContext, false); } private mapToLDContext(context?: EvaluationContext): LDContext { if (!context) return { kind: "user", anonymous: true }; return { kind: "user", key: context.userId || "anonymous", email: context.email, custom: context.attributes, }; } // ... other methods} // In-memory adapter for testingclass InMemoryFeatureFlagService implements FeatureFlagService { private flags = new Map<string, any>(); setFlag(flagKey: string, value: any): void { this.flags.set(flagKey, value); } isEnabled(flagKey: string, context?: EvaluationContext): boolean { return this.flags.get(flagKey) === true; } // ... other methods} // Environment-variable adapter for simple casesclass EnvVarFeatureFlagService implements FeatureFlagService { isEnabled(flagKey: string, context?: EvaluationContext): boolean { const envKey = `FEATURE_FLAG_${flagKey.toUpperCase().replace(/-/g, "_")}`; return process.env[envKey] === "true"; } // ... other methods} // 3. Wire up in dependency injectionconst featureFlagService: FeatureFlagService = process.env.NODE_ENV === "production" ? new LaunchDarklyFeatureFlagService(process.env.LD_SDK_KEY!) : process.env.NODE_ENV === "test" ? new InMemoryFeatureFlagService() : new EnvVarFeatureFlagService();We've covered the essential design patterns for feature flags. Let's consolidate:
What's next:
With design patterns established, the next page addresses the critical challenge of flag lifecycle management—how to create, maintain, and most importantly, remove feature flags before they become permanent technical debt.
You now understand the core design patterns for implementing feature flags cleanly. These patterns ensure your flags remain maintainable, testable, and removable. Next, we'll tackle the governance and lifecycle management that prevents flags from becoming permanent fixtures in your codebase.