Saltar al contenido principal
Escritos
7 min de lectura

Cómo lanzar una feature nueva sin romper a quien sigue en la versión antigua

Todo release de una app móvil convive con versiones antiguas que siguen instaladas. El gate de versión garantiza que la feature nueva solo aparezca donde existe, y que la app vieja siga entera sin depender de que todos actualicen en el mismo segundo.

  • Mobile
  • Versionado de API
  • Feature Flag
  • Release

Voy a empezar por el contexto, porque este problema solo se vuelve obvio después de que te muerde una vez.

Cuando tienes un sitio web, el deploy es simple: subiste la versión nueva y todo el mundo pasa a verla en el próximo refresh. La antigua deja de existir. En una app móvil no funciona así. Publicas el build nuevo en la tienda, pero hay gente que sigue en el anterior durante semanas, a veces meses. Actualizar es decisión del usuario, no tuya. Es decir: el día en que lanzas la feature nueva, hay varias versiones de tu app corriendo al mismo tiempo, todas pegándole a la misma API.

Ahí está la trampa. La feature nueva casi nunca es solo una pantalla. Toca el backend: un campo nuevo en el payload, un endpoint que cambió de forma, una regla que ahora espera un dato que la versión antigua ni siquiera sabe que existe. Si tratas "lanzar la feature" como "subir el backend y publicar la app", acabas de asumir que todo el mundo actualizó en el mismo segundo. Y no actualizó.

La pregunta correcta no es "cuándo lo enciendo", es "dónde"

La idea del gate de versión es simple de enunciar y fácil de equivocar: la feature nueva solo puede aparecer donde realmente existe. No es un flag de "enciéndelo para todos". Es una puerta que pregunta, a cada cliente que llega, si ese cliente está en condiciones de cruzarla.

Y ese "está en condiciones" tiene dos mitades que solemos confundir:

  1. ¿El usuario está en una versión de la app que sabe renderizar esto?
  2. ¿Ese usuario debería verlo ahora? (rollout gradual, plan, región, lo que sea)

La segunda mitad es el feature flag clásico, el que casi todo el mundo ya conoce. La primera es el gate de versión, y es la que mantiene entera la app antigua. Son cosas distintas, y tratarlas como si fueran la misma es la raíz de la mitad de los bugs de release en móvil.

Quien manda en la puerta es el backend

Un error común es dejar la decisión solo en el cliente: "si la versión de la app es mayor o igual a X, muéstralo". El problema es que, cuando necesites frenar el lanzamiento, porque apareció un bug o porque el rollout se va a posponer, no tienes cómo. La regla está congelada dentro de builds que ya están en manos de la gente.

Por eso la decisión vive en el backend, y la app solo obedece. En la práctica, el cliente dice quién es y el servidor responde qué puede hacer:

// La app envía su propia versión en cada request.
// Header: X-App-Version: 42

type Capabilities = {
  confirmarLeituraEscopo: boolean;
  chatInApp: boolean;
};

function capabilitiesFor(appVersion: number): Capabilities {
  return {
    // La pantalla de confirmación de lectura del alcance solo existe desde el build 42.
    confirmarLeituraEscopo: appVersion >= 42,
    // El chat entró en la 45, pero sigue en rollout: el flag decide el "ahora".
    chatInApp: appVersion >= 45 && flags.isEnabled("chat"),
  };
}

La app, por su parte, no decide nada sobre versiones. Pregunta y dibuja lo que llegó:

const caps = await api.getCapabilities();

if (caps.confirmarLeituraEscopo) {
  return <ConfirmacaoDeEscopo />;
}

return <FluxoDeAtendimentoAntigo />;

Fíjate en el punto que lo cambia todo: la versión antigua de la app no tiene el <ConfirmacaoDeEscopo />. Ni siquiera sabe que existe. Entonces el gate, para ella, no es "esconder la feature nueva": es simplemente no recibir nunca un true con el que no sabría qué hacer. El backend lo garantiza porque conoce la versión que está preguntando.

La regla que lo resume todo: la app vieja no necesita la puerta

Esta es la parte que más veo invertir, así que voy a ser bien directo.

El gate existe en la versión nueva, para proteger la transición. En la versión antigua no hace falta que exista, y no debe existir. La versión antigua ya es, por definición, el comportamiento antiguo. No vuelves al build 40 para agregar un if que esconde una pantalla que el build 40 nunca tuvo. Eso es energía gastada en un código que va a morir solo cuando todos actualicen.

Es decir: no agregas el gate en el pasado. Lo agregas en el presente, apuntando al futuro. El build nuevo sabe convivir con un backend que puede decir "todavía no". El build viejo solo necesita seguir siendo él mismo.

El contrato de la API es donde esto vive o muere

Todo esto se desmorona si el backend rompe la forma del payload creyendo que "todos ya actualizaron". La disciplina es aburrida pero corta: un campo nuevo es siempre aditivo. Agregas; nunca quitas ni renombras mientras exista una versión viva que dependa del formato antiguo.

Un ejemplo práctico. Si el flujo nuevo necesita un objeto escopo, entra como opcional:

{
  "id": 1234,
  "status": "a_caminho",
  "escopo": { "exigeConfirmacao": true, "texto": "Checar EPI antes de iniciar" }
}

La versión antigua recibe ese mismo payload y simplemente ignora el campo que no conoce. Nadie se rompe. Y la lista de lo que nunca se hace mientras haya una app vieja viva es corta e innegociable: no renombras un campo, no quitas un campo, no cambias el tipo y, lo peor de todo porque falla en silencio, no cambias el significado de un valor manteniendo el nombre.

¿Y cuando lo aditivo no alcanza?

A veces el cambio es demasiado grande para caber en un campo opcional: la respuesta entera necesita otra forma. Entonces no tocas el endpoint antiguo. Creas uno nuevo, versionado, y dejas los dos vivos al mismo tiempo:

GET /v1/atendimentos   → formato antiguo, sigue en pie para quien todavía lo usa
GET /v2/atendimentos   → formato nuevo, para las apps nuevas

El detalle que mucha gente olvida: subir la v2 no te da derecho a matar la v1. La v1 solo muere cuando ya nadie le pega, y "nadie" aquí significa ninguna app instalada, no ninguna app en la tienda. Son cosas distintas, y por eso la última pieza es medir.

Cuando la puerta no alcanza: jubilar la versión de una vez

Sostener la compatibilidad cuesta. En algún momento el código se llena de if de versión, o aparece un problema de seguridad en un build antiguo, y necesitas que esa versión simplemente deje de existir. Para eso existe el force update, y tiene que venir incluido desde el primer build; si no, nunca alcanzas a quien se quedó atrás.

La mecánica es que la app pregunte, apenas arranca, cuál es el piso:

GET /app-config
{
  "minSupportedVersion": 40,
  "latestVersion": 47
}

Y la propia app compara su build con ese piso:

  • Por debajo de minSupportedVersion → pantalla bloqueante de "actualiza para continuar". Sin escapatoria, porque esa versión realmente ya no corre.
  • Entre el piso y la última → un empujoncito opcional, como un banner de "hay una versión nueva", sin bloquear.

Es el tipo de cosa que uno pone en el scaffold y reza para no tener que usar nunca. Pero cuando hace falta, es la única salida que no depende de la buena voluntad del usuario.

Cómo decido, y cómo mido

No todo lanzamiento merece esta ceremonia. La pregunta que hago es una sola: si la mitad de mis usuarios no actualiza hoy, ¿algo se rompe o queda raro? Si la respuesta es no, cambié un texto o ajusté un espaciado, se lanza y se sigue. Si la respuesta es sí, toqué el contrato, la navegación o un flujo crítico, entonces hay gate, hay rollout y el backend manda en la puerta.

Y todo esto solo funciona si conoces la distribución de versiones de tu base. Como la app ya manda el X-App-Version en cada request, ese dato viene gratis: basta con registrarlo y agruparlo. Con él en la mano, las decisiones dejan de ser adivinanza:

  • "¿Puedo matar la v1 del endpoint?" → mira cuántas apps todavía le pegan. ¿0,2 %, muerta hace un año? Puedes. ¿15 %? Calma.
  • "¿Qué minSupportedVersion pongo?" → el número que jubila a la menor cantidad de gente posible para resolver tu problema.
  • "¿La feature prendió?" → cruza la adopción de la feature con la adopción de la versión que la trajo.

Sin ese número, "jubilar la versión antigua" es una apuesta. Con él, es una decisión.

Para cerrar

El gate de versión no es sofisticación. Es reconocer una verdad del mundo móvil que la web nos deja olvidar: nunca controlas cuándo actualiza el otro lado. Solo controlas lo que recibe.

Si tuviera que dejarlo en cuatro frases:

  1. La pregunta es "dónde", no "cuándo". El gate de versión pregunta si el cliente sabe renderizar; el feature flag pregunta si debería verlo ahora. Son dos cosas.
  2. Quien manda en la puerta es el backend. La regla no puede vivir en un código que ya está congelado en manos del usuario.
  3. La app vieja no necesita la puerta. Agregas el gate en el presente apuntando al futuro, nunca en el pasado.
  4. El contrato solo crece, y mides quién lo sigue usando. Campo aditivo por defecto, /v2 cuando no alcance, force update cuando haya que jubilar de una vez, siempre mirando la distribución de versiones.

Nada de esto es complicado. Lo difícil es acordarse, en medio de una feature nueva, de que aquel build de atrás sigue vivo en manos de alguien, y de que también es tu responsabilidad. Ese es el verdadero truco del versionado en móvil.

¿Tienes un sistema que necesita sobrevivir al crecimiento?

Ese es el tipo de decisión que ayudo a tomar. Si encaja con tu momento, hablemos.