Interface hierarchies and schema evolution
November 04, 2019
Soon GraphQL will allow Interfaces to implement other Interfaces.
See this PR for the GraphQL specification: https://github.com/graphql/graphql-spec/pull/373/
Interface hierarchies
This means this schema which models a simple hierarchy will be valid:
interface Node {
id: ID!
}
# This is new: we can implement other interfaces
interface NamedNode implements Node {
id: ID!
name: String
}
# We need to name ALL interfaces
# including transitive ones like Node
type Movie implements NamedNode & Node {
id: ID!
name: String
directors: [Director!]
}
type Director implements NamedNode & Node {
id: ID!
name: String
}
This is straight forward. The only surprising aspect of it is probably that we need to declare all interfaces a type (or interface) implements.
We can’t just write this:
# This is an invalid declaration
type Movie implements NamedNode {
id: ID!
name: String
directors: [Director!]
}
We need to declare all transitive interfaces an object implements. In this example NamedNode
does also implements Node
which means we need to add Node
to the list of interfaces Movie
implements. (as shown above)
While it seems a bit verbose and unnecessary at first it simplifies some internal aspects of GraphQL and keeps the schema definition very explicit: we can see all interfaces an object implements by just looking at the object declaration. (See the spec PR for more details why it was decided this way)
Relations between interfaces
As seen above we can now model hierarchies of interfaces instead of only a flat list of interfaces.
Combined with covariant field declarations this allows us to express relations between two interfaces more clear:
interface Animal {
relatives: [Animal]
}
interface Mammal implements Animal {
# the relatives of a mammal are mammals which are also animals
relatives: [Mammal]
}
Here the Mammal
relatives are clearly of type Mammal
. Before we could not express this relationship. Mammal
and Animal
could only be written as two separate interfaces:
interface Animal {
relatives: [Animal]
}
interface Mammal {
# the relatives of a mammal are mammals, but no Animals
relatives: [Mammal]
}
One abstract concept which can be found in many schemas is a Connection
type to model paginated lists.
A simplified Connection
looks like this:
interface Node {
id: ID!
}
interface Connection {
hasNextPage: Boolean
cursor: String
nodes: [Node]
}
With interfaces implementing interfaces we can now express a more specific Connection
while still
being abstract:
interface Node {
id: ID!
}
interface Connection {
hasNextPage: Boolean
cursor: String
nodes: [Node]
}
interface NameNode implements Node {
id: ID!
name: String
}
interface NamedConnection implements Connection{
hasNextPage: Boolean
cursor: String
nodes: [NameNode]
}
Schema evolution via interface extraction
One of the most interesting consequences of this new feature is that we can now evolve schemas more often via “interface extraction” without breaking the contract.
Coming back to the first example:
interface Node {
id: ID!
}
interface NamedNode implements Node {
id: ID!
name: String
}
type Movie implements NamedNode & Node {
id: ID!
name: String
directors: [Director!]
}
type Director implements NamedNode & Node {
id: ID!
name: String
}
Lets assume we realize that a Director
can be a human but also a computer. Therefore we want to have HumanDirector
and a ArtificialDirector
.
With interfaces implementing interfaces we can change Director
to an interface and add HumanDirector
and ArtificialDirector
:
interface Node {
id: ID!
}
interface NamedNode implements Node {
id: ID!
name: String
}
type Movie implements NamedNode & Node {
id: ID!
name: String
directors: [Director!]
}
interface Director implements NamedNode & Node {
id: ID!
name: String
}
type HumanDirector implements Director & NamedNode & Node {
id: ID!
name: String
age: Int
}
type ArtificialDirector implements Director & NamedNode & Node {
id: ID!
name: String
algorithm: String
}
This change doesn’t break any existing query, because Director
still contains the same fields as before and every query works as before.
We can even repeat this refactoring and introduce a LivingHumanDirector
:
interface HumanDirector implements
Director & NamedNode & Node {
id: ID!
name: String
age: Int
}
type LivingHumanDirector implements
HumanDirector & Director & NamedNode & Node {
id: ID!
name: String
age: Int
address: String
}
More useful than before
While this “interface extraction” refactoring was possible before it was much more limited: we could only convert an object into an interface if the object didn’t implement any interface.
Example:
type Movie {
id: ID!
name: String
directors: [Director!]
}
type Director {
id: ID!
name: String
}
Here we can extract an interface Director
and add HumanDirector
and ArtificialDirector
:
type Movie {
id: ID!
name: String
directors: [Director!]
}
interface Director {
id: ID!
name: String
}
type HumanDirector implements Director{
id: ID!
name: String
age: Int
}
type ArtificialDirector implements Director {
id: ID!
name: String
algorithm: String
}
But now we backed ourself in a corner: we can’t repeat this because HumanDirector
and ArtificialDirector
already implements an interface. We also can’t add the general Node
interface because Director
is already an interface.
Overall this refactoring had much higher consequences before interface hierarchies.
Limitation of interface extraction
There is still one constraint around interface extraction: mixing this approach with unions is not possible.
For example:
interface Node {
id: ID!
}
type Movie implements Node {
id: ID!
name: String
directors: [Director!]
}
type Director implements Node {
id: ID!
name: String
}
union DirectorOrMovie = Movie | Director
Here we can’t extract an interface from Director
or Movie
because it is used as part of an union declaration and union members must be of type object.
And even if we can extract an interface because we don’t have a union at the moment we restrict ourself for the future and it should be considered.
Another side effect of interface extraction is that code generation tools might produce different results because the kind changed from object to interface.
Written by Andi Marek You should follow him on Twitter