Evoluindo o schema via versionamento de campos
À medida que as necessidades da nossa aplicação evoluem, a API GraphQL que fornece dados para ela também precisará evoluir, introduzindo mudanças em seu schema. Sempre que a mudança não for uma breaking change, como ao adicionar um novo tipo ou campo, podemos aplicá-la diretamente sem temer efeitos colaterais. Mas quando a mudança é uma breaking change, precisamos garantir que não estamos introduzindo bugs ou comportamentos inesperados na aplicação.
Breaking changes são aquelas que removem um tipo, campo ou diretiva, ou modificam a assinatura de um campo (ou diretiva) já existente, como:
- Renomear um campo
- Alterar o tipo de um argumento de campo existente, ou torná-lo obrigatório
- Adicionar um novo argumento obrigatório ao campo
- Adicionar non-nullable ao tipo de resposta de um campo
Para lidar com breaking changes, existem duas estratégias principais: versionamento e evolução, conforme implementadas pelo REST e GraphQL, respectivamente.
APIs REST indicam a versão da API a ser utilizada na URL do endpoint (como https://api.mycompany.com/v1 ou https://api-v1.mycompany.com) ou por meio de algum header (como Accept-version: v1). Por meio do versionamento, breaking changes são adicionadas a uma nova versão da API, e como os clientes precisam apontar explicitamente para a nova versão da API, eles estarão cientes das mudanças.
GraphQL não descarta o uso de versionamento, mas incentiva o uso da evolução. Conforme indicado na página de melhores práticas do GraphQL:
While there's nothing that prevents a GraphQL service from being versioned just like any other REST API, GraphQL takes a strong opinion on avoiding versioning by providing the tools for the continuous evolution of a GraphQL schema.
A evolução se comporta de forma diferente, pois não se espera que aconteça uma vez a cada poucos meses, como ocorre com o versionamento. Em vez disso, é um processo contínuo, que ocorre até diariamente se necessário, o que a torna mais adequada para iteração rápida. Essa abordagem foi formulada pelo Principled GraphQL, um conjunto de melhores práticas para guiar o desenvolvimento de um serviço GraphQL, em seu quinto princípio:
5. Use an Agile Approach to Schema Development: The schema should be built incrementally based on actual requirements and evolve smoothly over time
Evoluindo o schema
Por meio da evolução, campos com breaking changes devem passar pelo seguinte processo:
- Reimplementar o campo usando um nome diferente.
- Deprecar o campo, solicitando que os clientes usem o novo campo no lugar.
- Sempre que o campo não for mais utilizado por ninguém, removê-lo do schema.
Vejamos um exemplo. Suponha que temos um tipo Account, modelando uma conta para uma pessoa com nome e sobrenome por meio deste schema (usando o SDL do GraphQL - Schema Definition Language):
type Account {
id: Int
name: String!
surname: String!
}Neste schema, tanto o campo name quanto o campo surname são obrigatórios (é o símbolo ! adicionado após o tipo String) pois esperamos que todas as pessoas tenham tanto um nome quanto um sobrenome.
Com o tempo, também permitimos que organizações abram contas. Organizações, porém, não têm sobrenome, então precisamos alterar a assinatura do campo surname para torná-lo não obrigatório:
type Account {
id: Int
name: String!
surname: String # Isso mudou
}Esta é uma breaking change porque a aplicação não espera que o campo surname retorne null, portanto pode não verificar essa condição, como ao executar este código JavaScript:
// Isso vai falhar quando account.surname for null
const upperCaseSurname = account.surname.toUpperCase();Os potenciais bugs resultantes de breaking changes podem ser evitados evoluindo o schema:
- Não modificamos a assinatura do campo
surname; em vez disso, o marcamos como depreciado, adicionando uma mensagem útil indicando o nome do campo que o substitui - Introduzimos um novo nome de campo
personSurname(ouaccountSurname) no schema
Nosso tipo Account agora fica assim:
type Account {
id: Int
name: String!
surname: String! @deprecated(reason: "Use `personSurname`")
personSurname: String
}Por fim, coletando logs das queries dos nossos clientes, podemos analisar se eles fizeram a transição para o novo campo. Sempre que notarmos que o campo surname não é mais utilizado por ninguém, podemos então removê-lo do schema:
type Account {
id: Int
name: String!
personSurname: String
}Problemas com a evolução
O exemplo descrito acima é muito simples, mas já demonstra alguns problemas potenciais ao evoluir o schema:
| Problema | Descrição |
|---|---|
| Os nomes dos campos se tornam menos elegantes | Na primeira vez que nomeamos o campo, possivelmente encontraremos o nome ideal para ele, como surname. Quando precisamos substituí-lo, porém, precisaremos criar um nome diferente que pode ser subótimo (o ideal já foi utilizado!). Todas as possíveis substituições no exemplo acima apresentam problemas:- personName deixa explícito que a conta é para uma pessoa, então se, mais tarde, precisarmos abrir uma conta para um não-humano com sobrenome (sei lá... um marciano?), então precisaremos evoluir o schema novamente para manter nomes consistentes- O trecho "account" em accountName é completamente redundante, pois o tipo já é Account- Caso contrário, que outro nome usar? surname1? surnameNew? Ou ainda pior, surnameV2?Como consequência, o schema atualizado será menos compreensível e mais verboso. |
| O schema pode acumular campos depreciados | Depreciar campos faz mais sentido como uma circunstância temporária; eventualmente, gostaríamos realmente de remover esses campos do schema para limpá-lo antes que comecem a se acumular. No entanto, pode haver clientes que não revisam suas queries e ainda buscam informações do campo depreciado. Nesse caso, nosso schema lentamente mas continuamente se tornará uma espécie de cemitério de campos, acumulando vários campos diferentes para a mesma funcionalidade. |
Vejamos como resolver esses problemas.
Versionamento de campos
Podemos criar nosso campo com um argumento chamado version, por meio do qual especificamos qual versão do campo utilizar.
Neste cenário, ainda precisaremos manter a implementação para o campo depreciado, portanto não estamos melhorando nesse aspecto. No entanto, seu contrato fica oculto: o novo campo agora pode manter seu nome original (não é necessário renomeá-lo de surname para personSurname), evitando que nosso schema se torne muito verboso.
Observe que esse conceito de versionamento é diferente do REST:
- REST estabelece uma situação tudo-ou-nada em que toda a API consultada tem a mesma versão, pois a versão a ser utilizada faz parte do endpoint
- Nesta outra abordagem, cada campo é versionado de forma independente
Portanto, podemos acessar versões diferentes para campos diferentes, desta forma:
query GetPosts {
posts(version: "1.0.0") {
id
title(version: "2.1.1")
url
author {
id
name(version: "1.5.3")
}
}
}Além disso, baseando-nos no semantic versioning, podemos usar os constraints de versão para escolher a versão, seguindo as mesmas regras utilizadas pelo Composer para declarar dependências de pacotes. Em seguida, renomeamos o argumento de campo version para versionConstraint e atualizamos a query:
query GetPosts {
posts(versionConstraint: "^1.0") {
id
title(versionConstraint: ">=2.1")
url
author {
id
name(versionConstraint: "~1.5.3")
}
}
}Aplicando essa estratégia ao nosso campo depreciado surname, podemos agora marcar a implementação depreciada como versão "1.0.0" e a nova implementação como versão "2.0.0" e acessar ambas, até na mesma query:
query GetSurname {
account(id: 1) {
oldVersion: surname(versionConstraint: "^1.0")
newVersion: surname(versionConstraint: "^2.0")
}
}Este recurso está disponível no Gato GraphQL:

Versionamento de directives
Como as directives também recebem argumentos, podemos implementar exatamente a mesma metodologia para versionar directives também!
Por exemplo, ao executar esta query:
query {
post(by: { id: 1 }) {
oldVersion: title @strTitleCase(versionConstraint: "^0.1")
newVersion: title @strTitleCase(versionConstraint: "^0.2")
}
}Ela pode produzir uma resposta diferente para cada versão da diretiva:
