Como lançar uma feature nova sem quebrar quem está na versão antiga
Toda release de app mobile convive com versões antigas ainda instaladas. O gate de versão garante que a feature nova só apareça onde ela existe — e que o app velho continue inteiro, sem depender de todo mundo atualizar no mesmo segundo.
- Mobile
- Versionamento de API
- Feature Flag
- Release
Vou começar pelo contexto, porque esse problema só fica óbvio depois que ele te morde uma vez.
Quando você tem um site, deploy é simples: subiu a versão nova, todo mundo passa a ver a versão nova no próximo refresh. A antiga deixa de existir. Em app mobile não funciona assim. Você publica a build nova na loja, mas tem gente que continua na anterior por semanas, às vezes meses. Atualizar é decisão do usuário, não sua. Ou seja: no dia em que você lança a feature nova, tem várias versões do seu app rodando ao mesmo tempo, todas batendo na mesma API.
É aí que mora a armadilha. A feature nova quase nunca é só tela. Ela mexe no backend: um campo novo no payload, um endpoint que mudou de forma, uma regra que agora espera um dado que a versão antiga nem sabe que existe. Se você trata "lançar a feature" como "subir o backend e publicar o app", você acabou de assumir que todo mundo atualizou no mesmo segundo. E não atualizou.
A pergunta certa não é "quando ligo", é "onde"
A ideia do gate de versão é simples de enunciar e fácil de errar: a feature nova só pode aparecer onde ela realmente existe. Não é um flag de "liga pra todo mundo". É um portão que pergunta, pra cada cliente que chega, se aquele cliente tem condição de atravessar.
E o "tem condição" tem duas metades que a gente costuma confundir:
- O usuário está numa versão do app que sabe renderizar isso?
- Esse usuário deveria ver isso agora? (rollout gradual, plano, região, o que for)
A segunda metade é o feature flag clássico — a que quase todo mundo já conhece. A primeira é o gate de versão, e é ela que segura o app antigo inteiro. São coisas diferentes, e tratar as duas como se fossem a mesma é a raiz de metade dos bugs de release no mobile. Deu pra sentir a diferença?
Quem manda no portão é o backend
Um erro comum é deixar a decisão só no cliente: "se a versão do app for maior ou igual a X, mostra". O problema é que, quando você precisar segurar o lançamento — porque apareceu um bug, porque o rollout vai ser adiado —, você não tem como. A regra está congelada dentro de builds que já estão na mão das pessoas.
Por isso a decisão vive no backend, e o app só obedece. Na prática, o cliente manda quem ele é, e o servidor responde o que ele pode:
// O app envia a própria versão em toda requisição.
// Header: X-App-Version: 42
type Capabilities = {
confirmarLeituraEscopo: boolean;
chatInApp: boolean;
};
function capabilitiesFor(appVersion: number): Capabilities {
return {
// A tela de confirmação de leitura do escopo só existe a partir da build 42.
confirmarLeituraEscopo: appVersion >= 42,
// O chat entrou na 45, mas ainda está em rollout: o flag decide o "agora".
chatInApp: appVersion >= 45 && flags.isEnabled("chat"),
};
}
O app, do lado dele, não decide nada sobre versão. Ele pergunta e desenha o que veio:
const caps = await api.getCapabilities();
if (caps.confirmarLeituraEscopo) {
return <ConfirmacaoDeEscopo />;
}
return <FluxoDeAtendimentoAntigo />;
Repare no ponto que muda tudo: a versão antiga do app não tem o <ConfirmacaoDeEscopo />. Ela nem sabe que ele existe. Então o gate, pra ela, não é "esconder a feature nova": é só nunca receber um true que ela não saberia o que fazer com ele. O backend garante isso porque conhece a versão que está perguntando.
A regra que resume tudo: o app velho não precisa do portão
Essa é a parte que eu mais vejo gente inverter, então deixa eu ser bem direto.
O gate existe na versão nova, pra proteger a transição. Na versão antiga, ele não precisa existir, e não deve. A versão antiga já é, por definição, o comportamento antigo. Você não volta na build 40 pra adicionar um if que esconde uma tela que a build 40 nunca teve. Isso é energia gasta num código que vai morrer sozinho quando todo mundo atualizar.
Ou seja: você não adiciona gate no passado. Você adiciona gate no presente, mirando no futuro. A build nova sabe conviver com um backend que pode dizer "ainda não". A build velha só precisa continuar sendo ela mesma.
O contrato de API é onde isso vive ou morre
Tudo isso desmorona se o backend quebrar a forma do payload achando que "todo mundo já atualizou". A disciplina é chata mas curta: campo novo é sempre aditivo. Você adiciona, nunca remove nem renomeia enquanto existir versão viva que dependa do formato antigo.
Um exemplo prático. Se o fluxo novo precisa de um objeto escopo, ele entra como opcional:
{
"id": 1234,
"status": "a_caminho",
"escopo": { "exigeConfirmacao": true, "texto": "Checar EPI antes de iniciar" }
}
A versão antiga recebe esse mesmo payload e simplesmente ignora o campo que não conhece. Ninguém quebra. E a lista do que nunca se faz enquanto tem app velho vivo é curta e inegociável: não renomeia campo, não remove campo, não muda o tipo, e — o pior de todos, porque falha calado — não muda o significado de um valor mantendo o nome.
E quando aditivo não basta?
Às vezes a mudança é grande demais pra caber num campo opcional: a resposta inteira precisa de outra forma. Aí você não mexe no endpoint antigo — cria um novo, versionado, e deixa os dois vivos ao mesmo tempo:
GET /v1/atendimentos → formato antigo, continua de pé pra quem ainda usa
GET /v2/atendimentos → formato novo, pros apps novos
O detalhe que muita gente esquece: subir a v2 não te dá o direito de matar a v1. A v1 só morre quando ninguém mais bate nela — e "ninguém" aqui é nenhum app instalado, não nenhum app na loja. São coisas diferentes, e é por isso que a última peça é medir.
Quando o portão não basta: aposentar a versão de vez
Segurar compatibilidade custa. Uma hora o código enche de if de versão, ou aparece um problema de segurança numa build antiga, e você precisa que aquela versão simplesmente pare de existir. Pra isso existe o force update, e ele precisa estar embarcado desde a primeira build — senão você nunca alcança quem está lá atrás.
A mecânica é o app perguntar, logo no start, qual é o piso:
GET /app-config
{
"minSupportedVersion": 40,
"latestVersion": 47
}
E o próprio app compara a build dele com esse piso:
- Abaixo de
minSupportedVersion→ tela bloqueante de "atualize pra continuar". Sem escapatória, porque aquela versão realmente não roda mais. - Entre o piso e a última → um empurrãozinho opcional, tipo um banner "tem versão nova", sem bloquear.
É o tipo de coisa que a gente coloca no scaffold e reza pra nunca precisar usar. Mas quando precisa, é a única saída que não depende da boa vontade do usuário.
Como eu decido — e como eu meço
Não é todo lançamento que merece essa cerimônia. A pergunta que eu faço é uma só: se metade dos meus usuários não atualizar hoje, alguma coisa quebra ou fica estranha? Se a resposta é não — mudei um texto, ajustei um espaçamento —, lança e segue. Se a resposta é sim — mexi no contrato, na navegação, num fluxo crítico —, então tem gate, tem rollout, e o backend manda no portão.
E tudo isso só funciona se você souber a distribuição de versões da sua base. Como o app já manda o X-App-Version em todo request, esse dado vem de graça: é só registrar e agrupar. Com ele na mão, as decisões deixam de ser chute:
- "Posso matar a v1 do endpoint?" → olha quantos apps ainda batem nela. 0,2% morto há um ano? Pode. 15%? Calma.
- "Qual
minSupportedVersioneu coloco?" → o número que aposenta o mínimo de gente possível pra resolver o seu problema. - "A feature pegou?" → cruza a adoção da feature com a adoção da versão que a trouxe.
Sem esse número, "aposentar a versão antiga" é aposta. Com ele, é decisão.
Fechando
Gate de versão não é sofisticação, é reconhecer uma verdade do mobile que o mundo web deixa a gente esquecer: você nunca controla quando o outro lado atualiza. Você só controla o que ele recebe.
Se eu tivesse que deixar isso em quatro frases:
- A pergunta é "onde", não "quando". O gate de versão pergunta se o cliente sabe renderizar; o feature flag pergunta se ele deveria ver agora. São duas coisas.
- Quem manda no portão é o backend. A regra não pode morar num código que já está congelado na mão do usuário.
- O app velho não precisa do portão. Você adiciona gate no presente mirando o futuro, nunca no passado.
- O contrato só cresce, e você mede quem ainda usa. Campo aditivo por padrão,
/v2quando não der, force update quando precisar aposentar de vez — sempre olhando a distribuição de versões.
Nada disso é complicado. O difícil é lembrar, no calor de uma feature nova, que aquela build lá atrás ainda está viva na mão de alguém — e que ela também é sua responsabilidade. É esse o pulo do gato do versionamento no mobile. Deu pra entender a ideia?