Declarative GraphQL: Write Less Code and Get More Done with graphql-tools
GraphQL is a powerful technology for building flexible and efficient APIs. However, creating GraphQL schemas and resolvers from scratch requires writing a lot of code. Luckily, the graphql-tools library from Apollo dramatically simplifies GraphQL development with a declarative, schema-first approach.
In this post, we‘ll explore how graphql-tools enables you to write less code and be more productive when developing GraphQL APIs. We‘ll cover:
- The key features and benefits of graphql-tools
- Defining schemas declaratively with makeExecutableSchema
- Organizing and modularizing your schema code
- Combining multiple schemas from different domains
- Next steps and advanced topics
By the end, you‘ll have a solid foundation for efficiently creating robust and maintainable GraphQL APIs using graphql-tools. Let‘s dive in!
Why graphql-tools?
The graphql-tools library was created to help tackle some common challenges and pain points with GraphQL development:
- Building the initial schema requires a lot of manual, error-prone code
- Defining resolvers to fetch data is repetitive
- Organzing all the schema and resolver code in a project is tricky
- Integrating multiple schemas is complicated
- Mocking data for testing is time-consuming
graphql-tools addresses these issues by providing a set of utilities for creating and manipulating GraphQL schemas. The most important tool it provides is makeExecutableSchema.
Defining Schemas with makeExecutableSchema
At the core of graphql-tools is the makeExecutableSchema function. It allows you to create a GraphQL schema from two parts:
- Schema definition – The types, queries, mutations, etc. defined using the GraphQL schema language
- Resolvers – The functions that populate the data for each field in the schema
Here‘s a basic example:
const typeDefs = `
type Author {
id: Int!
firstName: String
lastName: String
posts: [Post]
}
type Post {
id: Int!
title: String
author: Author
votes: Int
}
type Query {
posts: [Post]
author(id: Int!): Author
}
type Mutation {
upvotePost(postId: Int!): Post
}
`;
const resolvers = {
Query: {
posts: () => posts,
author: (_, { id }) => authors.find(author => author.id === id),
},
Mutation: {
upvotePost: (_, { postId }) => {
const post = posts.find(post => post.id === postId);
if (!post) {
throw new Error(`Post with id ${postId} not found`);
}
post.votes += 1;
return post;
},
},
Author: {
posts: (author) => posts.filter(post => post.authorId === author.id),
},
Post: {
author: (post) => authors.find(author => author.id === post.authorId),
},
};
const schema = makeExecutableSchema({
typeDefs,
resolvers,
});
Here we define our schema in the typeDefs string using the GraphQL schema language. It contains types for Author and Post, a query to get posts and an author by id, and a mutation for upvoting a post.
The resolvers are defined as a nested object that maps types and fields to resolver functions. These functions fetch the appropriate data for each field.
Finally, makeExecutableSchema combines the schema definition and resolvers to produce the final executable schema.
This declarative approach eliminates a lot of the manual work normally required to set up a GraphQL schema and resolvers. It‘s more concise and expressive than constructing the schema programmatically. Making changes is as easy as modifying the type definitions and resolvers.
Organizing Your Schema Code
For a small example like above, putting the typeDefs and resolvers right in the code works fine. But for a real-world application with many types, you‘ll quickly end up with a long, hard to maintain schema file.
The solution is to split up your schema definition, resolvers, and data sources into separate files. One common pattern is to organize by feature or domain:
src/
author/
schema.js
resolvers.js
dataSource.js
post/
schema.js
resolvers.js
dataSource.js
schema.js
Each feature has its own schema, resolvers, and data access in separate files. The top-level schema.js file is responsible for combining all the individual schemas together.
You can further modularize by separating the type definitions in each feature:
src/
author/
schema/
Author.js
Query.js
Mutation.js
resolvers.js
dataSource.js
Now each type has its own file containing just the relevant parts of the schema. The schema.js file in each feature simply combines all its types together and exports them:
import Author from ‘./Author‘;
import Query from ‘./Query‘;
import Mutation from ‘./Mutation‘;
export default [
Author,
Query,
Mutation,
];
Structuring your files this way keeps each part focused and avoids a single massive schema file. Defining the schema in smaller chunks is more manageable and promotes code reuse.
Combining Multiple Schemas
Large applications often have multiple GraphQL APIs, each with their own schema. For example, you might have separate APIs for:
- User authentication and profiles
- Blog posts and comments
- Product catalog and orders
- Search and recommendations
Each of these domains has unique types, queries, and mutations. They may be developed by different teams and backed by separate databases and services.
graphql-tools provides an easy way to combine multiple schemas together and expose them through a single GraphQL endpoint. You can use the mergeSchemas function:
import { mergeSchemas } from ‘graphql-tools‘;
import {
schema as authSchema,
resolvers as authResolvers,
} from ‘./auth/schema‘;
import {
schema as blogSchema,
resolvers as blogResolvers,
} from ‘./blog/schema‘;
import {
schema as productSchema,
resolvers as productResolvers,
} from ‘./product/schema‘;
const baseSchema = `
type Query {
_empty: String
}
type Mutation {
_empty: String
}
`;
const schema = mergeSchemas({
schemas: [
baseSchema,
authSchema,
blogSchema,
productSchema,
],
resolvers: [
authResolvers,
blogResolvers,
productResolvers,
],
});
Here we have three different schemas, each with their own set of resolvers. We also define a base schema with empty Query and Mutation types. This allows us to extend those root types in each domain-specific schema.
Calling mergeSchemas combines the base schema and all the domain schemas into a single master schema. The resolvers option specifies the resolvers to use for each schema. The result is a complete, executable schema with all the types, queries, and mutations from every domain.
One convenient feature of mergeSchemas is that it detects and automatically resolves any type conflicts between schemas. For example, say the Auth and Blog schemas both define a User type, but with different fields. mergeSchemas will combine the fields of both User types into a single type in the final schema.
However, type conflicts can still cause issues if the naming isn‘t consistent. It‘s a good idea to have a convention for type names to avoid collisions. For example, prefixing types with the domain name like AuthUser or BlogUser.
With mergeSchemas, you can develop each domain schema independently but still present a unified API to clients. This allows for more modular architectures and decoupled services. Teams can work on different parts of the overall schema without interfering with or waiting on each other.
Next Steps
You now have the tools and knowledge to create GraphQL schemas more efficiently using graphql-tools. Some next steps and more advanced topics to explore:
- Mocking resolvers for testing
- Integrating with databases and other data sources
- Authentication and authorization
- Caching and performance optimizations
- Generating schemas from TypeScript types
- Schema stitching to combine schemas from external services
The GraphQL ecosystem is constantly evolving, so it‘s worth staying up to date on the latest tools and best practices. Following the Apollo blog and other GraphQL resources is a great way to keep learning.
Conclusion
graphql-tools is a powerful library that makes GraphQL development more productive and enjoyable. By defining schemas declaratively and splitting them into domains, you can reduce boilerplate and focus on the important parts of your API.
The core idea is to use makeExecutableSchema to generate a complete schema from the schema definition string and resolver functions. Organizing your project by feature, with separate files for schema, resolvers, and data access, keeps the code modular and maintainable. Combining multiple domain schemas is simple with mergeSchemas.
These patterns and conventions may take some practice to apply effectively, but they scale well to large, complex GraphQL projects. There‘s a lot more to learn, but adopting graphql-tools will give you a solid foundation to build on.