GraphQL with Express and Apollo Server lets you build flexible APIs. The most surprising truth is that GraphQL isn’t just about "what you ask for is what you get" — it’s a powerful abstraction that lets your frontend dictate the shape of your data, while your backend focuses on resolving that data efficiently, often by composing smaller, independent data sources.
Let’s see it in action. Imagine a simple Express app:
// server.js
const express = require('express');
const { ApolloServer, gql } = require('apollo-server-express');
// 1. Define your GraphQL schema
const typeDefs = gql`
type Book {
title: String
author: String
}
type Query {
books: [Book]
}
`;
// 2. Define your resolvers (how to fetch the data)
const resolvers = {
Query: {
books: () => [
{ title: 'The Hitchhiker\'s Guide to the Galaxy', author: 'Douglas Adams' },
{ title: 'Pride and Prejudice', author: 'Jane Austen' },
],
},
};
async function startApolloServer() {
const server = new ApolloServer({ typeDefs, resolvers });
await server.start();
const app = express();
server.applyMiddleware({ app, path: '/graphql' }); // Mount Apollo Server
const PORT = 4000;
app.listen(PORT, () => {
console.log(`🚀 Server ready at http://localhost:${PORT}/graphql`);
});
}
startApolloServer();
When you run this with node server.js, you can open http://localhost:4000/graphql in your browser. This is Apollo Server’s built-in GraphQL Playground.
Here’s a query you can run:
query GetBooks {
books {
title
author
}
}
And the response you’d get:
{
"data": {
"books": [
{
"title": "The Hitchhiker's Guide to the Galaxy",
"author": "Douglas Adams"
},
{
"title": "Pride and Prejudice",
"author": "Jane Austen"
}
]
}
}
Now, if your frontend only needed the book titles, you’d modify the query:
query GetBookTitles {
books {
title
}
}
The response would then be:
{
"data": {
"books": [
{
"title": "The Hitchhiker's Guide to the Galaxy"
},
{
"title": "Pride and Prejudice"
}
]
}
}
Notice how the backend code (server.js) didn’t change. The Query.books resolver still returns both title and author. Apollo Server intelligently omits the author field from the response because the client didn’t request it. This is the core of GraphQL’s efficiency: over-fetching is eliminated.
The mental model for Apollo Server breaks down into three key parts:
-
Schema Definition Language (SDL): This is your API’s contract. It defines all the types of data your API can return (e.g.,
Book,User) and the entry points for fetching data (yourQuerytype) or modifying data (yourMutationtype). It’s a strongly typed language, so you define fields and their types (e.g.,title: String!,age: Int). -
Resolvers: These are functions that tell Apollo Server how to fetch the data for each field defined in your schema. For a
Queryfield likebooks, the resolverQuery.booksis called. For a field within a type, likeBook.author, you’d have aBook.authorresolver. If a resolver isn’t explicitly defined for a field, Apollo Server will try to find a property with the same name on the parent object. This is called a "default resolver." -
Apollo Server Instance: This orchestrates everything. You create an
ApolloServerinstance, passing it yourtypeDefsandresolvers. You then start the server and apply it as middleware to your Express application, typically at a specific path like/graphql.
The power comes from how resolvers can be composed. Imagine your books resolver doesn’t just return a hardcoded array. Instead, it might fetch data from a database, then call another resolver to fetch author details from a separate microservice. Apollo Server’s data source capabilities (ApolloDataSource) and the concept of a "GraphQL context" are crucial here. The context is an object that’s available to every resolver in a request, allowing you to share things like database connections, authenticated user information, or even other data sources.
A common pattern is to define your resolvers using an object structure that mirrors your schema:
const resolvers = {
Query: {
// Resolver for the 'books' query
books: (parent, args, context, info) => {
// Access context (e.g., db connection) if needed
// return db.getBooks();
return [{ title: 'Book A', authorId: 'auth1' }, { title: 'Book B', authorId: 'auth2' }];
},
},
Book: {
// Resolver for the 'author' field on the 'Book' type
author: (parent, args, context, info) => {
// 'parent' here is the book object returned by Query.books
// parent.authorId would be 'auth1' or 'auth2'
// return context.dataSources.authorAPI.getAuthor(parent.authorId);
return `Author for ID ${parent.authorId}`; // Simplified for example
},
},
};
When a query for books { title author } comes in, Apollo Server first calls Query.books. Let’s say it returns [{ title: 'Book A', authorId: 'auth1' }]. Then, for each book in that result, it needs to resolve the author field. It will call the Book.author resolver, passing the individual book object as the parent argument. This ability to chain resolvers and resolve fields independently is what makes GraphQL so adaptable.
When you define a Mutation in your schema, its resolvers work similarly but represent operations that change data. For example, a createUser mutation would have a Mutation.createUser resolver that handles the logic for creating a user in your database.
The one thing most people don’t realize is how deeply Apollo Server optimizes field resolution. If a query asks for books { title }, and your Book type also has an author field defined in the schema, Apollo Server will not even attempt to execute the Book.author resolver. It’s not just about not sending the data back; it’s about not doing the work to fetch it in the first place, which can save significant backend resources if author resolution is expensive.
The next step is usually integrating actual data sources and handling more complex relationships between types.