Feature-Toggle-Muster für sichere Releases
Feature Toggles für Trunk-Based Development, schrittweise Rollouts und sofortige Rollbacks: Toggle-Typen, Teststrategien und Cleanup ohne Schulden.

Feature Toggles entkoppeln Deployment von Release. Du kannst Code in den main-Branch mergen, in Produktion deployen und steuern, wer das neue Feature sieht – über Konfiguration, nicht Code-Änderungen. Das ermöglicht Trunk-Based Development, schrittweise Rollouts, A/B-Tests und sofortige Rollbacks ohne Redeployment.
Aber Toggles haben Kosten. Jeder Toggle fügt dem Code einen Zweig hinzu, verdoppelt die Anzahl der zu testenden Zustände und wird zur technischen Schuld, wenn er nicht aufgeräumt wird. Das Muster ist nur dann mächtig, wenn es mit Disziplin im Toggle-Lifecycle-Management kombiniert wird.
Arten von Feature Toggles
Nicht alle Toggles sind gleich. Der Typ bestimmt, wer ihn kontrolliert, wie lange er lebt und wie er getestet werden sollte.
// Toggle categories with distinct lifecycles
interface ToggleDefinition {
name: string;
type: 'release' | 'experiment' | 'ops' | 'permission';
owner: string;
createdAt: string;
expectedRemovalDate: string;
description: string;
}
const toggleRegistry: ToggleDefinition[] = [
{
name: 'new-checkout-flow',
type: 'release',
owner: 'checkout-team',
createdAt: '2022-07-01',
expectedRemovalDate: '2022-08-01',
description: 'New checkout UI with express payment options',
// Release toggles: short-lived, removed after full rollout
},
{
name: 'search-algorithm-v2',
type: 'experiment',
owner: 'search-team',
createdAt: '2022-07-10',
expectedRemovalDate: '2022-08-10',
description: 'Test new ranking algorithm against baseline',
// Experiment toggles: short-lived, removed after metric analysis
},
{
name: 'maintenance-mode',
type: 'ops',
owner: 'platform-team',
createdAt: '2022-01-15',
expectedRemovalDate: 'permanent',
description: 'Disable writes during maintenance windows',
// Ops toggles: long-lived, controlled by operations
},
{
name: 'premium-features',
type: 'permission',
owner: 'product-team',
createdAt: '2022-03-01',
expectedRemovalDate: 'permanent',
description: 'Gate premium features behind subscription tier',
// Permission toggles: long-lived, part of the business model
},
];Implementierung eines Toggle-Services
Ein Toggle-Service bewertet, ob ein Feature für einen gegebenen Kontext aktiviert ist (Benutzer, Umgebung, Prozentsatz).
interface ToggleContext {
userId: string;
userTier?: 'free' | 'pro' | 'enterprise';
environment: 'development' | 'staging' | 'production';
region?: string;
}
interface ToggleRule {
enabled: boolean;
conditions?: {
environments?: string[];
userTiers?: string[];
userIds?: string[]; // Specific users (for internal testing)
percentage?: number; // Gradual rollout percentage
};
}
class FeatureToggleService {
private toggles: Map<string, ToggleRule>;
constructor(toggleConfig: Record<string, ToggleRule>) {
this.toggles = new Map(Object.entries(toggleConfig));
}
isEnabled(toggleName: string, context: ToggleContext): boolean {
const rule = this.toggles.get(toggleName);
// Unknown toggle = disabled (fail safe)
if (!rule) return false;
// Global kill switch
if (!rule.enabled) return false;
// No conditions = enabled for everyone
if (!rule.conditions) return true;
const { conditions } = rule;
// Environment check
if (
conditions.environments &&
!conditions.environments.includes(context.environment)
) {
return false;
}
// Specific user allowlist (internal testers)
if (conditions.userIds?.includes(context.userId)) {
return true;
}
// User tier check
if (
conditions.userTiers &&
context.userTier &&
!conditions.userTiers.includes(context.userTier)
) {
return false;
}
// Percentage rollout (deterministic based on user ID)
if (conditions.percentage !== undefined) {
const hash = this.hashUserId(context.userId, toggleName);
return hash < conditions.percentage;
}
return true;
}
private hashUserId(userId: string, toggleName: string): number {
// Deterministic hash: same user always gets same result
let hash = 0;
const input = `${toggleName}:${userId}`;
for (let i = 0; i < input.length; i++) {
hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
}
return Math.abs(hash) % 100;
}
}Verwendung von Toggles im Anwendungscode
Halte die Toggle-Auswertung an der Grenze. Verteile keine Toggle-Checks tief in der Geschäftslogik.
// ❌ Toggle checks scattered throughout the codebase
class CheckoutService {
async processOrder(order: Order) {
if (toggles.isEnabled('new-checkout-flow', ctx)) {
// 50 lines of new flow
} else {
// 50 lines of old flow
}
if (toggles.isEnabled('new-checkout-flow', ctx)) {
await this.sendNewConfirmationEmail(order);
} else {
await this.sendOldConfirmationEmail(order);
}
// Toggle checked 5 more times in this file...
// Good luck testing all combinations
}
}
// ✅ Toggle evaluated once at the boundary, strategy pattern inside
class CheckoutService {
async processOrder(
order: Order,
checkoutFlow: CheckoutFlow // Injected based on toggle
) {
return checkoutFlow.process(order);
}
}
// At the route/controller level — the boundary
app.post('/api/checkout', async (req, res) => {
const ctx = getToggleContext(req);
const flow = toggles.isEnabled('new-checkout-flow', ctx)
? new NewCheckoutFlow(deps)
: new LegacyCheckoutFlow(deps);
const result = await checkoutService.processOrder(req.body, flow);
res.json(result);
});Testen mit Feature Toggles
Jeder Toggle verdoppelt die Anzahl der Code-Pfade. Die Teststrategie muss beide Zustände berücksichtigen – Toggle an und Toggle aus.
describe('CheckoutService', () => {
// Test both toggle states explicitly
describe('with new checkout flow enabled', () => {
const flow = new NewCheckoutFlow(mockDeps);
it('processes order with express payment', async () => {
const order = createTestOrder();
const result = await checkoutService.processOrder(order, flow);
expect(result.paymentMethod).toBe('express');
});
it('sends new confirmation email', async () => {
const order = createTestOrder();
await checkoutService.processOrder(order, flow);
expect(mockEmail.sent).toContainEqual(
expect.objectContaining({ template: 'new-confirmation' })
);
});
});
describe('with legacy checkout flow', () => {
const flow = new LegacyCheckoutFlow(mockDeps);
it('processes order with standard payment', async () => {
const order = createTestOrder();
const result = await checkoutService.processOrder(order, flow);
expect(result.paymentMethod).toBe('standard');
});
});
});
// Integration test: verify toggle service itself
describe('FeatureToggleService', () => {
it('enables feature for percentage rollout deterministically', () => {
const service = new FeatureToggleService({
'new-feature': {
enabled: true,
conditions: { percentage: 50 },
},
});
// Same user always gets the same result
const result1 = service.isEnabled('new-feature', {
userId: 'user-123',
environment: 'production',
});
const result2 = service.isEnabled('new-feature', {
userId: 'user-123',
environment: 'production',
});
expect(result1).toBe(result2);
});
it('returns false for unknown toggles', () => {
const service = new FeatureToggleService({});
const result = service.isEnabled('nonexistent', {
userId: 'user-123',
environment: 'production',
});
expect(result).toBe(false);
});
});Strategie für schrittweise Rollouts
Roll neue Features schrittweise aus – starte mit internen Benutzern, erweitere auf einen kleinen Prozentsatz und erhöhe bei wachsendem Vertrauen.
// Rollout progression for a new feature
const rolloutPlan = [
{
stage: 'internal',
config: {
enabled: true,
conditions: {
userIds: ['dev-1', 'dev-2', 'pm-1'], // Specific team members
},
},
duration: '3 days',
criteria: 'No errors in logs, positive team feedback',
},
{
stage: 'canary',
config: {
enabled: true,
conditions: { percentage: 5 },
},
duration: '1 week',
criteria: 'Error rate < 0.1%, latency p99 within 10% of baseline',
},
{
stage: 'partial',
config: {
enabled: true,
conditions: { percentage: 25 },
},
duration: '1 week',
criteria: 'Same as canary + conversion rate stable',
},
{
stage: 'majority',
config: {
enabled: true,
conditions: { percentage: 75 },
},
duration: '3 days',
criteria: 'All metrics stable at scale',
},
{
stage: 'full',
config: { enabled: true },
duration: 'permanent until cleanup',
criteria: 'Remove old code path, delete toggle',
},
];# ❌ No rollout strategy
# Deploy new feature to 100% of users on Friday at 5 PM
# Hope nothing breaks over the weekend
# ✅ Gradual rollout with monitoring
# Week 1: Internal team (3 users) — catch obvious bugs
# Week 2: 5% of users — validate at low scale
# Week 3: 25% of users — watch for edge cases
# Week 4: 75% of users — performance at scale
# Week 5: 100% + remove toggle and old codeToggle-Cleanup
Veraltete Toggles sind technische Schuld. Jeder Toggle, der seinen Zweck überlebt, fügt Komplexität hinzu, verwirrt neue Entwickler und vergrößert die Testfläche. Verfolge den Toggle-Lebenszyklus und erzwinge Cleanup.
// scripts/check-stale-toggles.ts
import { toggleRegistry } from '../config/toggles';
function findStaleToggles(): ToggleDefinition[] {
const now = new Date();
const stale: ToggleDefinition[] = [];
for (const toggle of toggleRegistry) {
if (toggle.expectedRemovalDate === 'permanent') continue;
const removalDate = new Date(toggle.expectedRemovalDate);
if (now > removalDate) {
stale.push(toggle);
}
}
return stale;
}
const stale = findStaleToggles();
if (stale.length > 0) {
console.warn(`⚠️ ${stale.length} stale toggle(s) found:`);
for (const t of stale) {
console.warn(` - ${t.name} (owner: ${t.owner}, expected removal: ${t.expectedRemovalDate})`);
}
// In CI: this could fail the build or create a ticket
process.exit(1);
}Wichtige Erkenntnisse
- Klassifiziere Toggles nach Typ – Release-, Experiment-, Ops- und Permission-Toggles haben unterschiedliche Lebenszyklen und Eigner
- Bewerte Toggles an der Grenze – prüfe den Toggle einmal im Controller und injiziere die passende Strategie, statt ihn in der Geschäftslogik zu verteilen
- Nutze deterministisches Hashing für Prozent-Rollouts – derselbe Benutzer muss immer dieselbe Variante sehen; zufällige Auswertung erzeugt inkonsistente Erlebnisse
- Teste beide Toggle-Zustände explizit – jeder Toggle verdoppelt die Code-Pfade; ungetestete Pfade brechen, wenn du den Toggle umschaltest
- Roll schrittweise aus – intern → 5% → 25% → 75% → 100%, mit klaren Kriterien für den Übergang in jede Stufe
- Erzwinge Toggle-Cleanup – verfolge erwartete Entfernungsdaten und lasse den CI-Build fehlschlagen, wenn Toggles ihre vorgesehene Lebensdauer überschreiten


