GraphQL Code vs Schema First Development

Two main approaches exist for writing a GraphQL server: code-first and schema-first. This post will compare these two approaches and explain why I typically prefer the code-first approach.

Schema-first Approach

With a schema-first approach, you start with the GraphQL SDL. For instance, you start in a .graphql file writing something like

type Query {
  hero(episode: Episode): Character
}

enum Episode {
  NEWHOPE
  EMPIRE
  JEDI
}

type Character {
  name: String!
  appearsIn: [Episode!]!
}

Once you've settled on the data types and operation definitions, you need to write resolvers to tell the GraphQL server how to resolve the data declared in the schema. In order to achieve type safety in accordance with the schema, you would typically use GraphQL Code Generator's Typescript Resolvers plugin. Finally, you can implement the logic necessary to resolve the data and use the generated types to annotate the resolvers ensuring type safety.

Code-first Approach

Nexus is what would typically be used for a code-first approach in TypeScript. Rather than specifying the schema in a separate .graphql file first, the source of truth for data types and operations lives in .ts files right alongside the resolver implementation. The same schema from the schema-first approach in Nexus would be

import { objectType, inputObjectType } from "nexus";

export const Episode = enumType({
  name: "Episode",
  members: ["NEWHOPE", "EMPIRE", "JEDI"],
});

export const Character = objectType({
  name: "Character",
  definition(t) {
    t.nonNull.string("name");
    t.field("appearsIn", { type: nonNull(list(nonNull(Episode))) });
  },
});

export const heroQuery = queryField("hero", {
  type: Character,
  args: {
    episode: arg({ type: nullable(Episode) }),
  },
  async resolve(root, args, ctx) {
    ...
  },
});

Similar to the schema-first approach, you still start by declaring the data types and operations and then filling in how to resolve them. You still do end up with an SDL file that you can feed to a GraphQL server implementation like Apollo, but it is a build artifact and not the original source of truth.

Schema-first Advantages

Nothing in the above examples is particularly compelling for the code-first approach. Indeed, the first time I looked at Nexus, I thought it was a step backwards to something like vanilla graphql-js compared to the approach popularized by Apollo where you get to write the nice SDL. I still think the biggest benefit of the schema-first approach is that writing and reading the SDL is easier than the Nexus API. appearsIn: [Episode!]! is a lot easier to parse than t.field("appearsIn", { type: nonNull(list(nonNull(Episode))) }). As I've used nexus more, however, this disparity has decreased.

Code-first Advantages

The biggest advantage of a code-first approach is that it allows for more dynamic schema creation. GraphQL's type system doesn't include generics, so you oftentimes end up with a lot of similar types when following conventions. Relay-styled pagination is the most common example in my experience. So many *Connection and *Edge types that only differ by what the node type is and the queries all have the same first/after/before/last arguments.

By moving our schema definition into the TypeScript realm instead of the GraphQL SDL realm, we gain the ability to use TypeScript to generate our schema. That means functions to abstract common patterns. Nexus actually includes a connection plugin that nicely implements relay-style pagination.

Instead of having to write SDL like this over and over

type CharacterConnection {
  edges: [CharacterEdge!]!
  pageInfo: PageInfo!
}

type CharacterEdge {
  cursor: String!
  node: Character!
}

type Query {
  heros(
    after: String
    before: String
    first: Int
    last: Int
  ): CharacterConnection!
}

you get to write

export const herosQuery = queryField((t) => {
  t.connectionField("heros", {
    type: Character,
    async nodes(root, args, ctx) {
      // Return an array of Characters
      return [];
    },
  });
});

It is these types of common situations where the code-first approach really shines.

The other advantages are a simplified dev/tooling setup and colocation of types/implementation. While your server runs, Nexus automatically regenerates TypeScript types based on what is defined. So if I add an argument and save the file, it is quickly available in the resolve arguments. This developer experience is similar to what you can get with GraphQL Code Generator in watch mode, but you don't need another tool to do it. Moreover, the colocation of the type definitions and the resolve implementation prevents having to jump back and forth between files.

Wrapping Up

Both schema-first and code-first approaches are viable for developing a GraphQL server. My preference at this point is to use Nexus and a code-first approach.