- Mobile
- Versionamento de API
- Feature Flag
- Release
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.
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?