GraphQL Backend - Schema-first vs Code-first

GraphQL is a query language for API created by Facebook in 2012 and open-sourced in 2015. It provides a set of features that allows building Back-end and a very optimized way for Data fetching. According to https://graphql.org GraphQL provides a complete and understandable description of the data in your API, gives clients the power to ask for exactly what they need and nothing more, makes it easier to evolve APIs over time, and enables powerful developer tools.

To do this, GraphQL is centered around an element called The GraphQL Schema which is the Contract between the Back-end service and the Front-end on how to consume the service and what can be served by the service.

As you will have understood, everything goes through this GraphQL schema. πŸ˜‰ Now this being said, let's see what are the different ways to build it

As a backend developer, I sometimes had to wonder which approach to adopt when I had to build a GraphQL Backend. When I started working on GraphQL, I used to work with the standard way to do this task, what is called the "Schema-first" approach where the GraphQL Schema defining type, fields, resolvers function, and relationships between data are defined first to say which fields can be exposed by the service and which functions resolvers can be called to ask for those fields and which parameters are required by each resolver.

On the other hand, this approach is not the only way to build a GraphQL service since the "Code-first" approach can be another way to build a GraphQL API. In this way, you only need to write the resolvers function needed to serve a specific need with some annotations, and a build tool will compile the schema and the SDL based on types and provided annotations. We will dive deeper into it later. let's jump into each approach now.

1. Schema-first

This way of building GraphQL services has been the only way to build a GraphQL backend at the beginning and it is based on a simple concept, the "Contract" ☝️

The "Contract" here represents an agreement between the Front-end and the Back-end on how Data are defined (field names, resolvers functions, available fields, etc ..) and how all the Frontend can consume those Data. This way, Front-end developers can work asynchronously when the GraphQL schema is ready. To do so, we use the SDL (Schema DΓ©finition Language) to define the schema and then create all the resolver functions that execute and return data at runtime. Therefore, the GraphQL schema is like a Blueprint of a GraphQL API.

type Query {
  welcome: String!
  users: [User!]!
}
  
type User {
 _id: ID!
 firstName: String!
 lastName: String!
 email:	String!
}
schema.graphql

This pattern is used by many popular GraphQL servers. let's see this with a real example. To see this in the real world, let's build a simple service using Apollo Server and Typescript. Let's follow the same approach as shown on their website

Step 1: Create a new project

Open your favorite terminal and create a folder

mkdir demo-schema-first 
cd demo-schema-first

then run the following commands yarn init --yes to initialize a node.js project using Yarn as a package manager. this will create a package.json

Step 2: Install dependencies

  • yarn add @apollo/server graphql to install useful packages
  • yarn add -D typescript nodemon @types/node to install typescript and corresponding types
  • Now run touch tsconfig.json to create a tsconfig.json and paste the following code into
{
  "compilerOptions": {
    "rootDirs": ["src"],
    "outDir": "dist",
    "lib": ["es2020"],
    "target": "es2020",
    "module": "esnext",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "types": ["node"]
  }
}
tsconfig.json

Add the following in your pakage.json

"type": "module", 
"scripts": {
   "compile": "tsc",
   "start": "npm run compile && nodemon ./dist/index.js"
  }

Our package.json will look like this

{
  "name": "demo-schema-first",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "type": "module",
  "scripts": {
    "compile": "tsc",
    "start": "npm run compile && node ./dist/index.js"
  },
  "dependencies": {
    "@apollo/server": "^4.3.0",
    "graphql": "^16.6.0"
  },
  "devDependencies": {
    "@types/node": "^18.11.18",
    "ts-node": "^10.9.1",
    "typescript": "^4.9.4"
  }
}

Create a src folder with

mkdir src
touch src/index.ts

and paste the following to bootstrap a basic Apollo Server

import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";

const typeDefs = `
  type Book {
	id:ID!
    title: String
    author: String
  }

  type Query {
    books: [Book]
  }
`;

const books = [
  {
    id:1,
    title: "The Awakening",
    author: "Kate Chopin",
  },
  {
    id:2,
    title: "City of Glass",
    author: "Paul Auster",
  },
];

const resolvers = {
  Query: {
    books: () => books,
  },
};

const server = new ApolloServer({
  typeDefs, // IMPORTANT
  resolvers, // IMPORTANT
});

const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
});

console.log(`πŸš€  Server ready at: ${url}`);

On the terminal, run the command yarn start and you will have the following output when you open http:localhost:4000 and execute the query to get all books

In this example, there is something very important to mention. The way the Apollo Server is created. We first define the schema using SDL syntax, define the corresponding resolvers, and pass both to the ApolloServer constructor.

However, the downside to a schema-first approach is that the resolver functions must match the schema exactly. Since developers need to write the schema and resolvers separately, this becomes a challenge for very large APIs with thousands of lines.

To this day, this approach does work, but it requires a lot of workarounds and tools to help keep the code clean and inconsistencies to a minimum.

2. Code-first

As described previously, the code-first approach focuses on writing resolvers functions and annotations, then a build tool will compile the schema and the SDL based on types and provided annotations and generate a GraphQL schema based on resolvers types. This approach can speed up the development process since there is no need to check each time that the resolvers match any schema.

To work with the code-first approach, you will need some third-party libraries like tpegraphql or Nexus. Let's build a GraphQL API from our previous example using TypeGraphQL

1. Create a new project

Open your favorite terminal and create a folder with

mkdir demo-code-first 
cd demo-code-first

then run the following commands

  • yarn init --yes to initialize a node.js project using Yarn as a package manager. this will create a package.json and add the following
 "scripts": {
   "compile": "tsc",
   "start": "npm run compile && nodemon ./src/index.ts"
  }

2. Install dependencies

  • yarn add -D typescript ts-node nodemon @types/node to install typescript and corresponding types
  • Β yarn add @apollo/server typedi class-validator reflect-metadata graphql@15.8.0 type-graphql@2.0.0-beta.1 which are dependencies required for our use case.

Note: Since we are working with Apollo server v4, we use type-graphql version 2.0.0-beta.1 to avoid errors since Apollo Server V4.0 has "Dropped support for versions of the GraphQL library prior to v16.6.0". You can have a look at the issue here πŸ‘‰ on GitHub for more details

Our package.json look like this

{
  "name": "demo-code-first",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "compile": "tsc",
    "start": "yarn compile && nodemon ./src/index.ts"
  },
  "dependencies": {
    "@apollo/server": "^4.3.0",
    "class-validator": "^0.14.0",
    "graphql": "^16.6.0",
    "reflect-metadata": "^0.1.13",
    "type-graphql": "2.0.0-beta.1",
    "typedi": "^0.10.0"
  },
  "devDependencies": {
    "@types/node": "^18.11.18",
    "ts-node": "^10.9.1",
    "typescript": "^4.9.4"
  }
}

Create a src folder with

mkdir src
touch src/index.ts

and paste the following to bootstrap a basic Apollo Server

We must ensure that it is imported at the top of our entry file (before we use/import type-graphql or our resolvers):

import "reflect-metadata";

3.TypeScript configuration

It's important to set these options in the tsconfig.json file of our project:

{
  "emitDecoratorMetadata": true,
  "experimentalDecorators": true
}

TypeGraphQL is designed to work with Node.js LTS (10.3+, 12+) and the latest stable releases. It uses features from ES2018 so we should set our tsconfig.json file appropriately:

{
  "target": "es2018" // or newer if your node.js version supports this
}

Due to using the graphql-subscription dependency that relies on an AsyncIterator, we may also have to provide the esnext.asynciterable to the lib option:

{
  "lib": ["es2018", "esnext.asynciterable"]
}

All in all, the minimal tsconfig.json file example looks like this:

{
  "compilerOptions": {
    "target": "es2018",
    "module": "commonjs",
    "outDir": "dist",
    "esModuleInterop": true,
    "lib": ["es2018", "esnext.asynciterable"],
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

4.TypeScript configuration

It's important to set these options in the tsconfig.json file of our project:

{
  "emitDecoratorMetadata": true,
  "experimentalDecorators": true
}

TypeGraphQL is designed to work with Node.js LTS (10.3+, 12+) and the latest stable releases. It uses features from ES2018 so we should set our tsconfig.json file appropriately:

{
  "target": "es2018" // or newer if your node.js version supports this
}

Due to using the graphql-subscription dependency that relies on an AsyncIterator, we may also have to provide the esnext.asynciterable to the lib option:

{
  "lib": ["es2018", "esnext.asynciterable"]
}

All in all, the minimal tsconfig.json file example looks like this:

{
  "compilerOptions": {
    "target": "es2018",
    "module": "commonjs",
    "lib": ["es2018", "esnext.asynciterable"],
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Let's create our server. First, create the following files inside srcfolder

  • book.ts
import { Field, ID, ObjectType } from "type-graphql";

@ObjectType()
export class Book {
  @Field((type) => ID)
  id: number;

  @Field()
  title: string;

  @Field()
  author: string;

  constructor(id: number, title: string, author: string) {
    this.title = title;
    this.author = author;
    this.id = id;
  }
}
  • bookResolver.ts
import { Arg, Query, Resolver } from "type-graphql";
import { Book } from "./book";
import { BookService } from "./bookService";
import { Container } from "typedi";

@Resolver((of) => Book)
export class BookResolver {
  constructor(private bookService: BookService) {}

  @Query((returns) => [Book])
  async books() {
    return Container.get(BookService).getBooks();
  }

  @Query((returns) => Book)
  async book(@Arg("id") id: number) {
    return Container.get(BookService).getBook(id);
  }
}
  • bookService.ts thas will behave like our layer between resolvers and Database where we fetch data
import { Service } from "typedi";
import { Book } from "./book";

@Service()
export class BookService {
  books = [
    {
      id: 1,
      title: "The Awakening",
      author: "Kate Chopin",
    },
    {
      id: 2,
      title: "City of Glass",
      author: "Paul Auster",
    },
  ];
  getBooks = (): Book[] => {
    return this.books;
  };

  getBook = (id: number): Book => {
    return this.books.find((book) => book.id === id);
  };
}
  • index.ts
import "reflect-metadata";
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { buildSchema } from "type-graphql";
import { BookResolver } from "./bookResolver";
import * as path from "path";

const bootstrap = async () => {
  const schema = await buildSchema({
    resolvers: [BookResolver],
    emitSchemaFile: path.resolve(__dirname, "schema.gql"),
  });
  const server = new ApolloServer({ schema });

  const { url } = await startStandaloneServer(server, {
    listen: { port: 4000 },
  });

  console.log(`πŸš€  Server ready at: ${url}`);
};

bootstrap();

On the terminal, run the command yarn start and you will have the following output when you open http:localhost:4000 and execute the query to get all books

This will output the exact same result as the previous approach using schema first.

Note. Something important to mention is this line on our index.ts file

const schema = await buildSchema({
    resolvers: [BookResolver],
    emitSchemaFile: path.resolve(__dirname, "schema.gql"), /// THIS LINE
  });

With this option, we tell our server to output the schema file at the root of our src folder. If we take a look at the root folder, we will see a Β schema.gql file which contains the blueprint of our Graphql API.

3. Conclusion

There are always pros and cons to everything. In my opinion, you should choose the one that is good for you and which you are comfortable with. Β I can also say that the schema-first approach is good for people who just started working with graphql since you will understand if it is the ideal way for you to develop Apis and is the most popular way on the market. However, if you find that over time, it becomes difficult to keep up with the complexity of the project, you can learn Code-first.

  • The code source for code-first is available here
  • The code source for schema-first is available here

Thanks for reading πŸ˜‰

Follow me on social media to stay up to date with latest posts.

Loading comments...
You've successfully subscribed to @Tech.hebdo
Great! Next, complete checkout to get full access to all premium content.
Error! Could not sign up. invalid link.
Welcome back! You've successfully signed in.
Error! Could not sign in. Please try again.
Success! Your account is fully activated, you now have access to all content.
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.