Conceptos, ideas, estrategias
Conceptos, ideas, estrategiasEvolucionar el esquema mediante el versionado de campos

Evolucionar el esquema mediante el versionado de campos

A medida que evolucionan las necesidades de nuestra aplicación, la API GraphQL que la alimenta de datos también necesitará evolucionar, introduciendo cambios en su esquema. Siempre que el cambio sea no disruptivo, como añadir un nuevo tipo o campo, podemos aplicarlo directamente sin temer efectos secundarios. Pero cuando el cambio es disruptivo, debemos asegurarnos de no introducir errores o comportamientos inesperados en la aplicación.

Los cambios disruptivos son aquellos que eliminan un tipo, campo o directiva, o modifican la firma de un campo (o directiva) ya existente, como:

  • Renombrar un campo
  • Cambiar el tipo de un argumento de campo existente, o hacerlo obligatorio
  • Añadir un nuevo argumento obligatorio al campo
  • Añadir non-nullable al tipo de respuesta de un campo

Para tratar los cambios disruptivos, hay dos estrategias principales: versionado y evolución, implementadas por REST y GraphQL, respectivamente.

Las APIs REST indican la versión de la API a usar bien en la URL del endpoint (como https://api.mycompany.com/v1 o https://api-v1.mycompany.com) o mediante alguna cabecera (como Accept-version: v1). Mediante versionado, los cambios disruptivos se añaden a una nueva versión de la API, y como los clientes deben apuntar explícitamente a la nueva versión de la API, serán conscientes de los cambios.

GraphQL no descarta el uso de versionado, pero recomienda usar evolución. Como se afirma en la página de GraphQL best practices:

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.

La evolución se comporta de forma diferente en cuanto a que no se espera que tenga lugar una vez cada pocos meses, como sí sucede con el versionado. Más bien, es un proceso continuo, que tiene lugar incluso a diario si es necesario, lo que la hace más adecuada para la iteración rápida. Este enfoque ha sido establecido por Principled GraphQL, un conjunto de buenas prácticas para guiar el desarrollo de un servicio GraphQL, en su quinto principio:

5. Use an Agile Approach to Schema Development: The schema should be built incrementally based on actual requirements and evolve smoothly over time

Evolucionar el esquema

Mediante la evolución, los campos con cambios disruptivos deben pasar por el siguiente proceso:

  1. Reimplementar el campo utilizando un nombre diferente.
  2. Deprecar el campo, solicitando a los clientes que usen el nuevo campo en su lugar.
  3. Cuando el campo ya no sea utilizado por nadie, eliminarlo del esquema.

Veamos un ejemplo. Supongamos que tenemos un tipo Account, modelando una cuenta para que sea una persona con un nombre y un apellido mediante este esquema (utilizando el SDL de GraphQL - Schema Definition Language):

type Account {
  id: Int
  name: String!
  surname: String!
}

En este esquema, tanto los campos name como surname son obligatorios (eso es el símbolo ! añadido tras el tipo String) ya que esperamos que todas las personas tengan tanto un nombre como un apellido.

Eventualmente, también permitimos a las organizaciones abrir cuentas. Las organizaciones, sin embargo, no tienen apellido, por lo que debemos cambiar la firma del campo surname para hacerlo no obligatorio:

type Account {
  id: Int
  name: String!
  surname: String # Esto ha cambiado
}

Este es un cambio disruptivo porque la aplicación no espera que el campo surname devuelva null, por lo que puede no comprobar esta condición, como al ejecutar este código JavaScript:

// Esto fallará cuando account.surname sea null
const upperCaseSurname = account.surname.toUpperCase();

Los posibles errores resultantes de cambios disruptivos pueden evitarse evolucionando el esquema:

  • No modificamos la firma del campo surname; en su lugar, lo marcamos como deprecado, añadiendo un mensaje útil que indica el nombre del campo que lo reemplaza
  • Introducimos un nuevo nombre de campo personSurname (o accountSurname) al esquema

Nuestro tipo Account ahora se ve así:

type Account {
  id: Int
  name: String!
  surname: String! @deprecated(reason: "Use `personSurname`")
  personSurname: String
}

Finalmente, recogiendo logs de las consultas de nuestros clientes, podemos analizar si han hecho el cambio al nuevo campo. Cuando observemos que el campo surname ya no es usado por nadie, podemos entonces eliminarlo del esquema:

type Account {
  id: Int
  name: String!
  personSurname: String
}

Problemas con la evolución

El ejemplo descrito arriba es muy simple, pero ya demuestra un par de problemas potenciales de evolucionar el esquema:

ProblemaDescripción
Los nombres de campo se vuelven menos pulcrosLa primera vez que nombramos el campo, posiblemente encontraremos el nombre óptimo para él, como surname. Cuando necesitamos reemplazarlo, sin embargo, tendremos que crear un nombre diferente para él que puede ser subóptimo (¡el óptimo ya está cogido!). Todos los posibles reemplazos en el ejemplo de arriba tienen problemas:

- personName hace explícito que la cuenta es para una persona, así que si más adelante debemos abrir una cuenta para un no-persona con un apellido (no lo sé... ¿un marciano?), entonces tendremos que evolucionar el esquema de nuevo para mantener nombres consistentes
- El fragmento "account" en accountName es completamente redundante ya que el tipo ya es Account
- En caso contrario, ¿qué otro nombre usar? surname1? surnameNew? O peor aún, surnameV2?

Como consecuencia, el esquema actualizado será menos comprensible y más verboso.
El esquema puede acumular campos deprecadosDeprecar campos es más sensato como una circunstancia temporal; eventualmente, nos gustaría realmente eliminar esos campos del esquema para limpiarlo antes de que empiecen a acumularse.

Sin embargo, puede haber clientes que no revisen sus consultas y sigan obteniendo información del campo deprecado. En este caso, nuestro esquema se convertirá lenta pero firmemente en una especie de cementerio de campos, acumulando varios campos diferentes para la misma funcionalidad.

Veamos cómo resolver estos problemas.

Versionado de campos

Podemos crear nuestro campo con un argumento llamado version, mediante el cual especificamos qué versión del campo usar.

En este escenario, todavía tendremos que mantener la implementación para el campo deprecado, por lo que no estamos mejorando en esa preocupación. Sin embargo, su contrato se vuelve oculto: el nuevo campo ahora puede mantener su nombre original (no hay necesidad de renombrarlo de surname a personSurname), evitando que nuestro esquema se vuelva demasiado verboso.

Por favor, ten en cuenta que este concepto de versionado es diferente al de REST:

  • REST establece una situación de todo-o-nada en la que toda la API consultada tiene la misma versión ya que la versión a usar es parte del endpoint
  • En este otro enfoque, cada campo se versiona independientemente

Por tanto, podemos acceder a diferentes versiones para diferentes campos, así:

query GetPosts {
  posts(version: "1.0.0") {
    id
    title(version: "2.1.1")
    url
    author {
      id
      name(version: "1.5.3")
    }
  }
}

Además, basándonos en semantic versioning, podemos usar las version constraints para elegir la versión, siguiendo las mismas reglas usadas por Composer para declarar dependencias de paquetes. Entonces, renombramos el argumento de campo version a versionConstraint y actualizamos la consulta:

query GetPosts {
  posts(versionConstraint: "^1.0") {
    id
    title(versionConstraint: ">=2.1")
    url
    author {
      id
      name(versionConstraint: "~1.5.3")
    }
  }
}

Aplicando esta estrategia a nuestro campo deprecado surname, podemos ahora etiquetar la implementación deprecada como versión "1.0.0" y la nueva implementación como versión "2.0.0" y acceder a ambas, incluso en la misma consulta:

query GetSurname {
  account(id: 1) {
    oldVersion: surname(versionConstraint: "^1.0")
    newVersion: surname(versionConstraint: "^2.0")
  }
}

Esta característica está disponible en Gato GraphQL:

Consultando campos mediante version constraints

Versionado de directivas

¡Como las directivas también reciben argumentos, podemos implementar exactamente la misma metodología para versionar directivas también!

Por ejemplo, al ejecutar esta consulta:

query {
  post(by: { id: 1 }) {
    oldVersion: title @strTitleCase(versionConstraint: "^0.1")
    newVersion: title @strTitleCase(versionConstraint: "^0.2")
  }
}

Puede producir una respuesta diferente para cada versión de la directiva:

Consultando una directiva versionada