Zum Inhalt springen

Feature Flags im großen Maßstab implementieren

Baue ein Feature-Flag-System mit Rollouts, Nutzer-Targeting, A/B-Tests und Kill Switches — plus Lifecycle-Muster gegen Flag-Schulden.

5 Min. Lesezeit
Dashboard, das Feature-Flag-Zustände mit prozentualen Rollouts, Nutzer-Targeting-Regeln und Kill-Switch-Steuerungen zeigt

Feature Flags entkoppeln das Deployment von der Freigabe. Du bringst Code jederzeit in die Produktion, aber die Funktion wird für Nutzer erst aktiv, wenn du den Flag umlegst. Das verwandelt das Deployment von einem risikoreichen Vorgang in eine Routineaufgabe – und es verwandelt Releases von Alles-oder-Nichts-Starts in schrittweise, messbare Rollouts, die sich in Sekunden rückgängig machen lassen.

Aber unverwaltete Feature Flags werden zu technischen Schulden. Jeder Flag ist eine Verzweigung in deinem Code, die die Testpfade verdoppelt. Teams, die Flags ohne Lifecycle-Management einführen, landen am Ende bei Tausenden veralteter Flags, bei Code, der sich nicht mehr nachvollziehen lässt, und bei „temporären" Flags, die seit drei Jahren in Produktion laufen. Das System muss von Anfang an so gestaltet sein, dass Aufräumen ein zentrales Anliegen ist.

Architektur der Flag-Auswertung

Der Kern eines Feature-Flag-Systems ist die Auswertungs-Engine: Soll der Flag bei einem gegebenen Flag-Key, einem Nutzerkontext und einer Reihe von Regeln aktiviert oder deaktiviert sein?

tstypescript
// Core flag evaluation types
interface FlagContext {
  userId: string;
  email?: string;
  country?: string;
  plan?: string;
  attributes: Record<string, string | number | boolean>;
}
 
interface FlagRule {
  conditions: FlagCondition[];
  percentage?: number;
  variant?: string;
}
 
interface FlagCondition {
  attribute: string;
  operator: 'eq' | 'neq' | 'contains' | 'gt' | 'lt' | 'in';
  value: string | number | string[];
}
 
interface FeatureFlag {
  key: string;
  enabled: boolean;
  rules: FlagRule[];
  defaultVariant: string;
  killSwitch: boolean;
  owner: string;
  expiresAt?: Date;
}
 
// ❌ Naive flag check: just a boolean
function isFeatureEnabled(flagKey: string): boolean {
  return flags[flagKey] === true; // No targeting, no gradual rollout
}
 
// ✅ Full evaluation with targeting and percentage rollout
class FlagEvaluator {
  evaluate(flag: FeatureFlag, context: FlagContext): FlagResult {
    // Kill switch overrides everything
    if (flag.killSwitch) {
      return { enabled: false, variant: 'off', reason: 'kill-switch' };
    }
 
    // Global toggle
    if (!flag.enabled) {
      return { enabled: false, variant: flag.defaultVariant, reason: 'disabled' };
    }
 
    // Evaluate rules in order — first match wins
    for (const rule of flag.rules) {
      if (this.matchesConditions(rule.conditions, context)) {
        if (rule.percentage !== undefined) {
          const hash = this.consistentHash(flag.key, context.userId);
          if (hash <= rule.percentage) {
            return { enabled: true, variant: rule.variant ?? 'on', reason: 'rule-match' };
          }
        } else {
          return { enabled: true, variant: rule.variant ?? 'on', reason: 'rule-match' };
        }
      }
    }
 
    return { enabled: false, variant: flag.defaultVariant, reason: 'no-match' };
  }
 
  // Consistent hashing: same user always gets same result
  private consistentHash(flagKey: string, userId: string): number {
    const input = `${flagKey}:${userId}`;
    let hash = 0;
    for (let i = 0; i < input.length; i++) {
      hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
    }
    return (Math.abs(hash) % 100) + 1;
  }
 
  private matchesConditions(
    conditions: FlagCondition[],
    context: FlagContext
  ): boolean {
    return conditions.every((condition) => {
      const value = context.attributes[condition.attribute]
        ?? (context as Record<string, unknown>)[condition.attribute];
 
      switch (condition.operator) {
        case 'eq': return value === condition.value;
        case 'neq': return value !== condition.value;
        case 'contains': return String(value).includes(String(condition.value));
        case 'in': return Array.isArray(condition.value) && condition.value.includes(String(value));
        case 'gt': return Number(value) > Number(condition.value);
        case 'lt': return Number(value) < Number(condition.value);
        default: return false;
      }
    });
  }
}

Muster für schrittweise Rollouts

Die eigentliche Stärke von Feature Flags liegt im schrittweisen Rollout: Du beginnst mit 1 % der Nutzer, beobachtest die Fehlerraten, erhöhst auf 10 %, beobachtest erneut und so weiter, bis du 100 % erreichst.

tstypescript
// Progressive rollout configuration
interface RolloutPlan {
  flagKey: string;
  stages: RolloutStage[];
  currentStage: number;
  metrics: RolloutMetrics;
}
 
interface RolloutStage {
  percentage: number;
  durationHours: number;
  autoAdvance: boolean;
  rollbackConditions: {
    errorRateThreshold: number;
    latencyP99Threshold: number;
  };
}
 
// Example rollout plan for a new checkout flow
const checkoutRollout: RolloutPlan = {
  flagKey: 'new-checkout-flow',
  currentStage: 0,
  stages: [
    {
      percentage: 1,
      durationHours: 24,
      autoAdvance: false,  // Manual review first
      rollbackConditions: {
        errorRateThreshold: 0.05,
        latencyP99Threshold: 3000,
      },
    },
    {
      percentage: 10,
      durationHours: 48,
      autoAdvance: true,
      rollbackConditions: {
        errorRateThreshold: 0.02,
        latencyP99Threshold: 2000,
      },
    },
    {
      percentage: 50,
      durationHours: 72,
      autoAdvance: true,
      rollbackConditions: {
        errorRateThreshold: 0.01,
        latencyP99Threshold: 1500,
      },
    },
    {
      percentage: 100,
      durationHours: 0,
      autoAdvance: false,
      rollbackConditions: {
        errorRateThreshold: 0.01,
        latencyP99Threshold: 1500,
      },
    },
  ],
  metrics: {} as RolloutMetrics,
};

Flag-Lifecycle-Management

Der Unterschied zwischen einem nützlichen Flag-System und einem Albtraum-Codebase: Jeder Flag muss einen Verantwortlichen und ein Ablaufdatum haben.

tstypescript
// Flag with lifecycle metadata
interface ManagedFlag extends FeatureFlag {
  createdAt: Date;
  createdBy: string;
  expiresAt: Date;       // When should this flag be removed?
  lastEvaluated?: Date;  // Is anyone still checking this flag?
  category: FlagCategory;
  jiraTicket: string;    // Link to cleanup ticket
}
 
type FlagCategory =
  | 'release'       // Temporary: remove after full rollout
  | 'experiment'    // Temporary: remove after A/B test concludes
  | 'ops'           // Semi-permanent: kill switches, circuit breakers
  | 'permission';   // Permanent: gating features by plan/role
 
// Automated stale flag detection
class FlagHealthChecker {
  async findStaleFlags(flags: ManagedFlag[]): Promise<StaleReport[]> {
    const now = new Date();
    const reports: StaleReport[] = [];
 
    for (const flag of flags) {
      const issues: string[] = [];
 
      // Expired flags that haven't been cleaned up
      if (flag.expiresAt < now && flag.category !== 'ops') {
        issues.push(
          `Expired ${this.daysSince(flag.expiresAt)} days ago`
        );
      }
 
      // Flags at 100% rollout that should be removed
      if (
        flag.category === 'release' &&
        flag.enabled &&
        flag.rules.length === 0
      ) {
        const age = this.daysSince(flag.createdAt);
        if (age > 30) {
          issues.push(
            `At 100% for ${age} days — remove flag and dead code`
          );
        }
      }
 
      // Flags that haven't been evaluated recently
      if (
        flag.lastEvaluated &&
        this.daysSince(flag.lastEvaluated) > 14
      ) {
        issues.push('Not evaluated in 14 days — possibly dead code');
      }
 
      if (issues.length > 0) {
        reports.push({
          flag: flag.key,
          owner: flag.owner,
          issues,
        });
      }
    }
 
    return reports;
  }
 
  private daysSince(date: Date): number {
    return Math.floor(
      (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)
    );
  }
}

Testen mit Feature Flags

Feature Flags vervielfachen deine Testmatrix. Ohne Disziplin testest du am Ende weder den aktivierten noch den deaktivierten Zustand gründlich.

tstypescript
// ❌ Testing without considering flag states
describe('checkout', () => {
  it('should process payment', async () => {
    // Which checkout flow does this test?
    // If the flag is on in the test environment, it tests new flow
    // If off, it tests old flow
    // Nobody knows which one CI is testing
    const result = await processCheckout(order);
    expect(result.status).toBe('success');
  });
});
 
// ✅ Explicit flag state testing
describe('checkout', () => {
  describe('with new-checkout-flow enabled', () => {
    beforeEach(() => {
      flagService.override('new-checkout-flow', true);
    });
 
    afterEach(() => {
      flagService.clearOverrides();
    });
 
    it('should use stripe payment intent API', async () => {
      const result = await processCheckout(order);
      expect(stripeClient.createPaymentIntent).toHaveBeenCalled();
    });
  });
 
  describe('with new-checkout-flow disabled', () => {
    beforeEach(() => {
      flagService.override('new-checkout-flow', false);
    });
 
    afterEach(() => {
      flagService.clearOverrides();
    });
 
    it('should use legacy charge API', async () => {
      const result = await processCheckout(order);
      expect(stripeClient.createCharge).toHaveBeenCalled();
    });
  });
});

Flag-Auswertung auf der Client-Seite

Bei Frontend-Anwendungen sollte die Flag-Auswertung schnell sein (keine Netzwerkaufrufe bei jedem Rendering) und konsistent (kein Flackern zwischen Zuständen).

tstypescript
// Client-side flag SDK pattern
class FeatureFlagClient {
  private flags: Map<string, FlagResult> = new Map();
  private listeners: Map<string, Set<() => void>> = new Map();
 
  async initialize(context: FlagContext): Promise<void> {
    // Fetch all flags once on initialization
    const response = await fetch('/api/flags', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(context),
    });
    const flags = await response.json();
 
    for (const [key, value] of Object.entries(flags)) {
      this.flags.set(key, value as FlagResult);
    }
 
    // Stream updates for real-time flag changes
    this.connectStream(context);
  }
 
  isEnabled(flagKey: string): boolean {
    return this.flags.get(flagKey)?.enabled ?? false;
  }
 
  // React hook integration
  onFlagChange(flagKey: string, callback: () => void): () => void {
    if (!this.listeners.has(flagKey)) {
      this.listeners.set(flagKey, new Set());
    }
    this.listeners.get(flagKey)!.add(callback);
    return () => this.listeners.get(flagKey)?.delete(callback);
  }
 
  private connectStream(context: FlagContext): void {
    const source = new EventSource(
      `/api/flags/stream?userId=${context.userId}`
    );
    source.onmessage = (event) => {
      const update = JSON.parse(event.data);
      this.flags.set(update.key, update.value);
      this.listeners.get(update.key)?.forEach((cb) => cb());
    };
  }
}

Die wichtigsten Erkenntnisse

Feature Flags entkoppeln das Deployment von der Freigabe: Du bringst Code jederzeit in Produktion und aktivierst Funktionen schrittweise über prozentbasierte Rollouts, die bei 1 % starten, über 10 % und 50 % mit automatisierten Metrikprüfungen fortschreiten und sofort zurückgerollt werden, wenn Fehlerraten oder Latenz die Schwellenwerte überschreiten. Jeder Feature Flag braucht Lifecycle-Metadaten: einen Verantwortlichen, ein Ablaufdatum, eine Kategorie (release, experiment, ops, permission) und ein verknüpftes Cleanup-Ticket – ohne diese Disziplin sammeln sich veraltete Flags an, bis niemand mehr weiß, welche Codepfade tatsächlich aktiv sind. Verwende konsistentes Hashing für prozentbasierte Rollouts, damit derselbe Nutzer über alle Anfragen hinweg immer denselben Flag-Zustand erhält – das verhindert flackernde Nutzererlebnisse und macht von Nutzern gemeldete Bugs reproduzierbar, indem du prüfst, welche Variante der Hash dieses Nutzers ergibt. Teste beide Flag-Zustände explizit in deiner Testsuite, indem du Flags im Testsetup überschreibst, statt dich auf Umgebungs-Standardwerte zu verlassen, denn ein Flag, der aktiviert funktioniert, aber deaktiviert abstürzt (oder umgekehrt), wird irgendwann in Produktion landen, wenn sich der Rollout ändert.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX