GraphQL root fields

There is no such thing as a “root field”.

graphql
Author

A. Coady

Published

April 12, 2024

There is no such thing as a “root field”.

There is a common - seemingly universal - misconception that GraphQL root fields are somehow special, in both usage and implementation. The better conceptual model is that there are root types, and all types have fields. The difference is not just semantics; it leads to actual misunderstandings.

Multiple queries

A common beginner question is “can there be multiple queries in a request”. The question would be better phrased as “can multiple fields on the root query type be requested”. The answer is of course, because requesting multiple fields on a type is normal. The implementation would have to go out of its way to restrict that behavior on just the root type. The only need for further clarity would be to introduce aliases for duplicate fields.

Flat namespace

GraphQL types share a global namespace, causing conflicts when federating multiple graphs. Nothing can be done about that unless GraphQL adopts namespaces.

But many APIs design the root query type to have unnecessarily flat fields. One often sees a hierarchy of types and fields below the root, but the top-level fields resemble a loose collections of functions. Verbs at the top level; nouns the rest of the way down. This design choice appears to be in a feedback loop with the notion of “root fields”.

Even the convention of calling the root query type Query demonstrates a lack of specificity. In a service-oriented architecture, a particular service might be more narrowly defined.

Mutations

Top-level mutation fields are special in one aspect: they are executed in order. This has resulted in even flatter namespaces for mutations,

mutation {
    createUser # executed first
    deleteUser
}

This is not necessary, but seems widely believed that it is. Nested mutations work just fine.

mutation {
    user {
        create # executed in arbitrary order
        delete
    }
}

If the underlying reason is truly execution order, the client could be explicit instead.

mutation {
    created: user { # executed first
        create
    }
    deleted: user {
        delete
    }
}

There is no reason it has to influence API design.

Static methods

At the library level, the effect is top-level resolvers are implemented as functions (or static methods), whereas all other resolver are methods. This may lead to redundant or inefficient implementations, is oddly inconsistent, and is contrary to the documentation.

A resolver function receives four arguments:

obj The previous object, which for a field on the root Query type is often not used.

Sure, “often not used” by the developer of the API. That does not mean “should be unset” by the GraphQL library, but that is what has happened. Some libraries even exclude the object parameter entirely. In object-oriented libraries like strawberry, the code looks unnatural.

import strawberry
 
 
@strawberry.type
class Query:
    @strawberry.field
    def instance(self) -> bool | None:
        return None if self is None else isinstance(self, Query)


schema = strawberry.Schema(Query)
query = '{ instance }'
schema.execute_sync(query).data
{'instance': None}

Strawberry allows omitting self for this reason, creating an implicit staticmethod.

Root values

Libraries which follow the reference javascript implementation allow setting the root value explicitly.

schema.execute_sync(query, root_value=Query()).data
{'instance': True}

Strawberry unofficially supports supplying an instance, but it has no effect.

schema = strawberry.Schema(Query())
schema.execute_sync(query).data
{'instance': None}

And of course self can be of any type.

schema.execute_sync(query, root_value=...).data
{'instance': False}

Moreover, the execute functions are for internal usage. Each library will vary in how to configure the root in a production application. Strawberry requires subclassing the application type.

import strawberry.asgi


class GraphQL(strawberry.asgi.GraphQL):
    def __init__(self, root):
        super().__init__(strawberry.Schema(type(root)))
        self.root_value = root

    async def get_root_value(self, request):
        return self.root_value

Example

Consider a more practical example where data is loaded, and clearly should not be reloaded on each request.

@strawberry.type
class Dictionary:
    def __init__(self, source='/usr/share/dict/words'):
        self.words = {line.strip() for line in open(source)}

    @strawberry.field
    def is_word(self, text: str) -> bool:
        return text in self.words

Whether Dictionary is the query root - or attached to the query root - it should be instantiated only once. Of course it can be cached, but again there is a more natural way to write this outside the context of GraphQL.

@strawberry.type
class Query:
    dictionary: Dictionary

    def __init__(self):
        self.dictionary = Dictionary()

Caching, context values, and root values are all clunky workarounds compared to the consistency of letting the root be Query() instead of Query. The applications which do not require this feature would never notice the difference.

The notion of “root fields” behaving as “top-level functions” has resulted in needless confusion, poorer API design, and incorrect implementations.