Módulo 4: Branch by abstraction

El feature flag como punto de conmutación

Descripción

Ya tienes la abstracción insertada (lección 2) y las dos implementaciones coexistiendo detrás de ella (lección 3). LegacyShipping recibe todo el tráfico; ModernShipping está montada y apagada. Falta la pieza que enciende la nueva de a poco: el feature flag. El flag es la perilla que decide, en cada llamada al cálculo de envío, cuál implementación corre detrás de la abstracción. Es la válvula que dirige la potencia entre los dos motores del avión: primero un chorrito al nuevo, luego más, hasta el 100%.

Este es el corazón mecánico del patrón, y tiene una propiedad que hay que ver ejecutada para creerla: conmutar la implementación no toca a los llamadores. Ni una línea de checkout_total, del carrito o del panel de admin cambia cuando subes el flag del 0 al 100%. Ellos siguen pidiéndole .cost() a la abstracción; el flag, por debajo, decide quién responde. Esa separación —el llamador atornillado al adaptador, no al motor— es lo que hace el cambio seguro y gradual: puedes darle a la nueva implementación el 10% del tráfico, observar, y subir, sin que ningún llamador se entere ni se rompa.

Esta lección lo ejecuta con un rollout gradual: un feature_flag en memoria con un rollout_percent que sube del 0 al 100, y un bucket estable por pedido —el mismo pedido cae siempre del mismo lado— que reparte el tráfico de forma reproducible. Vamos a medir cuántas llamadas van a cada implementación en cada nivel, y a verificar, pedido por pedido, que el resultado que ve el llamador es el mismo con la implementación conmutada que con el legacy siempre. Y vamos a distinguir un concepto que se confunde: el flag (runtime, por llamada, se cambia en caliente) frente a la config (deploy-time, toda la app), porque deciden cómo y cuándo se hace el cambio.

Conexión con el módulo. La lección 3 construyó ModernShipping; esta monta el flag que le da tráfico. La lección 5 hace la validación seria (parallel-run) que debe estar limpia antes de subir el flag —el orden real es validar primero, conmutar después—. La 6 borra el legacy cuando el flag llegó a 100% y se estabilizó. Fíjate en la frontera: el flag de aquí decide por llamada de función, dentro del proceso —es interno—. El desvío por porcentaje del strangler (módulo 3, lección 4) decidía por request HTTP, en un proxy externo. La mecánica del bucket estable es la misma en los dos; lo que cambia es el punto de decisión: aquí, en el código, cuál objeto se instancia detrás de la abstracción; allá, en la red, a qué servicio se enruta la request.

Una analogía: la válvula que dirige la potencia entre los dos motores

En el avión, ya tienes el motor viejo empujando y el motor nuevo montado al lado, apagado. La válvula que dirige la potencia es el feature flag. No es un interruptor de todo-o-nada: es una válvula graduable. Puedes ponerla en "0% al nuevo" (todo el empuje sigue viniendo del motor viejo), en "10% al nuevo" (un poco de potencia por el nuevo, la mayoría por el viejo), en "50%", en "100%" (todo el empuje por el nuevo, el viejo en vacío).

Lo importante de esta válvula es dónde no está conectada: no está conectada a los controles del piloto. El piloto sigue moviendo la misma palanca de gas, pidiendo la misma potencia; la válvula, por debajo, decide de qué motor sale esa potencia. El piloto no tiene que aprender un control nuevo ni cambiar cómo vuela: para él, el avión responde igual. Esa es la propiedad clave —el llamador (el piloto) no cambia cuando conmutas la implementación (el motor)—, y es exactamente lo que vamos a verificar en el código.

Y hay una segunda distinción que la válvula ilumina: la diferencia entre girar la válvula en vuelo y rediseñar el avión en el hangar. Girar la válvula es un ajuste en caliente: lo haces mientras el avión vuela, gradual, reversible —si el motor nuevo tose, giras la válvula de vuelta al viejo en el acto—. Eso es el feature flag: se cambia en runtime, por llamada, sin "aterrizar" el sistema. Rediseñar en el hangar sería cambiar el cableado de la potencia de forma fija, para todos los vuelos, y requeriría bajar el avión: eso es la config de deploy —se decide antes de arrancar, aplica a toda la app, y cambiarla exige un redeploy—. Los dos tienen su lugar; para migrar de a poco y poder revertir en el acto, quieres la válvula en vuelo, no el rediseño en el hangar.

Ejemplo trabajado: el rollout del flag sin tocar a los llamadores

Vamos a montar el flag y a subirlo del 0 al 100% sobre un lote fijo de 200 pedidos, midiendo el reparto y verificando que los llamadores no se rompen. El feature_flag vive en memoria (rollout_percent) y decide por pedido usando un bucket estable: convierte el id del pedido en un número del 0 al 99 con un hash (crc32), y si ese número cae por debajo del rollout_percent, el pedido usa ModernShipping; si no, LegacyShipping. Que el bucket sea estable importa: el mismo pedido cae siempre del mismo lado, así que la decisión es reproducible y un mismo pedido no salta de implementación entre dos llamadas. El llamador, checkout_total, es idéntico sin importar el rollout: recibe la abstracción y le pide .cost().

import zlib
from typing import Protocol

class ShippingCalculator(Protocol):
    def cost(self, order: dict) -> float: ...

class LegacyShipping:
    def cost(self, order: dict) -> float:
        base = {"local": 5.0, "national": 10.0, "international": 25.0}[order["zone"]]
        surcharge = max(0.0, order["weight_kg"] - 1.0) * 2.0
        cost = base + surcharge
        if order["order_total"] >= 50.0 and order["zone"] != "international":
            cost = 0.0
        return round(cost, 2)

class ModernShipping:
    RATES = {"local": 5.0, "national": 10.0, "international": 25.0}
    def cost(self, order: dict) -> float:
        base = self.RATES[order["zone"]]
        over = max(0.0, order["weight_kg"] - 1.0)
        free = order["order_total"] >= 50.0 and order["zone"] != "international"
        return 0.0 if free else round(base + over * 2.0, 2)

# --- El flag vive EN MEMORIA y se cambia en caliente, sin redeploy. Decide, orden
# por orden, cual implementacion corre detras de la abstraccion. El bucket estable
# hace que un mismo pedido caiga siempre del mismo lado (reproducible, sin saltos). ---
def bucket(order_id: int) -> int:
    return zlib.crc32(str(order_id).encode()) % 100

class FeatureFlag:
    def __init__(self, rollout_percent: int = 0):
        self.rollout_percent = rollout_percent
    def use_modern(self, order: dict) -> bool:
        return bucket(order["id"]) < self.rollout_percent

def resolve_calculator(order: dict, flag: FeatureFlag) -> ShippingCalculator:
    return ModernShipping() if flag.use_modern(order) else LegacyShipping()

# Un llamador cualquiera. Es IDENTICO sin importar el rollout: recibe la abstraccion.
def checkout_total(order: dict, calc: ShippingCalculator) -> float:
    return round(order["items_subtotal"] + calc.cost(order), 2)

ZONES = ["local", "national", "international"]
ORDERS = [{
    "id": i, "zone": ZONES[i % 3],
    "weight_kg": round(0.5 + (i % 5) * 0.5, 1),
    "order_total": 20.0 + (i % 7) * 10.0,
    "items_subtotal": 20.0 + (i % 7) * 10.0,
} for i in range(1, 201)]

legacy_always = LegacyShipping()

print("Conmutar el flag NO toca a los llamadores. Rollout sobre 200 pedidos:\n")
print(f"{'rollout':>9}{'-> modern':>12}{'-> legacy':>12}{'callers OK?':>14}")
print("-" * 47)
for pct in (0, 25, 50, 75, 100):
    flag = FeatureFlag(rollout_percent=pct)
    counts = {"modern": 0, "legacy": 0}
    callers_ok = True
    for o in ORDERS:
        calc = resolve_calculator(o, flag)
        counts["modern" if isinstance(calc, ModernShipping) else "legacy"] += 1
        # El resultado que ve el llamador es el mismo que con legacy siempre:
        # conmutar la implementacion no cambia lo que el checkout devuelve.
        callers_ok = callers_ok and (checkout_total(o, calc) == checkout_total(o, legacy_always))
    print(f"{pct:>8}%{counts['modern']:>12}{counts['legacy']:>12}{str(callers_ok):>14}")

print("\n  El reparto se mueve con una sola perilla (rollout_percent, en memoria).")
print("  'callers OK? = True' en todo nivel: ningun llamador se rompe al conmutar.")

Qué esperar. Al correr el archivo, la salida es exactamente esta:

Conmutar el flag NO toca a los llamadores. Rollout sobre 200 pedidos:

  rollout   -> modern   -> legacy   callers OK?
-----------------------------------------------
       0%           0         200          True
      25%          52         148          True
      50%         104          96          True
      75%         148          52          True
     100%         200           0          True

Lee la tabla de arriba hacia abajo: es el rollout entero en cinco renglones. En 0%, ninguno de los 200 pedidos usa ModernShipping —todos van al legacy—, exactamente como al final de la lección 3: la nueva existe pero no recibe tráfico. En 25%, 52 pedidos (de 200) caen por debajo del umbral y usan el modern; los otros 148 siguen en el legacy. Ese es tu chorrito inicial: una fracción del tráfico probando la implementación nueva mientras la mayoría sigue en la vieja. En 50% el reparto es casi mitad y mitad (104 y 96), y en 100% los 200 pedidos usan el modern y el legacy no recibe una sola llamada —el motor viejo en vacío, listo para desmontarse (lección 6)—.

Pero la columna que importa es la última: callers OK?, y da True en los cinco niveles. Esto es la propiedad central del patrón, medida. Para cada uno de los 200 pedidos, en cada nivel de rollout, el resultado de checkout_total con la implementación que el flag eligió es idéntico al resultado con el legacy siempre. Es decir: conmutar la implementación —darle el pedido al modern en vez de al legacy— no cambió lo que el llamador devuelve. El checkout_total no se tocó ni una vez; el flag movió el tráfico por debajo de él, y el llamador ni se enteró. Eso es el piloto que mueve la misma palanca de gas mientras la válvula, por debajo, cambia de motor.

Que a 25% salga 52 y no 50 exacto no es un error: el bucket por hash reparte de forma pareja pero no milimétrica sobre 200 pedidos. Y como el bucket es determinista (crc32(str(id)) % 100), volver a correr el mismo código con los mismos 200 ids da exactamente el mismo reparto: no hay azar, hay un hash fijo. Lo importante no es el número exacto, sino que el tráfico se mueve de la vieja a la nueva de forma controlada cuando subes una sola perilla —el rollout_percent— sin tocar a nadie más.

Profundización: flag vs config, y por qué el flag va en memoria por llamada

La distinción entre feature flag y config parece sutil pero decide cómo se hace el cambio. Aquí están, lado a lado:

                 FEATURE FLAG                     CONFIG (de deploy)
Cuando decide    en runtime, por llamada          antes de arrancar, fija
Alcance          por pedido / por usuario         toda la app a la vez
Cambiarla        en caliente, sin redeploy        requiere redeploy
Reversion        instantanea (baja el flag)       otro redeploy
Gradualidad      si (0 -> 10 -> 50 -> 100)         no (on/off para todos)
Analogia         girar la valvula en vuelo         recablear en el hangar

Para branch by abstraction quieres un flag, no una config, y por una razón concreta: la gradualidad y la reversión en caliente. Con un flag, le das el 10% del tráfico al modern, observas, y si algo va mal bajas el flag a 0 en el acto —sin redeploy, sin "aterrizar" el sistema—. Con una config de deploy, tu única opción sería prender el modern para todos a la vez con un despliegue, y si falla, apagarlo con otro despliegue: es on/off para toda la app, lento de revertir, y sin canary. El flag es lo que convierte la conmutación en gradual y reversible; la config la haría big-bang.

Por eso, en el ejemplo, el FeatureFlag vive en memoria y decide por pedido: flag.use_modern(order) se evalúa en cada llamada, mirando el rollout_percent actual. En un sistema real, ese rollout_percent vendría de un servicio de flags (LaunchDarkly, Unleash, una tabla en la base de datos, una variable que se puede cambiar sin redeploy) para poder subirlo y bajarlo en caliente. La forma exacta del backend del flag no importa para el patrón; lo que importa es la propiedad: se cambia en runtime, es gradual, y se revierte al instante. Aquí lo simulamos con un entero en memoria, que es la esencia sin la infraestructura.

Un matiz sobre el bucket estable y por qué se calcula sobre el id del pedido (o, en muchos casos reales, sobre el id del usuario). Si el flag decidiera al azar en cada llamada —con random() sin bucket—, el mismo pedido podría usar el modern en una llamada y el legacy en la siguiente, dando resultados que bailan entre las dos implementaciones dentro de una misma operación. El bucket estable lo evita: bucket(id) es determinista, así que un pedido cae siempre del mismo lado mientras el rollout_percent no cambie. Cuando el bucket se calcula sobre el usuario, la propiedad se vuelve aún más valiosa: un usuario ve consistentemente el modern o el legacy durante su sesión, sin saltos que le mostrarían un costo de envío en el carrito y otro en el checkout. La estabilidad del bucket es lo que hace el rollout gradual seguro para el usuario, no solo medible.

Errores comunes

Confundir el flag con una config de deploy. Qué pasa: el equipo "conmuta" con una variable de entorno que se lee al arrancar (USE_MODERN_SHIPPING=true), y para cambiarla hay que redesplegar. Por qué pasa: es lo más fácil de montar —una variable de entorno ya existe—. Cómo detectarlo: no puedes darle al modern el 10% del tráfico; solo puedes prenderlo para todos o para nadie, y cambiarlo exige un despliegue. Cómo corregirlo: para branch by abstraction necesitas un flag runtime, por llamada, cambiable en caliente, no una config de deploy. La diferencia no es cosmética: el flag te da el canary (10% primero) y la reversión instantánea (baja el flag si falla), que son justo lo que hace segura la conmutación. Una config de deploy convierte el cambio en un big-bang on/off, sin gradualidad ni fallback rápido —exactamente lo que el patrón quiere evitar—.

Un bucket inestable que hace saltar al usuario entre implementaciones. Qué pasa: el flag decide con random() < rollout_percent/100 en cada llamada, sin bucket estable. Por qué pasa: parece equivalente —"reparte el mismo porcentaje al azar"—. Cómo detectarlo: un mismo pedido o usuario obtiene el modern en una llamada y el legacy en la siguiente. En el cálculo de envío, eso puede mostrar un costo en el carrito y otro distinto en el checkout para el mismo pedido, dentro de la misma sesión. Cómo corregirlo: calcula el bucket de forma determinista sobre una clave estable (el id del pedido o, mejor, del usuario): bucket(id) < rollout_percent. Así el mismo pedido cae siempre del mismo lado mientras el porcentaje no cambie, y el usuario ve una implementación consistente. El azar reparte el porcentaje correcto en promedio, pero rompe la consistencia por-entidad; el bucket estable da las dos cosas.

Poner lógica de negocio en el punto de resolución del flag. Qué pasa: el resolve_calculator se va llenando de reglas —"si es international usa el legacy, si el total es alto usa el modern, salvo los martes"— hasta que el punto de conmutación tiene más lógica que las implementaciones. Por qué pasa: el punto de resolución toca todas las llamadas, parece cómodo para meter condiciones especiales. Cómo detectarlo: resolve_calculator deja de ser "elige por el flag" y empieza a ser "elige por reglas de negocio". Ahora tienes una tercera pieza de lógica —además del legacy y el modern— que también hay que mantener y que confunde de quién es cada resultado. Cómo corregirlo: el punto de resolución debe hacer una sola cosa: consultar el flag y devolver la implementación correspondiente. Todo caso especial de comportamiento vive dentro de una implementación, no en el selector. Si international necesita tratamiento distinto, eso es lógica del ModernShipping (o del legacy), no del resolve_calculator. El selector se queda flaco: pregunta el flag, entrega el objeto, nada más.

Ejercicios

Ejercicio 1 — La válvula y el piloto. En la analogía del avión, la válvula que dirige la potencia (el flag) no está conectada a los controles del piloto (los llamadores). (a) ¿Por qué es esencial que el piloto no tenga que cambiar cómo vuela cuando giras la válvula? (b) ¿Qué diferencia hay entre "girar la válvula en vuelo" y "recablear la potencia en el hangar"? (c) ¿Cuál de las dos corresponde a un feature flag y cuál a una config de deploy, y por qué para migrar quieres la primera?

Ver solución

(a) Porque si el piloto tuviera que cambiar cómo vuela cada vez que giras la válvula, conmutar de motor dejaría de ser transparente: cada cambio de reparto exigiría reentrenar al piloto (reescribir a los llamadores). El valor del patrón es que el punto de conmutación está debajo del piloto: él pide la misma potencia (llama a la misma abstracción) y la válvula, por su cuenta, decide de qué motor sale. En el código, checkout_total no se toca al subir el flag —esa es la propiedad callers OK? = True—.

(b) "Girar la válvula en vuelo" es un ajuste en caliente, gradual y reversible al instante: lo haces sin bajar el avión, das un poco de potencia al motor nuevo y, si tose, vuelves al viejo en el acto. "Recablear la potencia en el hangar" es un cambio fijo para todos los vuelos, que requiere bajar el avión (redeploy) y es on/off, sin gradualidad.

(c) Girar la válvula en vuelo es el feature flag (runtime, por llamada, en caliente, gradual, reversible); recablear en el hangar es la config de deploy (fija, toda la app, requiere redeploy, on/off). Para migrar quieres el flag porque te da el canary (darle poco tráfico al modern primero) y la reversión instantánea (bajarlo si falla), que son lo que hace segura la conmutación. La config te obligaría a un big-bang: prender el modern para todos a la vez y apagarlo con otro despliegue si sale mal.

Ejercicio 2 — Lee el rollout. En el ejemplo, a rollout_percent=25 el reparto fue 52 al modern y 148 al legacy sobre 200 pedidos, con callers OK? = True. (a) ¿Por qué no salió exactamente 50 y 150? (b) Si vuelves a correr el mismo código con los mismos 200 ids, ¿el reparto será igual o distinto? (c) ¿Qué significa, en términos del patrón, que callers OK?True en el nivel de 25%?

Ver solución

(a) Porque el bucket se calcula con un hash (crc32) del id, y un hash reparte los ids de forma pareja pero no milimétrica. De 200 ids, aproximadamente el 25% cae por debajo de 25, pero "aproximadamente" no es "exactamente": salieron 52. Sobre muestras más grandes la proporción se acerca más al 25%; lo que importa es que la fracción es controlable con el rollout_percent, no que sea exacta.

(b) Será exactamente igual. El bucket es determinista (crc32(str(id)) % 100): el mismo id produce siempre el mismo bucket, así que el mismo lote de ids produce siempre el mismo reparto. No hay azar; hay un hash fijo. Por eso el ejemplo es reproducible: puedes correrlo mil veces y a 25% siempre saldrán esos 52 pedidos, los mismos.

(c) Significa que, para los 52 pedidos que a 25% usan ModernShipping y los 148 que usan LegacyShipping, el resultado que ve el llamador (checkout_total) es idéntico al que vería con el legacy siempre. Es decir: darle esos 52 pedidos al modern no cambió nada de lo que el checkout devuelve —la nueva implementación produce los mismos costos que la vieja en esos casos, y el llamador, que depende de la abstracción, no notó la conmutación—. Es la propiedad central del patrón (conmutar no rompe a los llamadores) verificada en ese nivel de rollout. (Nota: aquí da True porque ModernShipping coincide con el legacy en todos estos casos; en la lección 5 veremos qué pasa cuando no coincide, y por qué la validación va antes de subir el flag.)

Ejercicio 3 — Diseña el gate de rollout. El ejemplo sube el flag de golpe a cada nivel (0, 25, 50, 75, 100) para ilustrar el reparto. En la realidad no subirías a ciegas. (a) ¿Qué condición deberías verificar antes de subir el rollout_percent de un nivel al siguiente? (b) ¿Qué harías si, con el flag en 25%, empezaras a ver errores en la implementación nueva? (c) ¿Por qué el orden correcto es "validar primero, subir el flag después" y no al revés?

Ver solución

(a) Antes de subir, deberías verificar que la implementación nueva está sana en el tráfico que ya recibe: que no lanza errores, que su latencia es aceptable y —lo central para el cálculo de envío— que coincide con el legacy en los casos que se están cotejando (el parallel-run de la lección 5 en 0 discrepancias). Un gate de rollout solo promueve al siguiente nivel si esas condiciones se cumplen; si no, se queda o baja.

(b) Bajarías el flag en el acto —a 0%, o al nivel anterior seguro— sin redeploy, porque el flag se cambia en caliente. Eso devuelve todo el tráfico al legacy y detiene el daño de inmediato (solo el 25% de los usuarios vio el problema, no el 100%, y por poco tiempo). Después investigas y arreglas ModernShipping con calma, con el flag en 0, sin presión de producción. La reversión instantánea es justo lo que un flag te da y una config de deploy no.

(c) Porque subir el flag es exponer la implementación nueva a usuarios reales. Si subes primero y validas después, cada usuario en el porcentaje conmutado es una prueba en producción sin red: si el modern difiere del legacy en su caso, ya cobró de más o de menos antes de que lo detectaras. Validar primero —con el parallel-run, sin exponer a nadie— atrapa las discrepancias antes de que lleguen a un usuario. El flag se sube solo sobre lo ya validado. Es la misma lógica del avión: primero enciendes el motor nuevo en tierra y compruebas que empuja bien; solo después le confías potencia en vuelo.

Resumen y siguiente paso

En esta lección hiciste el tercer paso de branch by abstraction: montar el feature flag como punto de conmutación. Viste, con la válvula que dirige la potencia entre los dos motores sin tocar los controles del piloto, que el flag decide qué implementación corre sin que los llamadores cambien. Y lo ejecutaste con un rollout gradual: subiste el rollout_percent del 0 al 100% sobre 200 pedidos con un bucket estable, mediste el reparto moverse de la vieja a la nueva, y verificaste callers OK? = True en los cinco niveles —la propiedad central del patrón, medida—. Aprendiste la distinción que decide cómo se hace el cambio: el flag (runtime, por llamada, en caliente, gradual, reversible) frente a la config de deploy (fija, toda la app, big-bang), y por qué el bucket estable mantiene a cada pedido y usuario en una implementación consistente.

Antes de avanzar deberías poder: explicar por qué conmutar el flag no toca a los llamadores; distinguir un flag de una config y decir por qué el patrón necesita un flag; explicar qué garantiza el bucket estable y por qué un bucket al azar rompe la consistencia por usuario; y describir el orden correcto —validar primero, subir el flag después— y qué harías si el modern fallara a mitad del rollout.

La lección 5 hace exactamente esa validación: el parallel-run para probar que el cambio es seguro. Ahora que sabes conmutar el flag, vas a aprender qué hay que verificar antes de subirlo. Vas a montar un parallel-run que llama a las dos implementaciones, compara sus resultados y reporta discrepancias —devolviendo siempre el valor del legacy, sin exponer la nueva—. Y vas a atrapar un bug real: una ModernShipping que olvidó una regla tácita del legacy, que el parallel-run delata en un caso conocido. Solo cuando la corrida dé 0 discrepancias será seguro subir el flag que acabas de montar.

Recursos

  • Martin Fowler y Pete Hodgson, "Feature Toggles (aka Feature Flags)" (2017) — martinfowler.com/articles/feature-toggles.html. La referencia sobre los tipos de flags (release, ops, experiment) y por qué un flag runtime es distinto de una config de deploy. El marco directo de esta lección. En inglés.
  • Martin Fowler, "BranchByAbstraction" (2014) — martinfowler.com/bliki/BranchByAbstraction.html. Describe cómo el flag (o toggle) conmuta entre las dos implementaciones detrás de la abstracción, y cómo se sube de forma gradual. En inglés.
  • Pete Hodgson, "Feature toggles are one of the worst kinds of technical debt" — sobre por qué los flags deben ser temporales (se quitan cuando la migración termina, lección 6) y no acumularse en el código. En inglés.
  • Jez Humble y David Farley, Continuous Delivery (Addison-Wesley, 2010) — el rol de los feature toggles para desacoplar el despliegue del código de la activación de la funcionalidad, que es lo que permite subir el flag de forma gradual e independiente del deploy. En inglés.