Mudanças de schema no GraphQL: depreciação sem versão
6 min de leitura
Uma API REST pode publicar /v2/ ao lado de /v1/ e deixar cada consumidor migrar no próprio ritmo. O GraphQL tem um schema em um endpoint, e todo client, o app mobile no build do ano passado e o dashboard interno lançado hoje de manhã, consulta o mesmo graph. Não existe uma URL para bifurcar. Depreciar um campo significa marcá-lo como depreciado no lugar, em um schema do qual todo mundo já depende, o que torna a disciplina diferente do REST mesmo que o problema de fundo, avisar quem consome que algo vai sumir, seja o mesmo que depreciação de API cobre em geral.
Como o GraphQL marca um campo como depreciado, se não há versão para incrementar?
Com a diretiva @deprecated, aplicada direto no campo:
type Product {
price: Float @deprecated(reason: "Use priceV2 for multi-currency support.")
priceV2: Money
}
O campo continua consultável. Ele não some, não retorna 404, não muda de comportamento; só carrega uma anotação legível por máquina que a maioria das ferramentas GraphQL, GraphiQL, Apollo Studio, linters de schema, vai mostrar para quem navegar pelo schema ou escrever uma query contra ele. Esse é todo o mecanismo. Não existe um endpoint de depreciação separado, nenhum header, nenhum documento acompanhante exigido pela spec, o que é ao mesmo tempo o atrativo e a armadilha: a diretiva é fácil de adicionar e fácil de ignorar, porque nada obriga um client a olhar para ela.
Alguém realmente vê o motivo da depreciação?
Só quem usa o schema diretamente, por introspecção ou um editor consciente do schema, e esse é um público menor do que os leitores comuns de um changelog de API. Um app mobile construído contra uma query há seis meses já assou aquela query no binário dele; ele vai continuar pedindo price e continuar recebendo resposta, depreciado ou não, até alguém reconstruir o app com o campo novo e lançar uma atualização. A diretiva diz para uma desenvolvedora escrevendo código novo não usar o campo antigo. Ela não faz nada pelo client que já está lançado e rodando.
| Mecanismo | Quem alcança |
|---|---|
Diretiva @deprecated | Desenvolvedoras navegando o schema ou escrevendo queries novas |
| Falhas de CI do linter de schema | O time dono do código do client, se ele rodar um |
| Uma entrada de changelog | Quem quer que a leia, incluindo um time client sem linter |
| Nada (o campo simplesmente funciona) | Um client já construído usando o campo antigo |
Um campo depreciado deveria mesmo assim ganhar uma entrada de changelog?
Sim, e ela faz mais trabalho do que a diretiva sozinha, porque um changelog alcança gente que a diretiva não alcança: um time parceiro que consome o graph sem navegar o schema dele, um client construído contra uma cópia do schema cacheada há meses, qualquer um que só notaria lendo prosa. Changelog de API cobre em geral o que uma entrada deve a quem chama; uma entrada de GraphQL deve uma coisa que o REST raramente precisa explicitar, porque quem chama REST deduz isso do número de versão: se o campo antigo ainda funciona hoje, ainda funciona com um aviso, ou de fato parou de retornar dados. A diretiva sozinha não responde nada disso para uma leitora que nunca abriu o schema.
Quando é realmente seguro remover um campo do schema?
Só quando os logs de query mostram que ninguém mais pede por ele, o que é uma pergunta de uso, não de calendário. Um campo pode carregar @deprecated por um ano e ainda ser estrutural para um client que nunca foi reconstruído; removê-lo em um cronograma fixo, como um Sunset de REST costuma fazer, quebra esse client sem nenhum aviso sobre o qual ele possa agir, porque o GraphQL não dá a ele nada sobre o que agir além da diretiva que ele nunca leu. Registre o uso em nível de campo antes de se comprometer com uma data de remoção, e trate qualquer contagem de query diferente de zero como uma pausa, não uma contagem regressiva.
Adicionar um campo carrega o mesmo risco que em uma API REST?
Estruturalmente menor, porque um client GraphQL só recebe os campos que pede explicitamente. Adicionar priceV2 ao lado de price não pode quebrar uma query existente do jeito que adicionar um campo a uma resposta JSON REST pode quebrar um deserializador estrito, porque nada obriga o client a pedir o campo novo. Adicionar um valor a um enum já existente é a exceção que vale a pena nomear no mesmo fôlego: um client que testa exaustivamente cada valor do enum, o que linguagens fortemente tipadas incentivam, quebra no momento em que um valor novo chega, independentemente de alguma query tê-lo pedido ou não. A segurança só vale para campos e membros de union que o client escolhe usar; ela não vale para um conjunto fechado que o código do client enumera à mão.
O que uma entrada de changelog do GraphQL precisa que uma entrada REST não precisa?
A forma da query, não só o nome do campo, porque “o campo price está depreciado” está sem exatamente a parte que quem chama realmente precisa: quais tipos e quais queries tocam nele. Uma entrada útil nomeia o tipo, o campo, o campo substituto e, se você conseguir gerar, as queries reais em produção que ainda pedem a forma antiga. Essa última parte, amarrar o aviso de depreciação ao uso real, é o que quem chama REST ganha de graça dos logs do servidor em uma URL e quem chama GraphQL não ganha, porque toda query bate no mesmo endpoint não importa o que peça.
Alguma coisa além de um campo pode carregar a diretiva @deprecated?
Valores de enum, usando a mesma diretiva direto na definição do valor em vez da do campo:
enum ShippingMethod {
STANDARD
EXPRESS
OVERNIGHT @deprecated(reason: "Use EXPRESS with priority: true instead.")
}
A spec define @deprecated para exatamente dois lugares, a definição de um campo ou um valor de enum, e nada além disso na versão estável; a depreciação em nível de argumento ou de input-field existe só em linguagem de draft mais recente, não no que a maioria dos servidores implementa hoje. Um valor de enum marcado assim continua sendo um valor legal que um servidor ainda pode retornar ou aceitar, a mesma promessa de não quebrar nada que um campo depreciado faz, o que é o que torna seguro lançar isso antes de de fato remover o valor.
FAQ
O GraphQL suporta algo como um header Sunset para um endpoint inteiro?
Não, porque geralmente só existe um endpoint. O timing da depreciação vive em nível de campo, no texto de motivo da diretiva @deprecated e em qualquer changelog ou guia de migração que um time publique junto, não em um header de resposta que um client possa ler programaticamente.
Um campo depreciado pode ser removido e depois readicionado com um tipo diferente?
Só como um nome de campo novo. Reintroduzir o mesmo nome de campo com um tipo mudado é exatamente a mudança que quebra algo que o ciclo de depreciação existe para evitar; dê ao substituto o próprio nome, como priceV2 faz, e deixe o antigo se extinguir completamente antes do nome ficar livre para reuso.
O texto de motivo do @deprecated deveria linkar para a entrada de changelog?
Sim, quando as ferramentas de schema suportarem isso. O campo de motivo aceita uma string simples, e uma URL dentro dessa string é o caminho mais curto de uma desenvolvedora encarando a saída de introspecção até a explicação mais completa que uma entrada de changelog pode dar.
Uma mudança de schema do GraphQL é alguma vez compatível com versões anteriores de um jeito que o REST não é? Mudanças aditivas de campo, sim, pelo motivo acima: clients só recebem o que pedem. Novos valores de enum são a exceção, porque um client que enumera um conjunto fechado pode quebrar com um valor que não esperava. Remoções e mudanças de tipo são exatamente tão quebradoras quanto seus equivalentes REST.
As afirmações técnicas deste artigo não foram revisadas de forma independente. Se algo estiver errado, avise a gente e vamos corrigir.