GraphQL.DE

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