Feature flags decouple deployment from release. Code ships to production disabled, then gradually turns on for 1%, 10%, 100% of users — or specific cohorts — without code changes or redeployments. Claude Code implements the OpenFeature standard so your flag evaluation code is provider-agnostic, integrates with LaunchDarkly or custom backends, and wraps database migrations and risky features in flags that can be disabled instantly.
CLAUDE.md for Feature Flag Projects
## Feature Flags
- Standard: OpenFeature SDK (@openfeature/server-sdk)
- Provider: LaunchDarkly (production), in-memory JSON file (local dev/tests)
- Flag naming: snake_case, descriptive — e.g., checkout_v2_enabled, new_search_algorithm
- Every flag must have: description, owner, created date, removal date
- Flag types: boolean (on/off), string (variant), number (percentage), object (config)
- Never hardcode flag keys — import from src/flags.ts constants file
- All flags start as false (off by default)
- Remove flags within 30 days of full rollout
OpenFeature Setup
// src/flags.ts — centralized flag key registry
export const FLAGS = {
CHECKOUT_V2: 'checkout_v2_enabled',
NEW_SEARCH: 'new_search_algorithm',
PRODUCT_RECOMMENDATIONS: 'product_recommendations_enabled',
FREE_SHIPPING_THRESHOLD: 'free_shipping_threshold_cents', // number flag
} as const;
// src/lib/featureFlags.ts — OpenFeature initialization
import { OpenFeature } from '@openfeature/server-sdk';
import { LaunchDarklyProvider } from '@launchdarkly/openfeature-node-server';
import { InMemoryProvider } from '@openfeature/in-memory-provider';
export async function initFeatureFlags() {
if (process.env.NODE_ENV === 'production') {
const provider = new LaunchDarklyProvider(process.env.LAUNCHDARKLY_SDK_KEY!);
await OpenFeature.setProviderAndWait(provider);
} else {
// Local dev: read from flags.json, no network required
const flagConfig = await import('./flags-local.json');
const provider = new InMemoryProvider(flagConfig.flags);
await OpenFeature.setProviderAndWait(provider);
}
}
// flags-local.json — local development defaults
// {
// "flags": {
// "checkout_v2_enabled": { "defaultVariant": "off", "variants": { "on": true, "off": false } },
// "free_shipping_threshold_cents": { "defaultVariant": "default", "variants": { "default": 5000 } }
// }
// }
Evaluating Flags
// src/lib/featureFlags.ts (continued)
import { OpenFeature, type EvaluationContext } from '@openfeature/server-sdk';
// Evaluation context: who is making the request?
// Pass this for targeting rules (user tier, region, beta access)
export function buildContext(user: User): EvaluationContext {
return {
targetingKey: user.id,
email: user.email,
plan: user.plan, // 'free' | 'pro' | 'enterprise'
country: user.country,
betaUser: user.betaAccess,
};
}
// Typed flag evaluation helpers
const client = OpenFeature.getClient();
export async function isEnabled(
flagKey: string,
context: EvaluationContext,
defaultValue = false,
): Promise<boolean> {
const result = await client.getBooleanDetails(flagKey, defaultValue, context);
// Log evaluation for debugging (not in hot paths)
if (process.env.FLAG_DEBUG === '1') {
console.log(`Flag [${flagKey}]: ${result.value} (reason: ${result.reason})`);
}
return result.value;
}
export async function getStringVariant(
flagKey: string,
context: EvaluationContext,
defaultValue: string,
): Promise<string> {
return client.getStringValue(flagKey, defaultValue, context);
}
export async function getNumberValue(
flagKey: string,
context: EvaluationContext,
defaultValue: number,
): Promise<number> {
return client.getNumberValue(flagKey, defaultValue, context);
}
Using Flags in Application Code
// Route handler — flag gates the checkout version
import { isEnabled, buildContext } from '../lib/featureFlags';
import { FLAGS } from '../flags';
app.post('/api/checkout', requireAuth, async (req, res) => {
const context = buildContext(req.user);
const useCheckoutV2 = await isEnabled(FLAGS.CHECKOUT_V2, context);
if (useCheckoutV2) {
return handleCheckoutV2(req, res);
}
return handleCheckoutV1(req, res);
});
// React: flag in component
// Client-side flags fetched from an endpoint (don't expose SDK key to browser)
export async function getFlags(userId: string) {
const context = buildContext(await getUser(userId));
return {
checkoutV2: await isEnabled(FLAGS.CHECKOUT_V2, context),
freeShippingThreshold: await getNumberValue(FLAGS.FREE_SHIPPING_THRESHOLD, context, 5000),
};
}
// Pass flags via server component props or inject at page load
// Never evaluate flags directly in browser — all evaluation happens server-side
LaunchDarkly Targeting Rules
Configure a flag to roll out to:
1. Internal users immediately
2. Beta users after 1 week
3. 10% → 50% → 100% of all users over 2 weeks
LaunchDarkly targeting (via SDK or dashboard):
// Programmatic flag configuration via LaunchDarkly REST API
// (Usually done in LaunchDarkly UI, but can be templated)
const targetingRules = {
flagKey: 'checkout_v2_enabled',
rules: [
// Rule 1: Internal team — always on
{
clauses: [{ attribute: 'email', op: 'endsWith', values: ['@mycompany.com'] }],
variation: 1, // true
},
// Rule 2: Beta users — always on
{
clauses: [{ attribute: 'betaUser', op: 'in', values: [true] }],
variation: 1,
},
],
// Percentage rollout for everyone else
fallthrough: {
rollout: {
bucketBy: 'key', // targetingKey — ensures consistent user assignment
variations: [
{ variation: 0, weight: 90000 }, // 90% → false
{ variation: 1, weight: 10000 }, // 10% → true
],
},
},
};
Flag-Gated Database Migration
We need to run a risky data migration (backfilling a new column).
Gate it with a flag so we can stop immediately if something breaks.
// src/workers/backfillMigration.ts
import { isEnabled } from '../lib/featureFlags';
import { FLAGS } from '../flags';
const SYSTEM_CONTEXT = {
targetingKey: 'system',
environment: process.env.NODE_ENV,
};
export async function runBackfill() {
// Check flag before each batch — allows mid-migration stop
const shouldContinue = await isEnabled(FLAGS.NEW_SEARCH, SYSTEM_CONTEXT);
if (!shouldContinue) {
console.log('Backfill paused by feature flag');
return { paused: true };
}
let lastId = 0;
let processedCount = 0;
while (true) {
// Re-check flag at each batch (can be toggled mid-run)
if (!await isEnabled(FLAGS.NEW_SEARCH, SYSTEM_CONTEXT)) {
console.log(`Backfill paused at ID ${lastId} after ${processedCount} rows`);
break;
}
const batch = await db('products')
.where('id', '>', lastId)
.whereNull('search_vector')
.orderBy('id')
.limit(500)
.select('id', 'name', 'description');
if (batch.length === 0) {
console.log(`Backfill complete: ${processedCount} rows processed`);
break;
}
await db('products')
.whereIn('id', batch.map(r => r.id))
.update({ search_vector: buildSearchVector }); // simplified
lastId = batch[batch.length - 1].id;
processedCount += batch.length;
await new Promise(r => setTimeout(r, 25)); // Throttle
}
return { processedCount, lastId };
}
Kill Switch Pattern
// Centralized kill switch check — wrap any expensive or risky operation
export async function withKillSwitch<T>(
flagKey: string,
context: EvaluationContext,
fn: () => Promise<T>,
fallback: () => Promise<T>,
): Promise<T> {
const isAlive = await isEnabled(flagKey, context, true); // Default true = enabled
if (!isAlive) {
console.warn(`Kill switch activated for: ${flagKey}`);
return fallback();
}
return fn();
}
// Usage: wrap the AI recommendation feature
app.get('/api/recommendations', requireAuth, async (req, res) => {
const result = await withKillSwitch(
FLAGS.PRODUCT_RECOMMENDATIONS,
buildContext(req.user),
() => getAIRecommendations(req.user.id), // Expensive ML call
() => getTopSellingProducts(), // Cheap fallback
);
res.json(result);
});
Testing with Feature Flags
// Test with specific flag states — don't rely on real LD connection
import { OpenFeature } from '@openfeature/server-sdk';
import { InMemoryProvider } from '@openfeature/in-memory-provider';
beforeEach(async () => {
// Override the provider for this test
await OpenFeature.setProviderAndWait(new InMemoryProvider({
flags: {
'checkout_v2_enabled': {
defaultVariant: 'on',
variants: { on: true, off: false },
},
},
}));
});
test('checkout v2 returns new response shape', async () => {
const res = await request(app)
.post('/api/checkout')
.set('Authorization', `Bearer ${testToken}`)
.send(checkoutPayload);
expect(res.body).toHaveProperty('paymentIntentClientSecret'); // V2 field
});
For the zero-downtime deployment patterns that feature flags enable (deploy code off, then enable), see the zero-downtime deployments guide. For the CQRS read model migrations that benefit from kill switch patterns, the CQRS guide covers projection backfill strategies. The Claude Skills 360 bundle includes feature flag skill sets covering OpenFeature setup, targeting rules, and migration gating patterns. Start with the free tier to try flag integration templates.