Volver al Blog
Desarrollo19 de mayo de 202616 min de lectura

Feature Flags Sin Volverse Loco

Los feature flags son una idea simple que se convierte en deuda silenciosa. Este artículo cubre el ciclo completo: añadir un flag con contexto, rollout gradual y la parte que nadie documenta: la limpieza.

IM
Ignacio MelendezDesarrollador Full-Stack y de Videojuegos
Feature Flags Sin Volverse Loco

Los feature flags empiezan siendo una buena idea. Se necesita desplegar una feature a medias sin romper producción, o probar un nuevo flujo de precios con el 5% de usuarios antes de comprometerse del todo. Se añade un booleano, se envuelve el código, despliegue a producción. Listo en diez minutos.

Seis meses después un nuevo desarrollador se encuentra mirando un codebase con cuarenta flags, doce de los cuales nadie sabe si siguen activos, y tres que llevan "activados permanentemente" tanto tiempo que eliminarlos se siente como desactivar una bomba. Eso no es un problema de herramientas, es un problema de disciplina. Y empieza en el momento en que se añade el flag.

1. La implementación más simple que no da vergüenza

Antes de echar mano de LaunchDarkly o cualquier servicio externo, hay que entender realmente qué es un feature flag: una condición con nombre evaluada en tiempo de ejecución. La versión más simple posible es un objeto estático que se importa.

1// flags.ts
2export const flags = {
3  NEW_CHECKOUT: true,
4  DARK_MODE: false,
5  USAGE_BASED_BILLING: false,
6} as const
7
8// uso
9import { flags } from './flags'
10
11if (flags.NEW_CHECKOUT) {
12  return renderNewCheckout(cart)
13}
14return renderLegacyCheckout(cart)

Esto está bien para un desarrollador solo en un proyecto pequeño (siendo conscientes de que no es escalable en el tiempo). El problema es el nombre. Un flag llamado NEW_CHECKOUT no dice nada: ¿nuevo respecto a qué? ¿Cuándo se añadió? ¿Sigue siendo "nuevo"? Los buenos nombres de flags llevan una pista de su propio ciclo de vida: CHECKOUT_V2_ROLLOUT, BILLING_MIGRATION_GATE, DARK_MODE_EXPERIMENT. El nombre debería decirle a un futuro lector por qué existe el flag y aproximadamente cuándo deja de existir.

Nombrar los flags por el cambio que controlan, no por la feature que habilitan. ENABLE_NEW_CHECKOUT es peor que CHECKOUT_V2_ROLLOUT: el primero podría ser permanente, el segundo implica una transición temporal.

2. Definir flags con contexto suficiente

La causa principal de la deuda de flags es añadir un flag sin metadatos. Seis meses después, nadie sabe quién es el owner, de qué ticket salió, ni qué condición haría seguro eliminarlo. La solución es mecánica: hacer que el flag lleve esa información desde el principio.

1type FeatureFlag = {
2  enabled: boolean
3  owner: string
4  ticket: string
5  removeAfter: string       // fecha ISO: cuándo revisitar
6  removalCondition: string  // condición en lenguaje natural para eliminar
7}
8
9export const flags: Record<string, FeatureFlag> = {
10  CHECKOUT_V2_ROLLOUT: {
11    enabled: true,
12    owner: 'payments-team',
13    ticket: 'PAY-1234',
14    removeAfter: '2026-08-01',
15    removalCondition:
16      'Eliminar cuando V2 esté al 100% y sin incidentes P1/P2 durante 2 semanas',
17  },
18  USAGE_BASED_BILLING: {
19    enabled: false,
20    owner: 'billing-team',
21    ticket: 'BILL-789',
22    removeAfter: '2026-07-15',
23    removalCondition:
24      'Eliminar cuando la migración de billing esté completa para todas las cuentas enterprise',
25  },
26}
27
28export function isEnabled(flagName: keyof typeof flags): boolean {
29  return flags[flagName]?.enabled ?? false
30}

Esta estructura da dos cosas inmediatamente: una flag autodocumentada que otro desarrollador puede entender sin leer el ticket, y una fecha de expiración legible por máquina que puede usarse para construir un aviso de flags obsoletos en la pipeline de CI.

Una buena idea podría ser añadir un check en CI que falle si algún flag tiene una fecha removeAfter de hace más de 30 días y sigue en el codebase. Esto convierte la limpieza de "alguien debería hacer esto algún día" en "la build está rota hasta que alguien lo haga."

3. El evaluador: más allá del booleano

Los flags estáticos funcionan hasta que necesitas overrides por entorno, targeting por usuario o rollouts porcentuales. En ese punto es necesario un evaluador (una función que reciba el nombre de un flag y un contexto, y devuelva una decisión).

El contexto es la decisión de diseño clave. Debe contener todo lo que el evaluador necesita: el ID de usuario, el entorno, cualquier atributo usado para targeting (plan, región, antigüedad de la cuenta). De esta forma, se mantiene la lógica de evaluación centralizada y los callsites limpios.

1type EvaluationContext = {
2  userId: string
3  environment: 'production' | 'staging' | 'development'
4  attributes?: Record<string, string | number | boolean>
5}
6
7type FlagConfig = {
8  enabled: boolean
9  environments?: Array<'production' | 'staging' | 'development'>
10  rolloutPercentage?: number   // 0-100
11  allowlist?: string[]         // IDs de usuario siempre habilitados
12  blocklist?: string[]         // IDs de usuario siempre deshabilitados
13}
14
15function evaluate(flag: FlagConfig, ctx: EvaluationContext): boolean {
16  if (!flag.enabled) return false
17
18  if (flag.blocklist?.includes(ctx.userId)) return false
19  if (flag.allowlist?.includes(ctx.userId)) return true
20
21  if (flag.environments && !flag.environments.includes(ctx.environment)) {
22    return false
23  }
24
25  if (flag.rolloutPercentage !== undefined) {
26    return isInRollout(ctx.userId, flag.rolloutPercentage)
27  }
28
29  return true
30}

El orden de evaluación importa: blocklist antes que allowlist (la seguridad primero), filtro de entorno antes que el rollout (no tiene sentido hashear usuarios que de todas formas no serían elegibles), rollout al final como barrera probabilística.

4. Rollout porcentual desde cero

Un rollout porcentual tiene un requisito clave: debe ser consistente. Si un usuario ve la feature en su primera visita, tiene que verla en la centésima. Si se usara un número aleatorio en cada petición, la feature aparecería y desaparecería cada vez que el usuario recargara la página.

La solución es convertir el ID de usuario en un número fijo entre 0 y 99 mediante un hash. A cada usuario se le asigna un número de casillero permanente. Si el rollout es del 10%, los usuarios con casillero 0-9 ven la feature; el resto no. Como el hash siempre produce el mismo número para el mismo usuario, la asignación nunca cambia entre peticiones.

1import { createHash } from 'crypto'
2
3function stableBucket(userId: string, flagName: string): number {
4  // Incluir el nombre del flag significa que distintos flags asignan
5  // distintos buckets al mismo usuario — evita que todos los flags
6  // se activen al mismo tiempo para los mismos usuarios
7  const hash = createHash('sha256')
8    .update(`${flagName}:${userId}`)
9    .digest('hex')
10
11  // Primeros 8 chars hex → entero 32 bits → bucket 0-99
12  const num = parseInt(hash.substring(0, 8), 16)
13  return num % 100
14}
15
16function isInRollout(userId: string, flagName: string, percent: number): boolean {
17  return stableBucket(userId, flagName) < percent
18}
19
20// Ejemplos:
21// isInRollout('user_abc', 'CHECKOUT_V2_ROLLOUT', 10) → true/false estable
22// isInRollout('user_abc', 'DARK_MODE_EXPERIMENT', 10) → resultado distinto
23//   para el mismo usuario porque el nombre del flag cambia el input del hash

No es recomendable usar userId.hashCode() ni ninguna función hash nativa del lenguaje. La mayoría de implementaciones de hashCode no son estables entre reinicios del proceso o versiones del lenguaje. Usa un hash criptográfico (SHA-256) con un formato de input fijo.

Mezclar el nombre del flag en el hash es una decisión deliberada. Sin él, todos los flags usarían el mismo número de casillero para cada usuario: el mismo 5% de personas vería todas las features experimentales a la vez. Eso concentra las malas experiencias siempre en el mismo grupo y hace que los resultados de los experimentos no sean fiables. Al incluir el nombre del flag, cada flag asigna usuarios de forma independiente.

5. Flags que nadie se atreve a tocar

Hay un feature flag en el servicio de pagos que lleva dos años activado. Nadie sabe qué pasa si lo desactivas. Nadie lo ha tocado.

Este es el problema real de los feature flags: no la implementación, sino la acumulación. Los flags se acumulan porque añadirlos es barato y eliminarlos se siente arriesgado. Con el tiempo, el codebase se llena de ramas que nadie ejercita, que complican cada refactor, y que representan acoplamiento invisible entre partes del sistema.

El patrón es consistente: se añade un flag durante un rollout, el rollout se completa, alguien lo pone a enabled: true globalmente, y entonces simplemente... se queda. El ticket para eliminarlo se crea pero nunca se prioriza. El desarrollador que sabía por qué existía ya no está. Dieciocho meses después, el flag evalúa a true en cada llamada, la rama false es código muerto, y nadie sabe si esa rama tiene dependencias.

  • Se acumulan ramas muertas: código que nunca puede alcanzarse pero que hay que mantener, leer y entender.
  • El type safety se degrada: lógica condicional que podría ser una llamada directa se convierte en un if/else con dos versiones de los mismos tipos.
  • Los tests mienten: los tests escritos cuando se estaba haciendo el rollout del flag pueden testear ambas ramas, pero la rama habilitada ha derivado.
  • El refactoring es más difícil: no puedes extraer limpiamente una función cuando tiene dos comportamientos controlados por un flag.

6. El ciclo de limpieza

La limpieza necesita estar suficientemente automatizada como para no depender de que alguien se acuerde de hacerla. La fecha removeAfter de la definición del flag es tu trigger. La versión más simple es un script que se ejecuta en CI y lista todos los flags caducados:

1// scripts/check-stale-flags.ts
2import { flags } from '../src/flags'
3
4const today = new Date()
5const stale: string[] = []
6
7for (const [name, flag] of Object.entries(flags)) {
8  const removeAfter = new Date(flag.removeAfter)
9  if (removeAfter < today) {
10    stale.push(
11      `[STALE] ${name} — expiró ${flag.removeAfter}, owner: ${flag.owner}, ticket: ${flag.ticket}`
12    )
13  }
14}
15
16if (stale.length > 0) {
17  console.error('Flags obsoletos detectados:')
18  stale.forEach(msg => console.error(msg))
19  process.exit(1)  // falla el CI
20}
21
22console.log('Todos los flags están dentro de su ventana de eliminación.')

Cuando se elimina un flag, el proceso es sencillo: eligir la rama que ahora es el comportamiento permanente, inlínear su código, eliminar la otra rama, eliminar la definición del flag, y eliminar la evaluación del flag en cada callsite. Lo único que frena a la gente es el miedo (la sensación de que "activado permanentemente" podría diferir de algún modo de "el código que habilita ejecutándose incondicionalmente").

No difiere. Si un flag ha estado activado para todos durante meses sin incidentes, su código es correcto. Eliminar el condicional no cambia ningún comportamiento: simplemente elimina la complejidad de una rama que nunca puede tomarse.

Cuando se elimine un flag, es una buena idea hacerlo en dos commits: primero marcarlo como activado permanentemente (eliminando la rama false), desplegar y verificar. Luego, eliminar el flag y la evaluación en un segundo commit. Esto proporcionará un punto de rollback seguro por si eliminar la rama muerta causa algún problema inesperado.

7. Testing con flags: el caso que todo el mundo ignora

El código con feature flags tiene un problema de testing del que nadie habla: la mayoría de suites de tests solo testean un estado del flag. Generalmente el estado habilitado, porque eso es en lo que el desarrollador estaba trabajando cuando escribió los tests. La rama deshabilitada (el camino legacy) queda sin testear y se pudre.

El enfoque correcto es testear ambas ramas explícitamente, usando un override del flag en el contexto del test. Esto también protege durante la limpieza: si existen tests explícitos para ambas ramas, eliminar la rama deshabilitada hará que esos tests fallen hasta que también se eliminen, forzando a que la limpieza sea completa en lugar de parcial.

1// checkout.test.ts
2import { evaluate } from '../src/evaluator'
3import { getCheckoutFlow } from '../src/checkout'
4
5const baseCtx = { userId: 'user_test', environment: 'production' as const }
6
7const flagEnabled = { enabled: true } as FlagConfig
8const flagDisabled = { enabled: false } as FlagConfig
9
10describe('flujo de checkout', () => {
11  it('renderiza el checkout V2 cuando el flag está activo', () => {
12    const result = getCheckoutFlow(cart, evaluate(flagEnabled, baseCtx))
13    expect(result.version).toBe('v2')
14  })
15
16  it('renderiza el checkout legacy cuando el flag está inactivo', () => {
17    const result = getCheckoutFlow(cart, evaluate(flagDisabled, baseCtx))
18    expect(result.version).toBe('legacy')
19  })
20})
21
22// Al eliminar el flag e inlinear la rama habilitada:
23// - El test de flagDisabled fallará inmediatamente
24// - Esto te obliga a eliminar ese test — prueba de que la limpieza está completa

Hay un segundo modo de fallo más difícil de detectar: tests escritos contra una función que lee el flag internamente, donde el estado del flag se establece en algún setup de test que nadie nota que está ahí. Cuando se elimina el flag del código de producción, los tests siguen pasando porque el código de setup todavía establece un valor, aunque simplemente ya no hace nada. El test te da falsa confianza de que se verificaron realmente ambos caminos.

También hay que evitar leer flags dentro de las funciones bajo test. En su lugar,se debe pasar el resultado del flag evaluado como parámetro. Esto hace la dependencia del flag explícita, elimina la necesidad de setup de test que muta estado global de flags, y hace ambos caminos de código testeables sin ninguna infraestructura de mocking.

El principio es simple: evalúar los flags en el boundary (el controlador, el request handler, el consumer de la cola), luego pasar el resultado booleano hacia abajo a las funciones que lo usan. Las funciones que reciben un booleano no saben ni les importa que vino de un flag, lo que significa que pueden ser testadas con un literal true o false y el test es honesto.

Conclusiones

Los feature flags no son una herramienta compleja. La implementación es una función hash y una estructura de datos. La dificultad está en la disciplina de tratar cada flag como temporal desde el momento en que se crea, dándole un owner, una fecha límite y una condición de eliminación en lenguaje natural, y luego haciendo cumplir eso a través del CI.

  • Nombrar los flags por la transición que controlan, no por la feature que habilitan.
  • Toda definición de flag debe llevar: owner, ticket, fecha removeAfter, condición de eliminación en lenguaje natural.
  • Incluir el nombre del flag en el hash del rollout para prevenir exposición correlada entre flags.
  • Automatizar la detección de flags obsoletos en CI, convirtiendo la limpieza de intención en obligación.
  • Testear ambos estados del flag explícitamente, no a través de un setup global que puede convertirse silenciosamente en un no-op.
  • Evaluar los flags en el boundary; pasar el resultado como parámetro a las funciones que lo usan.

Los flags que llevan "activados permanentemente dos años" no existen porque los desarrolladores sean descuidados. Existen porque el sistema no hizo que eliminarlos fuera lo natural. Usando metadatos obligatorios desde el principio y el check de flags obsoletos automatizado, la mayoría de esas conversaciones nunca ocurrirán.