Developing GraphQL APIs using Nexus
When developing a GraphQL API there are two popular approaches to create the GraphQL Schema: the schema-first approach and the code-first. The schema-first consists of building the Schema using the Schema Definition Language while the code-first uses a programming language to create the Schema.
In this blog post, we will explore both alternatives, outline the benefits of the code-first approach, and see it in action with GraphQL Nexus.
Let's see with a code example what it means to define our schema with schema-first and code-first.
Schema defined using the Schema Definition Language (SDL):
# Profile Type type Profile { id: ID! username: String! } # The "Query" type defines all queries users can execute type Query { me: Profile! }
The same schema is defined using a Programming Language, in this case, Javascript with Nexus library:
import { objectType, scalarType } from '@nexus/schema'; const Profile = objectType({ name: "Profile", definition(t) { t.nonNull.id("id") t.nonNull.string("username") } }) const Query = objectType({ name: "Query", definition(t) { t.nonNull.field("me", { type: Profile, resolve() { return { id: 1, username: "blogpost" }; } }) } });
The Benefits of a Code-first Approach
The schema-first has great benefits: it uses a common language that's easy to understand by all team members and improves the API design and communication between backend and front-end teams. However, once we start adding more and more types to our Schema, we might have a few issues with this approach:
- If a type changes, the resolver for the matching type needs to be updated. Keeping all types and resolvers in sync becomes a challenge in larger schemas.
- We need additional tools to modularize the schema as it's very hard to have all the schema types in a single file.
- It's harder to reuse types, causing duplications.
The code-first, on the other hand, can be harder to understand by team members—but given that we're building the schema using code, it will solve the schema-first issues outlined above.
- We can easily modularize the schema as it just means modularizing our code.
- The code is the single source of truth, and the types are generated from the code.
- We can reuse types and do anything that the underlying programming language supports when building the schema.
Code-first in JavaScript
Depending on the programming language used there are different alternatives to building code-first GraphQL APIs. In JavaScript, two popular libraries are Nexus and TypeGraphQL. In the next section, we'll explore Nexus, and in a future blog post, we might go deep with TypeGraphQL.
GraphQL Nexus
GraphQL Nexus is a declarative, code-first library for building GraphQL Schemas. GraphQL Nexus can work with any GraphQL server like Apollo, or middleware like Mercurius.
The API is simple but provides us all the features we can build with graphql-js. Let's see it in action, with a basic e-commerce GraphQL API.
We will use Apollo Server, an in-memory database and the goal is to create a modular schema from the beginning.
First, let's imagine how we might solve the problem with a schema-first approach:
input BuyProductInput { count: Int = 1 productId: ID! } type Cart { id: Int items: [CartProduct]! } type CartProduct { count: Int product: Product } type Mutation { buyProduct(input: BuyProductInput!): Cart! } type Product { id: ID inStock: Boolean price: Int title: String } type Query { products: [Product]! }
Now let's see how we can build this schema using Nexus, in a modular, reusable way. Let's add two different files. Each file will have all related types, queries, and mutations: Product.ts and Cart.ts
//Product.ts import { extendType, objectType } from "nexus"; //inStock is the only field we need a custom resolver. export const Product = objectType({ name: "Product", definition(t) { t.id("id"); t.string("title"); t.int("price"); t.boolean("inStock", { resolve(parent) { return parent.stock > 0; }, }); }, }); //add products Query export const ProductQuery = extendType({ type: "Query", definition(t) { t.nonNull.list.field("products", { type: "Product", resolve(_root, _args, ctx) { return ctx.db.products; }, }); }, });
We can see in the Product
type that id/title/price fields do not need a resolver function. For inStock
, we need a resolver to calculate this value depending on the stock number. Colocating the boolean field definition and the resolver makes it easier to reason about and easier to update.
In Cart.ts we will define all the types related to the Cart:
//Cart.ts import { extendType, inputObjectType, objectType, nonNull } from "nexus"; export const Cart = objectType({ name: "Cart", definition(t) { t.int("id"); t.nonNull.list.field("items", { type: "CartProduct", resolve(parent, _args) { return Array.from(parent.cartProducts.values()); }, }); }, }); export const BuyProductInput = inputObjectType({ name: "BuyProductInput", definition(t) { t.nonNull.id("productId"); t.int("count", { default: 1 }); }, }); export const CartMutation = extendType({ type: "Mutation", definition(t) { t.nonNull.field("buyProduct", { type: "Cart", args: { input: nonNull(BuyProductInput), }, resolve(_root, { input }, ctx) { return ctx.db.addToCart({ productId: input.productId, count: input.count!, customerId: ctx.customer.id, }); }, }); }, }); export const BaseCartProduct = objectType({ name: "CartProduct", definition(t) { t.int("count"); }, });
One interesting feature is extending the BaseCartProduct
type in Product.ts and adding the product field. This way we keep all related features in Product.ts and each module is responsible for a feature or type.
//Product.ts /* .... types defined above */ //this type will extend CartProduct defined in Cart.ts and add the product: Product field export const CartProductWithProduct = extendType({ type: "CartProduct", definition(t) { t.field("product", { type: "Product", resolve(parent, _args, ctx) { return ctx.db.products.find( (product) => product.id == parent.productId ); }, }); }, });
Resolvers, Types, and Generated Artifact
As we create our types using code, the definition and resolvers of the types are colocated in the same place. This facilitates the process of updating our types and resolvers.
Nexus, by default, generates a schema file and the TypeScript types matching our GraphQL Schema. It's also a good practice to commit the generated files to the repository. Type-safety is a big plus and Nexus is almost 100% type-safe by default.
Nexus Plugin System
When we need to reuse functionality or to define our own abstractions, we can create or use a Nexus Plugin. Nexus Plugin API can be used to define new options for types and fields and modify our schema.
There are some plugins ready to use, like the Relay Connection plugin, which allows us to easily enable paginated associations following the Relay Specification. The full list can be found here.
Transitioning to Nexus
One downside of code-first approaches and Nexus is difficulty in understanding between different teams compared to the Schema Definition Language. This is a major downside, but there are possible solutions to this problem.
One solution is to use the Schema Definition Language to design our API Schema, easing the communication between teams, and use a tool like SDL converter to generate the initial Nexus code and then use GraphQL Nexus to develop the API.
Final Notes
In this post, we have discussed different alternatives to develop a GraphQL API, and how code-first approaches can help us with defining a modular GraphQL Schema, with the code being the single source of truth, and with an improved developer experience and type-safety compared to traditional schema-first approaches.
In future posts, we will explore other tools available in the JavaScript landscape to develop code-first GraphQL APIs so stay tuned!