Saltar al contenido

Patrones de feature toggles para releases seguros

Cómo implementar feature toggles para trunk-based development, despliegues graduales y rollbacks instantáneos: tipos, pruebas y limpieza sin deuda.

5 min de lectura
Flujo de evaluación de feature toggles mostrando el contexto del usuario, las reglas del toggle y la activación de la funcionalidad

Los feature toggles desacoplan el despliegue del release. Puedes mergear código a main, desplegarlo a producción y controlar quién ve la nueva funcionalidad mediante configuración — no cambios de código. Esto habilita el trunk-based development, los despliegues graduales, el A/B testing y los rollbacks instantáneos sin redeploy.

Pero los toggles tienen un costo. Cada toggle agrega una rama a tu código, duplica el número de estados a probar y se convierte en deuda técnica si no se limpia. El patrón solo es poderoso cuando se combina con disciplina en la gestión del ciclo de vida de los toggles.

Tipos de feature toggles

No todos los toggles son iguales. El tipo determina quién lo controla, cuánto tiempo vive y cómo debe probarse.

tstypescript
// 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
  },
];

Implementación de un servicio de toggles

Un servicio de toggles evalúa si una funcionalidad está habilitada para un contexto dado (usuario, entorno, porcentaje).

tstypescript
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;
  }
}

Uso de toggles en el código de aplicación

Mantén la evaluación del toggle en el límite. No esparzas los checks del toggle en lo profundo de la lógica de negocio.

tstypescript
// ❌ 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);
});

Pruebas con feature toggles

Cada toggle duplica el número de caminos en el código. La estrategia de pruebas debe considerar ambos estados — toggle activado y toggle desactivado.

tstypescript
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);
  });
});

Estrategia de despliegue gradual

Despliega las nuevas funcionalidades gradualmente — comienza con usuarios internos, expande a un pequeño porcentaje y aumenta a medida que ganas confianza.

tstypescript
// 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',
  },
];
shbash
# ❌ 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 code

Limpieza de toggles

Los toggles obsoletos son deuda técnica. Cada toggle que sobrevive a su propósito agrega complejidad, confunde a nuevos desarrolladores y aumenta la superficie de prueba. Rastrea el ciclo de vida del toggle y haz cumplir la limpieza.

tstypescript
// 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);
}

Conclusiones clave

  1. Clasifica los toggles por tipo — los toggles de release, experimento, ops y permiso tienen diferentes ciclos de vida y dueños
  2. Evalúa los toggles en el límite — verifica el toggle una vez en el controlador e inyecta la estrategia adecuada, no esparcido por la lógica de negocio
  3. Usa hashing determinístico para despliegues por porcentaje — el mismo usuario debe ver siempre la misma variante; la evaluación aleatoria crea experiencias inconsistentes
  4. Prueba ambos estados del toggle explícitamente — cada toggle duplica los caminos del código; los caminos no probados se romperán cuando actives el toggle
  5. Despliega gradualmente — interno → 5% → 25% → 75% → 100%, con criterios claros para avanzar en cada etapa
  6. Haz cumplir la limpieza de toggles — rastrea las fechas de remoción esperadas y falla el CI cuando los toggles superen su vida útil prevista
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX