This article is part of an ongoing series on conceptualizing, designing, and implementing a GraphQL server. The previous articles from the series are:
The GraphQL schema defines the contracts for a GraphQL service by exporting the set of types, fields, and mutations that can be executed against the service. When creating a GraphQL service, we can decide to have the schema be the source of truth and let all our implementation code match its definitions, or we can have our code be the source of truth and have the schema be an artifact generated from the code.
In either case, we will have a fully functioning GraphQL service, but depending on which approach we use, we may be able to implement more or less features, more or less easily, down the road. The two approaches are called, respectively, schema-first and code-first.
In this article, we will explore these two approaches to creating our GraphQL service, review the advantages/disadvantages of each, and, finally, take a stance and decide which is possibly the better choice. Let’s start!
Schema-first indicates that we first define the schema for the GraphQL service and then we implement the code by matching the definitions in the schema. To code the schema, we use the Schema Definition Language (SDL), a syntax created to represent the GraphQL data model. Because of this, this approach may also be called SDL-first.
The SDL has contributed to the widespread popularity of GraphQL. Even though the first implementations and utilities of GraphQL were in JavaScript (graphql-js in 2015, graphql-tools in 2016), the SDL was designed to be language-agnostic. Implementations of GraphQL servers in different languages — including those for Node.js, Ruby, Python, Scala, PHP, and others — can read from/write to SDL.
Let’s see what the SDL looks like (I won’t go into detail since there are many tutorials about it, like this one). Defining a type Film
with properties id
and title
in SDL looks like this:
type Film { id: Int title: String! }
The code is almost self-descriptive: the title is a property of type string (written with title case: String
), and it must always have a value, represented through the character !
.
Since GraphQL is all about querying relationships in a graph (that’s where the name GraphQL comes from: Graph Query Language), we can define relationships across different types, like this:
type Film { id: Int title: String! director: Person! actors(limit: Int = 10): [Person] } type Person { id: Int name: String! }
Since a film can have many actors and actresses, the property actors
is defined as a list type through characters []
, and we can limit how many elements to return in this list through the custom argument limit
, with a default value of 10.
Already in this simple example we can appreciate the SDL’s biggest advantage: it is very simple and concise and, as such, very easy to understand, both for technical and nontechnical people. As a consequence, the SDL can act as a communication tool through which people across teams can collaborate in the creation of the organization’s data model.
Similarly, we can already notice what drawbacks the SDL brings. The first is that the SDL doesn’t include the resolvers, i.e., the actual code that will compute the field’s value. Hence, the SDL cannot all on its own be the single source of truth since it’s not complete. At most, SDL just brings a clean separation between schema definition and resolution, as in this code (which uses graphql-tools):
const { makeExecutableSchema } = require('graphql-tools') // Schema definition const typeDefs = ` type Film { id: Int title: String! director: Person! actors(limit: Int = 10): [Person] } type Person { id: Int name: String! } ` // Schema resolution const resolvers = { Film: { director: (film, args) => fetchUserById(film.directorID), actors: (film, args) => findUsers(film.actorIDs, {limit: args.limit}), } } const schema = makeExecutableSchema({ typeDefs, resolvers, })
The second drawback is that the resolver code must match exactly the definition in the SDL (e.g., a property with type String!
must be String!
, not String
or Int!
). Hence, it is not DRY (Don’t Repeat Yourself); there will be information duplicated across the codebase, which must be kept in sync.
In the code-first approach, we start by coding the resolvers, and then, from code as a single source of truth, we have the schema generated as an artifact. Thus, we still have a schema, but instead of being manually created, it is created through running a script. This approach may also be called resolver-first.
Understanding the schema just from looking at code is not as easy as looking at the SDL definition. Hence, several GraphQL server implementations have attempted to have their code mirror, as much as possible, the SDL.
For instance, in Nexus (a code-first GraphQL server in JavaScript/TypeScript), the name of the method defining a field already expresses the type of the response (in the code below: t.int
, t.string
, t.field
for relationships to an object type, and t.list.field
denoting a list of relationships to an object type). Implementing the same schema as above looks like this:
const Film = objectType({ name: "Film", definition(t) { t.int("id", { description: "Id of the film" }); t.string("title", { description: "Title of the film" }); t.field("director", { type: Person, resolve(root, args, ctx) { return ctx.getFilm(root.id).director(); }, }); t.list.field("actors", { type: Person, nullable: true, args: { limit: intArg({ required: false, default: 10, description: "Limit the number of actors/actresses" }), }, resolve(root, args, ctx) { return ctx.getFilm(root.id).actors(args.limit); }, }); } }); const Person = objectType({ name: "Person", definition(t) { t.int("id"); t.string("name"); } });
Fields to be resolved to a property of the same name in the object (such as fields id
and title
from type Film
, which are resolved as properties id
and title
from the film object) can have their resolver functions omitted. If the data model mostly consists of simple attributes like these, without any custom logic to be resolved, the code then quite resembles the SDL, as is the case for type Person
in the code above.
Nexus has put emphasis in being declarative and readable. As such, even though less understandable than reading the SDL, it is still good enough that it can be used for communicating the intent of the schema in addition to how it will be resolved.
Other GraphQL servers also emulate the SDL in their definitions, but they may have less optimal results. For instance, graphql-php implements the schema above like this:
$film = new ObjectType([ 'name' => 'Film', 'fields' => [ 'id' => Type::int(), 'title' => [ 'type' => Type::string(), 'description' => 'Film's title' ], 'director' => [ 'type' => Type::nonNull($personType), 'description' => 'Film director', 'resolve' => function(Film $film) { return DataSource::findPerson($film->directorID); } ], 'actors' => [ 'type' => Type::listOf($personType), 'description' => 'List of actors/actresses in the film', 'args' => [ 'limit' => [ 'type' => Type::int(), 'description' => 'Limit the number of actors returned', 'defaultValue' => 10 ] ], 'resolve' => function(Film $film, $args) { return DataSource::findActors($film->id, $args['limit']); } ] ] ]); $personType = new ObjectType([ 'name' => 'Person', 'description' => 'A person', 'fields' => [ 'id' => Type::int(), 'name' => [ 'type' => Type::string(), 'description' => 'Person's name' ], ] ]);
In this case, the definition is more jarred, with several indentations cutting the flow of legibility and diverse syntax colors getting in the way. Hence, even though the schema is still understandable, it is not so for everyone, and its utility as a communication tool across teams decreases.
From these examples, we can appreciate the code-first schema’s biggest advantages and liabilities: it can effectively be the single source of truth of the data model since it contains both the schema definitions and the code to resolve them, but at the expense of being less understandable.
Please note that we can still use a schema, written in SDL, to communicate the data model with our co-workers: the schema can be generated as an artifact from the code by running some script. Moreover, the generated schema can be consistently formatted to help its legibility, such as ordering the types alphabetically.
Generating the schema can be done manually whenever needed. It can also be automated as part of our continuous integration process — triggering it whenever our codebase is tagged, for instance — and committing the newly created file to a special repository. This is the strategy employed by GitHub for its public GraphQL schema.
We can choose which of these two approaches to use for creating our GraphQL service. However, the decision may have to be made at the very beginning since it may influence which GraphQL server implementation we use; most implementations will be opinionated on using either one or the other one, very seldom both.
Moreover, this decision may also affect which language to use. Some languages offer the two options, such as JavaScript or TypeScript through Apollo server (schema-first) and Nexus (code-first), Python through Ariadne (schema-first) and Graphene (code-first), and .NET through GraphQL for .NET offering both alternatives.
However, some other languages have only one approach, such as Rust, for which there is only one GraphQL server: Juniper, which supports the code-first approach only.
Finally, it may also affect which framework for some language we can use. For PHP, for instance, Laravel can choose from both options through Lighthouse (schema-first) and Laravel GraphQL (code-first), but WordPress only has solutions offering the code-first approach, such as WPGraphQL and Gato GraphQL.
Links to all the different GraphQL servers, for all languages, can be found here.
So far, we have reviewed how the two approaches compare regarding legibility (schema-first is better) and their ability to become a single source of truth without duplicated code (code-first is better). These are not the only characteristics to compare, however.
Next, let’s see how different implementers justify using either approach for their own GraphQL server implementations.
In “Schema-First GraphQL: The Road Less Travelled,” Mirumee’s Jakub Draganek promotes the schema-first approach used by GraphQL server Ariadne.
Jakub mentions that the advantages of having the schema act as the common contract between the client and server sides include the following:
In “The Problems of ‘Schema-First’ GraphQL Server Development” (and in his presentation at GraphQL Conf 2019), Prisma’s Nikolas Burk advances the code-first approach offered by the GraphQL server Nexus.
According to Nikolas, code-first is better because there are no exceptional features supported by schema-first that code-first does not support. At the same time, it requires less effort to use because, in contrast to the schema-first approach, it doesn’t depend on an excessive amount of tooling. Schema-first forces developers to use a myriad of additional tools, bogging down their experience.
The following is a list of challenges that schema-first requires some tool to solve, but not in code-first:
I’d like to add that there are problems in schema-first that do not take any effort to fix in code-first, such as this issue from the GraphQL spec, which concerns localizing the descriptions in the schema, so as to make it usable for those who speak different languages.
Hands down, I’m all for the code-first approach. Let me explain my position.
I have built a GraphQL server in PHP, Gato GraphQL, which supports the code-first approach only. The lack of schema-first is due to the unavailability of a suitable SDL parser in PHP, and implementing one on my own would take a precious amount of effort, which I’d rather spend on some other task.
However, even if I could, adding schema-first support to my GraphQL server may not make much sense because the schema is not static, but dynamic: it can morph as needed, regulated through code. A dynamic schema provides all the benefits described below, which I seriously doubt could be provided through the schema-first approach.
The source of truth for the schema can be a superset of the one required by GraphQL (the equivalent GraphQL schema can be seen through GraphiQL and Voyager). The additional properties (such as the global fields, global connections, global directives, and persisted fragments) can already be used in our API without having to wait for them to be added to the GraphQL spec, if ever.
Because the source of truth is not tied to the schema, then we can generate any schema for any other system, too; GraphQL is just one of the targets. For instance, it can generate a JSON Schema for a REST service from the same source of truth.
The API can be public/private at the same time, depending on whether the user is logged in and on the logged-in user roles, or offer more or less fields depending on some other property, such as whether the user has paid for a pro membership.
Types do not know in advance what fields they will resolve. Instead, field resolvers attach themselves to type resolvers using the publish-subscribe pattern, and field resolvers can override other field resolvers. This feature makes the API very extensible, allowing us to have a general code for our API and customize it at the application level for a specific client or project.
A field can be processed by not just one, but many field resolvers: each field resolver in the chain can decide, on runtime, whether to process the field based on some property, or pass it along the chain.
For instance, a special field resolver may be used only if a field argument "source: testing"
is passed, enabling it to be tested in a few sites in production before the general release. The same strategy also enables you to provide quick bug fixes for a specific client or environment without running the risk of unintended side-effects everywhere else.
The combination of the publish-subscribe pattern and the field resolver-chaining features described above also makes the implementation of the server natively decentralized/federated. Indeed, there is no need to implement federation because a federated or non-federated API is coded the same way, and there is no need for different teams to establish special conventions for managing the data model, as happens with Apollo Federation.
Types and interfaces can be automatically namespaced to avoid collisions from third parties.
I will be describing several of these strategies and their implementation in upcoming articles in this series. I hope that, then, you will be convinced of the superiority of the code-first approach.
In this article we analyzed and compared the two approaches to creating a GraphQL service: schema-first and code-first. Even though they both have positive and negative characteristics, based on my own experience, I can claim that code-first is the better option since it allows us to implement features that are not possible otherwise.
I am not alone in my feelings. In this tweet, Prisma’s Johannes Schickling makes the prediction that, starting from 2020, the code-first approach will become more popular than schema-first.
Prisma has some experience creating tools to improve the inherent limitations from schema-first, and it has created Nexus in order to directly avoid these problems by architectural design through the code-first approach. As such, I believe that Johannes surely knows what he is talking about, and I would heed his prediction.
In any case, using schema-first or code-first is still a decision that must be based on each project’s specific conditions. If your team already knows how to handle it, or your schema will never grow from a limited size, or you only need to launch something quick today and you don’t need to worry about the future, then schema-first is ideal.
However, schema-first may produce pain points down the road if you need to scale up the schema, federate it, have autonomous teams working on it, or customize it for a specific client or project. If you believe that your schema may eventually grow in complexity or size, then code-first is the way to go.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your site. Instead of guessing why problems happen, you can aggregate and report on problematic GraphQL requests to quickly understand the root cause. In addition, you can track Apollo client state and inspect GraphQL queries' key-value pairs.
LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.
4 Replies to "Code-first vs. schema-first development in GraphQL"
Great read. I’ve worked on a Typescript/Rails schema first approach application. Now working on a restful Typescript/Laravel service and I’m missing the graphql schema. Im interested in trying a schema first approach. I hope it’ll be a tool allowing backend & frontend dev to collaborate more. The risk I see in a code first approach for speedy changes is the impact on all the integrated systems. Working in a code first approach is enabling the backend dev team to make all design decisions. Depending on your team this may be great. I think this ties into your first point about schemas being a communication tool. Theres one way I’ll find out 🙂
“Im interested in trying a schema first approach. I hope it’ll be a tool allowing backend & frontend dev to collaborate more.”
Have your tried playing with https://graphqleditor.com/ ? Looks like we might have what you are looking – live collabroation, schema libraries, visual representation as well as mock backend an some more features 🙂
I did both, schema and code first approach (using NestJS). While it is easier to write the contract using the schema, it is much more convenient to then implement the resolvers using the code first approach. So to summarize, I first wrote the gql schema and then wrote the corresponding classes with decorators (code first), once done I dropped the schema.
SDL First or Code First, you can still API First and Schema First. Using “Schema First” to name the act of using SDL to define your API/Schema First is poor naming. The real issue is, you can use SDL or Code to define your API/Schema First, choose the one that makes the most sense for you. The value of API/Schema First design principle is still valid and great practice regardless of it you choose SDL or Code First to is define the API.