Zum Inhalt springen

Feature Flags im großen Maßstab: Progressive Delivery

Baue ein Feature-Flag-System mit prozentualen Rollouts, Nutzersegmentierung, A/B-Tests und Kill Switches, das Deployment und Release entkoppelt.

4 Min. Lesezeit
Eine Deployment-Pipeline, in der Feature Flags den Traffic nach Prozentsätzen auf neue und alte Codepfade lenken

Deployment ist nicht Release

Code deployen und ein Feature releasen sind zwei unterschiedliche Aktionen. Deployment ist ein technischer Vorgang — Artefakte in die Produktion zu bringen. Release ist eine Geschäftsentscheidung — Funktionalität für Nutzer freizuschalten. Feature Flags trennen beides: Du kannst jederzeit inaktiven Code deployen und Features freigeben, wann und für wen du willst.

Der Feature-Flag-Vertrag

Ein Feature Flag ist ein Entscheidungspunkt zur Laufzeit. Im einfachsten Fall ist es ein Boolean. Im großen Maßstab ist es eine Rule Engine, die den Nutzerkontext anhand von Targeting-Regeln auswertet und einen Wert zurückgibt.

tstypescript
interface FeatureFlag {
  key: string;
  type: "boolean" | "string" | "number" | "json";
  defaultValue: unknown;
  enabled: boolean;
  rules: TargetingRule[];
  killSwitch: boolean;
}
 
interface TargetingRule {
  priority: number;
  conditions: Condition[];
  percentage: number;
  value: unknown;
}
 
interface Condition {
  attribute: string;
  operator: "eq" | "neq" | "in" | "contains" | "gt" | "lt" | "semver_gt";
  value: unknown;
}
 
interface EvaluationContext {
  userId: string;
  email?: string;
  plan?: string;
  country?: string;
  appVersion?: string;
  customAttributes?: Record<string, unknown>;
}
tstypescript
class FeatureFlagClient {
  private flags: Map<string, FeatureFlag> = new Map();
 
  evaluate<T>(flagKey: string, context: EvaluationContext, fallback: T): T {
    const flag = this.flags.get(flagKey);
 
    // Flag doesn't exist — return fallback
    if (!flag) return fallback;
 
    // Kill switch activated — return default immediately
    if (flag.killSwitch) return flag.defaultValue as T;
 
    // Flag disabled globally — return default
    if (!flag.enabled) return flag.defaultValue as T;
 
    // Evaluate targeting rules in priority order
    const sortedRules = [...flag.rules].sort(
      (a, b) => a.priority - b.priority
    );
 
    for (const rule of sortedRules) {
      if (this.matchesConditions(rule.conditions, context)) {
        if (this.isInPercentage(context.userId, flagKey, rule.percentage)) {
          return rule.value as T;
        }
      }
    }
 
    return flag.defaultValue as T;
  }
 
  private matchesConditions(
    conditions: Condition[],
    context: EvaluationContext
  ): boolean {
    return conditions.every((condition) => {
      const contextValue = this.getAttribute(context, condition.attribute);
      switch (condition.operator) {
        case "eq":
          return contextValue === condition.value;
        case "neq":
          return contextValue !== condition.value;
        case "in":
          return Array.isArray(condition.value) &&
            condition.value.includes(contextValue);
        case "contains":
          return typeof contextValue === "string" &&
            contextValue.includes(condition.value as string);
        default:
          return false;
      }
    });
  }
 
  // Deterministic percentage based on userId + flagKey hash
  private isInPercentage(
    userId: string,
    flagKey: string,
    percentage: number
  ): boolean {
    if (percentage >= 100) return true;
    if (percentage <= 0) return false;
    const hash = this.consistentHash(`${userId}:${flagKey}`);
    return (hash % 100) < percentage;
  }
}

Muster für progressive Rollouts

Starte mit internen Nutzern, erweitere auf Beta-Tester und fahre dann auf 1 %, 5 %, 25 %, 50 %, 100 % hoch. Wenn die Metriken in irgendeiner Phase schlechter werden, stoppe sofort oder rolle zurück — ohne Deployment.

tstypescript
// ❌ Big-bang release — all users get the feature at once
function NewCheckout() {
  return <RadicallyDifferentCheckout />;
}
 
// ✅ Progressive rollout with monitoring at each stage
function Checkout() {
  const flags = useFeatureFlags();
  const isNewCheckout = flags.evaluate("new-checkout-flow", {
    userId: user.id,
    plan: user.plan,
    country: user.country,
  }, false);
 
  return isNewCheckout ? <NewCheckoutFlow /> : <CurrentCheckoutFlow />;
}
 
// Rollout configuration evolution:
const rolloutStages: TargetingRule[] = [
  // Stage 1: Internal team only
  {
    priority: 1,
    conditions: [{ attribute: "email", operator: "contains", value: "@ourcompany.com" }],
    percentage: 100,
    value: true,
  },
  // Stage 2: Beta users
  {
    priority: 2,
    conditions: [{ attribute: "plan", operator: "eq", value: "beta" }],
    percentage: 100,
    value: true,
  },
  // Stage 3: 5% of all users
  {
    priority: 3,
    conditions: [],
    percentage: 5,
    value: true,
  },
  // Stage 4: 50% of all users
  // Stage 5: 100% — flag cleaned up
];

Serverseitige vs. clientseitige Auswertung

Wo du Flags auswertest, beeinflusst Latenz, Sicherheit und Zuverlässigkeit. Serverseitige Auswertung hält Targeting-Regeln privat und sorgt für Konsistenz. Clientseitige Auswertung reduziert Latenz für UI-Änderungen, macht aber Flag-Konfigurationen sichtbar.

tstypescript
// Server-side: evaluate on the API, send only the result
// app/api/flags/route.ts
export async function GET(request: Request) {
  const context = await buildContextFromRequest(request);
 
  // Only send evaluated values, never rules or conditions
  const flags = {
    "new-checkout": flagClient.evaluate("new-checkout", context, false),
    "dark-mode": flagClient.evaluate("dark-mode", context, false),
    "pricing-tier": flagClient.evaluate("pricing-tier", context, "standard"),
  };
 
  return Response.json(flags);
}
 
// Client-side: bootstrap with server-evaluated values
function FlagProvider({ children }: { children: React.ReactNode }) {
  const [flags, setFlags] = useState<Record<string, unknown>>({});
 
  useEffect(() => {
    fetch("/api/flags")
      .then((r) => r.json())
      .then(setFlags);
  }, []);
 
  return (
    <FlagContext.Provider value={flags}>
      {children}
    </FlagContext.Provider>
  );
}
 
function useFlag<T>(key: string, fallback: T): T {
  const flags = useContext(FlagContext);
  return (flags[key] as T) ?? fallback;
}

Flag-Lebenszyklus und technische Schulden

Feature Flags, die ewig leben, werden zu technischen Schulden. Jedes Flag ist ein Zweig in deinem Code. Etabliere einen Lebenszyklus: erstellen, ausrollen, 100 % erreichen, Flag und alten Codepfad entfernen.

tstypescript
interface FlagMetadata {
  key: string;
  owner: string;
  createdAt: string;
  expectedRemovalDate: string;
  purpose: "release" | "experiment" | "ops" | "permission";
  jiraTicket: string;
}
 
// Automated flag staleness detection
function findStaleFlags(
  flags: FlagMetadata[],
  today: Date = new Date()
): FlagMetadata[] {
  return flags.filter((flag) => {
    const removalDate = new Date(flag.expectedRemovalDate);
    return removalDate < today;
  });
}
 
// ESLint rule concept: warn on flags past their removal date
// Flag inventory tracked alongside code
const FLAG_REGISTRY: FlagMetadata[] = [
  {
    key: "new-checkout-flow",
    owner: "payments-team",
    createdAt: "2025-06-01",
    expectedRemovalDate: "2025-08-01",
    purpose: "release",
    jiraTicket: "PAY-1234",
  },
  {
    key: "maintenance-mode",
    owner: "platform-team",
    createdAt: "2025-01-15",
    expectedRemovalDate: "9999-12-31", // Permanent ops flag
    purpose: "ops",
    jiraTicket: "PLAT-500",
  },
];

Monitoring und Observability

Jede Flag-Auswertung sollte Telemetrie emitieren. Ohne sie kannst du Flag-Zustände nicht mit Fehlerraten, Latenzänderungen oder Geschäftsmetriken korrelieren.

tstypescript
class ObservableFeatureFlagClient extends FeatureFlagClient {
  constructor(private readonly metrics: MetricsClient) {
    super();
  }
 
  evaluate<T>(flagKey: string, context: EvaluationContext, fallback: T): T {
    const start = performance.now();
    const result = super.evaluate(flagKey, context, fallback);
    const duration = performance.now() - start;
 
    this.metrics.increment("feature_flag.evaluation", {
      flag: flagKey,
      result: String(result),
      userId: context.userId,
    });
 
    this.metrics.histogram("feature_flag.evaluation_ms", duration, {
      flag: flagKey,
    });
 
    return result;
  }
}

Wichtige Erkenntnisse

Feature Flags entkoppeln Deployment und Release. Code jederzeit deployen; Features für bestimmte Nutzer, in bestimmten Prozentsätzen, per Schalter freigeben — nicht per Deployment. Nutze progressive Rollouts, um den Blast Radius zu begrenzen: zuerst intern, dann Beta, dann Prozentsätze hochfahren mit Monitoring in jeder Phase.

Werte Flags serverseitig aus, wenn Targeting-Regeln sensibel sind oder Konsistenz wichtig ist. Tracke jedes Flag mit Metadaten — Owner, Zweck, geplantes Entfernungsdatum — und erzwinge die Bereinigung, um technische Schulden zu vermeiden. Emitiere Telemetrie bei jeder Auswertung, damit du Flag-Zustände mit Produktionsmetriken korrelieren kannst.

Das Ziel ist nicht, mehr Flags zu haben. Das Ziel ist, mit Vertrauen auszuliefern und zu wissen, dass jedes neue Feature in Sekunden zurückgedreht werden kann, ohne die Deployment-Pipeline anzufassen.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX