Was sind Feature Flags — und warum jedes Team sie braucht
Die klassische Deployment-Angst kennt jeder: Ein neues Feature ist fertig, aber der Release-Termin ist drei Wochen weg. Der Feature-Branch liegt rum, Merge-Konflikte stapeln sich, und wenn es dann live geht, ist der Code schon veraltet. Feature Flags lösen dieses Problem radikal.
Definition: Ein Feature Flag (auch Feature Toggle oder Feature Switch) ist eine Konfigurationsoption, die bestimmten Code-Pfade zur Laufzeit aktiviert oder deaktiviert — ohne Deployment. Du kannst Code deployen, der noch nicht live ist, und ihn dann per Konfigurationsänderung für Nutzer freischalten.
Trunk-Based Development & Release Decoupling
Feature Flags sind das Herzstück von Trunk-Based Development (TBD): Alle Entwickler commiten täglich auf main, kein langlaufender Feature-Branch. Wie ist das möglich? Weil unfertige Features hinter Flags versteckt sind. Der Code ist deployed — aber unsichtbar.
Das Prinzip heißt Release Decoupling: Deploy != Release. Du kannst jederzeit deployen (kontinuierlich), aber den Release-Zeitpunkt separat steuern. Das gibt Business und Engineering gemeinsam die Kontrolle.
Die vier Arten von Feature Flags
🚀
Release Flags
Temporär. Verstecken unfertige Features. Werden entfernt sobald das Feature stabil läuft. Kurzlebig: Tage bis Wochen.
🧪
Experiment Flags
A/B-Tests und Multivariate Tests. Bestimmte Nutzer-Kohorten sehen Variante A oder B. Metrics entscheiden.
🔐
Permission Flags
Langlebig. Schalten Features für bestimmte Nutzergruppen frei: Beta-User, Enterprise-Tier, interne Teams.
🔴
Kill Switch
Notfall-Deaktivierung eines problematischen Features in Sekunden — ohne Deployment, ohne Rollback.
Praxis-Insight
Claude Code und Feature Flag Strategie
Claude Code erkennt im Kontext, welche Art von Flag du brauchst. "Ich will ein Feature für Beta-User freischalten" → Permission Flag mit User-Targeting. "Wir testen zwei Varianten des Checkout-Flows" → Experiment Flag mit randomisierter Zuweisung. Claude Code schlägt die richtige Implementierungsstrategie vor, bevor es Code schreibt.
Selbst implementiert: Feature Flags mit Redis und PostgreSQL
Bevor du ein externes Tool einführst, lohnt sich zu verstehen was intern nötig ist. Claude Code implementiert eine solide Eigenlösung — in TypeScript, typsicher, mit Redis als Backend für schnelle Reads.
Datenbankschema (PostgreSQL)
-- Feature Flags Tabelle in PostgreSQL
CREATE TABLE feature_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
flag_key VARCHAR(100) UNIQUE NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT false,
rollout_pct SMALLINT DEFAULT 0, -- 0-100 Prozent Gradual Rollout
rules JSONB DEFAULT '[]', -- Targeting Rules als JSON
description TEXT,
expires_at TIMESTAMPTZ, -- Auto-Expiry für Release Flags
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Index für schnelle Lookups
CREATE INDEX idx_flags_key ON feature_flags(flag_key);
CREATE INDEX idx_flags_expires ON feature_flags(expires_at) WHERE expires_at IS NOT NULL;
TypeScript Service mit Redis-Caching
// feature-flags.service.ts — Type-safe Feature Flag Service
import { createClient } from 'redis';
import { Pool } from 'pg';
import { createHash } from 'crypto';
interface FlagContext {
userId?: string;
email?: string;
plan?: 'free' | 'pro' | 'enterprise';
country?: string;
betaUser?: boolean;
}
interface FeatureFlag {
flagKey: string;
enabled: boolean;
rolloutPct: number;
rules: TargetingRule[];
expiresAt?: Date;
}
interface TargetingRule {
attribute: keyof FlagContext;
operator: 'eq' | 'in' | 'contains';
value: string | string[] | boolean;
}
export class FeatureFlagService {
private redis = createClient({ url: process.env.REDIS_URL });
private pg = new Pool({ connectionString: process.env.DATABASE_URL });
private readonly TTL = 60; // 60s Redis Cache
async isEnabled(flagKey: string, ctx: FlagContext = {}): Promise<boolean> {
const flag = await this.getFlag(flagKey);
if (!flag || !flag.enabled) return false;
// Auto-Expiry prüfen
if (flag.expiresAt && flag.expiresAt < new Date()) return false;
// Targeting Rules auswerten (erste passende Rule gewinnt)
for (const rule of flag.rules) {
if (this.matchesRule(rule, ctx)) return true;
}
// Gradual Rollout: deterministisch per userId-Hash
if (flag.rolloutPct > 0 && ctx.userId) {
const bucket = this.getBucket(ctx.userId, flagKey);
return bucket < flag.rolloutPct;
}
// Kein Targeting: globaler On/Off Zustand
return flag.rolloutPct === 100;
}
private getBucket(userId: string, flagKey: string): number {
// Hash userId+flagKey → deterministischer Bucket 0-99
const hash = createHash('sha256')
.update(`${userId}-${flagKey}`)
.digest('hex');
return parseInt(hash.slice(0, 8), 16) % 100;
}
private matchesRule(rule: TargetingRule, ctx: FlagContext): boolean {
const value = ctx[rule.attribute];
if (value === undefined) return false;
switch (rule.operator) {
case 'eq': return value === rule.value;
case 'in': return (Array.isArray(rule.value) ? rule.value : [rule.value])
.includes(value as string);
case 'contains': return String(value).includes(String(rule.value));
default: return false;
}
}
private async getFlag(flagKey: string): Promise<FeatureFlag | null> {
const cacheKey = `ff:${flagKey}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const { rows } = await this.pg.query(
'SELECT * FROM feature_flags WHERE flag_key = $1', [flagKey]
);
if (!rows[0]) return null;
const flag: FeatureFlag = {
flagKey: rows[0].flag_key,
enabled: rows[0].enabled,
rolloutPct: rows[0].rollout_pct,
rules: rows[0].rules,
expiresAt: rows[0].expires_at ? new Date(rows[0].expires_at) : undefined,
};
await this.redis.setEx(cacheKey, this.TTL, JSON.stringify(flag));
return flag;
}
}
Selbst Implementiert
Wann lohnt sich die Eigenlösung?
Wenn du unter 10 aktive Flags hast, dein Team klein ist und du kein externes Billing-Risiko willst. Die Eigenlösung kostet nichts außer Redis + PostgreSQL (die du sowieso hast) und gibt dir volle Kontrolle über Daten-Residency und DSGVO-Compliance.
OpenFeature SDK: Der vendor-neutrale Standard
OpenFeature ist ein CNCF-Projekt (Cloud Native Computing Foundation) und standardisiert die Feature-Flag-API unabhängig vom Backend. Du schreibst deinen Code gegen das OpenFeature SDK — und kannst den Provider (LaunchDarkly, Flagsmith, eigene Impl.) jederzeit wechseln, ohne Applikationscode zu ändern.
Das Provider-Pattern
// openfeature-setup.ts — OpenFeature SDK konfigurieren
import { OpenFeature, Client } from '@openfeature/server-sdk';
import { LaunchDarklyProvider } from '@openfeature/launchdarkly-provider';
// Einmalig beim App-Start: Provider registrieren
await OpenFeature.setProviderAndWait(
new LaunchDarklyProvider(process.env.LD_SDK_KEY!)
);
// Client für dein Modul
const featureClient: Client = OpenFeature.getClient('checkout');
// Typsicheres Auswerten — Boolean
const newCheckout = await featureClient.getBooleanValue(
'new-checkout-flow', // Flag-Key
false, // Default-Wert (Fallback)
{ targetingKey: user.id, plan: user.plan } // Evaluation Context
);
// String-Variante für Experiments (A/B/C)
const variant = await featureClient.getStringValue(
'checkout-layout',
'control',
{ targetingKey: user.id }
);
// → 'control' | 'variant-a' | 'variant-b'
Context-basierte Auswertung
OpenFeature unterscheidet zwischen Evaluation Context (wer fragt?) und Flag-Konfiguration (für wen ist es an?). Du übergibst Nutzer-Attribute als Context — der Provider entscheidet anhand der Targeting-Konfiguration.
// Globaler Context für alle Requests setzen (z.B. in Middleware)
import { OpenFeature } from '@openfeature/server-sdk';
// Express Middleware: User-Context aus JWT einlesen
export const featureFlagMiddleware = (req, res, next) => {
const { userId, email, plan, betaAccess } = req.user ?? {};
OpenFeature.setTransactionContext(
{
targetingKey: userId ?? 'anonymous',
email,
plan,
betaUser: betaAccess === true,
country: req.headers['cf-ipcountry'] ?? 'unknown',
},
next // Async Context Propagation via AsyncLocalStorage
);
};
// In beliebigem Service-Layer — kein Context-Parameter nötig!
const isNewDashboard = await featureClient.getBooleanValue('new-dashboard', false);
OpenFeature Vorteil: Du kannst in der Entwicklung einen In-Memory-Provider nutzen (Flag-Werte hardcoded im Code), in Staging einen eigenen Redis-Provider, und in Produktion LaunchDarkly — ohne eine Zeile Applikationscode zu ändern. Das macht Lokaltests trivial einfach.
LaunchDarkly Integration: Enterprise-Grade Feature Management
LaunchDarkly ist der Marktführer für Feature Management. Das SDK cached Flags lokal, streamt Updates in Echtzeit via Server-Sent Events, und hat integrierten Fallback wenn der LD-Service nicht erreichbar ist. Claude Code integriert LaunchDarkly in wenigen Minuten.
LaunchDarkly
Was LaunchDarkly besser macht als Eigenlösungen
Echtzeit-Updates ohne Cache-Invalidierung, Flag-Targeting über UI ohne Deployment, integrierte Metrics und Feature-Adoption-Analytics, Audit-Log für Compliance, Flag-Governance mit Approvals. Das alles selbst zu bauen kostet Monate.
SDK Setup (Node.js)
// launchdarkly.client.ts
import * as ld from '@launchdarkly/node-server-sdk';
let ldClient: ld.LDClient;
export async function initLaunchDarkly(): Promise<void> {
ldClient = ld.init(process.env.LD_SDK_KEY!, {
logger: ld.basicLogger({ level: 'warn' }),
// Offline-Fallback: nutzt Last-Known-Good Flags
offline: process.env.NODE_ENV === 'test',
// Streaming für Echtzeit-Updates
stream: true,
});
await ldClient.waitForInitialization({ timeout: 5 });
console.log('✅ LaunchDarkly SDK initialisiert');
}
export function getLDClient(): ld.LDClient {
if (!ldClient) throw new Error('LaunchDarkly nicht initialisiert!');
return ldClient;
}
User Targeting und Gradual Rollout
// feature-service.ts — LaunchDarkly User Targeting
import { getLDClient } from './launchdarkly.client';
interface LDUser {
kind: 'user';
key: string; // userId — PFLICHT
email?: string;
name?: string;
custom?: Record<string, string | boolean | number>;
}
export async function checkFeature(
flagKey: string,
user: LDUser,
defaultValue = false
): Promise<boolean> {
const client = getLDClient();
return client.variation(flagKey, user, defaultValue);
}
// Gradual Rollout: LaunchDarkly UI steuert Prozentsatz
// Zuerst 1% → 5% → 25% → 50% → 100% — jederzeit pausierbar
const context: LDUser = {
kind: 'user',
key: user.id,
email: user.email,
custom: {
plan: user.plan, // 'free' | 'pro' | 'enterprise'
betaUser: user.isBeta, // Beta-Targeting
signupDate: user.createdAt.toISOString(),
country: user.country,
}
};
const showNewFeature = await checkFeature('new-ai-dashboard', context);
// Multivariate Flag: Rückgabewert ist String
const client = getLDClient();
const layout = await client.variation(
'dashboard-layout',
context,
'default' // Fallback
);
// → 'default' | 'sidebar' | 'topnav' | 'compact'
Echtzeit-Updates via Streaming
// Flag-Änderungen sofort ohne Server-Neustart reaktieren
const client = getLDClient();
client.on('update', (settings) => {
// Wird aufgerufen wenn sich ein Flag in der LD-UI ändert
console.log(`Flag geändert: ${settings.key}`);
// Cache invalidieren, Metrics loggen, etc.
flagUpdateMetrics.increment({ flag: settings.key });
});
// Graceful Shutdown
process.on('SIGTERM', async () => {
await client.close();
});
A/B Testing Pattern: Zufällige Zuweisung mit konsistenter User-Experience
A/B Testing über Feature Flags ist mächtig — aber nur wenn die Zuweisung deterministisch ist. Ein Nutzer muss bei jedem Request dieselbe Variante sehen, nicht zufällig wechseln. Das erreicht man durch Hash-basierte Zuweisung statt Math.random().
A/B Testing
Das Kernprinzip: Sticky Bucketing
Deterministischer Hash aus userId + flagKey → immer dieselbe Bucket-Nummer → immer dieselbe Variante. Auch nach Deployment, Server-Neustart oder Cache-Miss ist die Zuweisung identisch.
// ab-testing.service.ts — A/B Test mit Metrics-Erfassung
import { createHash } from 'crypto';
type ABVariant = 'control' | 'variant-a' | 'variant-b';
interface ABExperiment {
experimentKey: string;
variants: {
name: ABVariant;
weight: number; // Summe aller Weights = 100
}[];
}
export class ABTestingService {
constructor(
private metrics: MetricsService,
private featureFlags: FeatureFlagService
) {}
async getVariant(
experimentKey: string,
userId: string,
experiment: ABExperiment
): Promise<ABVariant> {
// Erst prüfen ob Experiment aktiv (Feature Flag)
const isActive = await this.featureFlags.isEnabled(
`experiment-${experimentKey}`,
{ userId }
);
if (!isActive) return 'control';
// Deterministischer Bucket 0-99
const hash = createHash('sha256')
.update(`${userId}:${experimentKey}`)
.digest('hex');
const bucket = parseInt(hash.slice(0, 8), 16) % 100;
// Variante anhand kumulativer Weights zuweisen
let cumulative = 0;
for (const variant of experiment.variants) {
cumulative += variant.weight;
if (bucket < cumulative) {
// Zuweisung in Metrics loggen (einmalig)
this.metrics.track('ab_assignment', {
experiment: experimentKey,
variant: variant.name,
userId,
});
return variant.name;
}
}
return 'control';
}
// Conversion Event tracken
trackConversion(experimentKey: string, variant: ABVariant, userId: string) {
this.metrics.track('ab_conversion', {
experiment: experimentKey,
variant,
userId,
timestamp: new Date().toISOString(),
});
}
}
// Verwendung im Controller
const variant = await abService.getVariant('checkout-cta', userId, {
experimentKey: 'checkout-cta',
variants: [
{ name: 'control', weight: 50 }, // 50% "Jetzt kaufen"
{ name: 'variant-a', weight: 30 }, // 30% "Kostenlos starten"
{ name: 'variant-b', weight: 20 }, // 20% "30 Tage testen"
]
});
// Auf Conversion tracken
if (checkoutCompleted) {
abService.trackConversion('checkout-cta', variant, userId);
}
Wichtig bei A/B Tests: Tracke Assignments nur einmal pro Nutzer pro Experiment (Deduplizierung!). Sonst verfälschst du deine Stichprobe. Idealerweise verwendest du eine separate Assignment-Tabelle in deiner DB, die sicherstellt dass ein Nutzer immer dieselbe Variante bekommt — auch wenn der Cache geleert wird.
Vergleich: Eigenlösung vs. OpenFeature vs. LaunchDarkly
| Feature |
Eigenlösung |
OpenFeature + Redis |
LaunchDarkly |
| Setup-Aufwand |
Mittel (2-3h) |
Gering (1h) |
Sehr gering (<30min) |
| Echtzeit-Updates |
Nein (Cache TTL) |
Manuell (Pub/Sub) |
Ja (SSE Streaming) |
| A/B Testing UI |
Nein |
Nein |
Ja (vollständig) |
| Audit-Log |
Selbst implementieren |
Teilweise |
Ja (DSGVO-konform) |
| Kosten |
$0 (nur Infra) |
$0 (nur Infra) |
Ab ~$10/Mo |
| Vendor Lock-in |
Keiner |
Keiner |
Hoch |
| Enterprise Features |
Nein |
Teilweise |
Ja |
Best Practices: Flag Lifecycle, Stale Flags und Testing
Feature Flags sind wie technische Schulden: Wenn du sie nicht aktiv verwaltest, werden sie zum Problem. Stale Flags (nicht mehr benötigte Flags) verlangsamen den Code, verwirren das Team und können zu Sicherheitsproblemen führen.
Flag Lifecycle Management
1
Erstellen mit Ablaufdatum
Jeder Release-Flag bekommt bei Erstellung ein expires_at. Trag es direkt im Ticket ein. Ohne Ablaufdatum kein Flag erstellen.
2
Monitoring in Produktion
Dashboard zeigt alle aktiven Flags mit Alter. Flags älter als 30 Tage werden orange markiert — das Team muss Stellung nehmen: entfernen oder verlängern.
3
Rollout abschließen
Feature ist auf 100% und stabil seit >1 Woche → Flag aus dem Code entfernen (nicht nur auf enabled: true setzen). Pull Request: "Remove feature flag new-checkout".
4
Cleanup automatisieren
Wöchentlicher Cron listet alle expired Flags und erstellt automatisch GitHub Issues. Kein manuelles Tracking nötig.
Stale Flags automatisch finden
-- Stale Flag Report: Flags ohne Änderung seit 30+ Tagen
SELECT
flag_key,
enabled,
rollout_pct,
AGE(updated_at) AS last_changed,
expires_at,
CASE
WHEN expires_at < NOW() THEN 'EXPIRED'
WHEN updated_at < NOW() - INTERVAL '30 days' THEN 'STALE'
ELSE 'ACTIVE'
END AS status
FROM feature_flags
WHERE updated_at < NOW() - INTERVAL '30 days'
OR expires_at < NOW()
ORDER BY last_changed DESC;
Testing mit Feature Flags: Alle Code-Pfade abdecken
Feature Flags verdoppeln deine Code-Pfade. Wenn du nicht explizit testest, hast du toten Code der sich unbemerkt bricht. Claude Code schreibt Tests für beide Zustände automatisch.
// feature-flags.test.ts — Beide Pfade testen
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FeatureFlagService } from './feature-flags.service';
import { CheckoutService } from './checkout.service';
describe('CheckoutService — Feature Flag Varianten', () => {
let flagService: FeatureFlagService;
let checkoutService: CheckoutService;
beforeEach(() => {
flagService = { isEnabled: vi.fn() } as any;
checkoutService = new CheckoutService(flagService);
});
it('zeigt alten Checkout wenn Flag disabled', async () => {
vi.mocked(flagService.isEnabled).mockResolvedValue(false);
const result = await checkoutService.getCheckoutFlow(mockUser);
expect(result.version).toBe('legacy');
expect(result.components).toContain('OldPaymentForm');
});
it('zeigt neuen Checkout wenn Flag enabled', async () => {
vi.mocked(flagService.isEnabled).mockResolvedValue(true);
const result = await checkoutService.getCheckoutFlow(mockUser);
expect(result.version).toBe('new-2026');
expect(result.components).toContain('StripePaymentElement');
});
it('fällt auf legacy zurück wenn FlagService wirft', async () => {
// Fehlerfall: Flag-Service nicht erreichbar
vi.mocked(flagService.isEnabled).mockRejectedValue(new Error('Redis down'));
const result = await checkoutService.getCheckoutFlow(mockUser);
// IMMER sicher fallback auf bekannten Zustand
expect(result.version).toBe('legacy');
});
it('Gradual Rollout: userId in 5% Bucket erhält neues Feature', async () => {
// userId der deterministisch in ersten 5% fällt
const userId = 'user-low-hash'; // In Tests: vorab berechnen
vi.mocked(flagService.isEnabled).mockImplementation(
async (key, ctx) => ctx?.userId === userId ? true : false
);
const result = await checkoutService.getCheckoutFlow({ id: userId });
expect(result.version).toBe('new-2026');
});
});
Claude Code Test-Strategie: Erkläre Claude Code deinen Feature Flag Namen und welche Code-Pfade dahinter stehen — es generiert automatisch Tests für enabled/disabled/error-fallback-Szenarien. Mit Vitest und vi.fn() ist das in Minuten erledigt.
Kill Switch: Emergency Stop in Sekunden
// kill-switch.ts — Emergency Feature Deaktivierung
import { FeatureFlagService } from './feature-flags.service';
export async function emergencyDisable(
flagKey: string,
reason: string
): Promise<void> {
const service = new FeatureFlagService();
// 1. Flag in DB sofort deaktivieren
await service.pg.query(
`UPDATE feature_flags
SET enabled = false, updated_at = NOW()
WHERE flag_key = $1`,
[flagKey]
);
// 2. Redis Cache sofort invalidieren (kein 60s Warten!)
await service.redis.del(`ff:${flagKey}`);
// 3. Pub/Sub Broadcast für alle Instanzen
await service.redis.publish('flag-updates', JSON.stringify({
action: 'DISABLE', flagKey, reason, timestamp: new Date()
}));
// 4. Alert senden
console.error(`🚨 KILL SWITCH aktiviert: ${flagKey} — ${reason}`);
}
// CLI-Usage bei Incident
// npx ts-node kill-switch.ts new-ai-dashboard "Performance Degradation P1"
Feature Flag Checkliste: Dos and Don'ts
- ✓Jeder Release-Flag hat ein
expires_at Datum beim Erstellen
- ✓Default-Wert im Code ist immer
false (nicht true) — Safety-First
- ✓Feature-Flag-Name enthält den Ticket/Issue-Key:
PROJ-123-new-checkout
- ✓Beide Code-Pfade (flag=true und flag=false) haben eigene Tests
- ✓Fehler im Flag-Service → immer Fallback auf Default, niemals Exception nach oben
- ✓Gradual Rollout: Hash-basiert deterministisch, nie
Math.random()
- ✓Flag nach vollständigem Rollout aus Codebase entfernen (nicht nur enableden)
- ✓Kill Switch Pattern: Redis-Cache sofort invalidieren, nicht auf TTL warten
- ✓Wöchentlicher Stale-Flag-Report als automatischer Cron
- ✓Flag-Änderungen in Audit-Log mit Zeitstempel, User und Reason
Flag-Explosion vermeiden: Mehr als 50 aktive Flags gleichzeitig ist ein Warnsignal. Jeder neue Flag muss den "Brauchen wir das wirklich?"-Check bestehen. Permissions-Flags für dauerhafte Tier-Unterschiede sind ok — aber Release-Flags müssen konsequent geklint werden.
Feature Flags in deinem Projekt implementieren?
Claude Code analysiert deine bestehende Architektur und schlägt die passende Feature-Flag-Strategie vor — von der einfachen Eigenlösung bis zur LaunchDarkly-Integration. Teste 14 Tage kostenlos.
14 Tage kostenlos testen →
Fazit: Feature Flags sind kein Nice-to-Have
Teams die Trunk-Based Development mit Feature Flags praktizieren, deployen 5-10× häufiger als Teams mit Feature-Branches — und haben weniger Incidents, weil Rollouts graduell und reversibel sind. Der Kill Switch allein ist den Aufwand wert.
Der Einstieg ist einfach: Fang mit einem einfachen Redis-backed Service an, nutze OpenFeature für die Abstraktion, und füge LaunchDarkly hinzu wenn die Komplexität es rechtfertigt. Claude Code hilft dir beim Setup — in der Zeit die du sonst für Merge-Konflikte verbrätst.