Schema Design and Types
Schema Design and Types
SDL (Schema Definition Language)
GraphQL schemas are written in SDL, a type system that describes what queries are possible and what types they return. Every GraphQL API has a root schema with three operation types: Query, Mutation, and Subscription.
# schema.graphql
type Query {
user(id: ID!): User
users(filter: UserFilter, pagination: PaginationInput): UserConnection!
post(slug: String!): Post
posts(filter: PostFilter, pagination: PaginationInput): PostConnection!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
login(email: String!, password: String!): AuthPayload!
}
type Subscription {
postCreated: Post!
commentAdded(postId: ID!): Comment!
}
Scalar and Object Types
GraphQL has five built-in scalars (Int, Float, String, Boolean, ID) and you can define custom scalars like DateTime. Object types define the shape of data. Non-null markers (!) enforce that a field always returns a value.
type User {
id: ID!
name: String!
email: String!
role: Role!
posts(first: Int, after: String): PostConnection!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
slug: String!
content: String!
published: Boolean!
author: User!
tags: [String!]!
comments(first: Int, after: String): CommentConnection!
commentCount: Int!
createdAt: DateTime!
updatedAt: DateTime!
}
type Comment {
id: ID!
content: String!
author: User
replies: [Comment!]!
createdAt: DateTime!
}
enum Role { USER EDITOR ADMIN }
scalar DateTime
Relay-Style Connection Pattern
For pagination, GraphQL uses the Relay connection specification. A Connection type wraps a list of edges, each containing a node (the actual data) and a cursor for pagination. PageInfo tells the client whether more pages exist.
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
Input Types
Use input types for mutation arguments instead of passing multiple scalar parameters. This keeps mutations clean and makes validation easier on the server side.
input CreatePostInput {
title: String!
content: String!
tags: [String!]
published: Boolean = false
}
input UpdatePostInput {
title: String
content: String
tags: [String!]
published: Boolean
}
input PostFilter {
status: PostStatus
authorId: ID
search: String
}
input PaginationInput {
first: Int
after: String
}
Resolvers and Data Loaders
Resolvers and Data Loaders
Apollo Server Setup
Apollo Server is the most popular Node.js GraphQL server. It handles parsing queries, executing resolvers, and formatting errors. The context function runs per-request and is where you attach database connections, authentication info, and DataLoaders.
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { typeDefs } from './schema.js';
import { resolvers } from './resolvers.js';
import { createContext } from './context.js';
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
formatError: (formattedError, error) => {
console.error('GraphQL Error:', error);
if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return { ...formattedError, message: 'Internal server error' };
}
return formattedError;
},
});
await server.start();
app.use('/graphql', express.json(), expressMiddleware(server, {
context: async ({ req }) => createContext({ req }),
}));
Resolver Structure
Every resolver receives four arguments: parent (the previous resolver's return value), args (arguments from the query), context (per-request shared state), and info (query metadata). Field-level resolvers let you resolve nested relationships lazily.
import { GraphQLError } from 'graphql';
export const resolvers = {
Query: {
user: async (_, { id }, { loaders }) => {
return loaders.user.load(id);
},
users: async (_, { filter, pagination }, { db }) => {
const where = buildUserFilter(filter);
const limit = pagination?.first || 20;
const cursor = pagination?.after ? decodeCursor(pagination.after) : null;
if (cursor) where.created_at = { $lt: cursor };
const users = await db.users.find(where)
.sort({ created_at: -1 })
.limit(limit + 1);
const hasNext = users.length > limit;
const data = hasNext ? users.slice(0, limit) : users;
return {
edges: data.map(u => ({ node: u, cursor: u.created_at.toISOString() })),
pageInfo: {
hasNextPage: hasNext,
endCursor: data.length ? data[data.length - 1].created_at.toISOString() : null,
},
totalCount: await db.users.count(where),
};
},
},
User: {
posts: async (parent, { first, after }, { loaders }) => {
return loaders.userPosts.load({ userId: parent.id, first, after });
},
},
Post: {
author: async (parent, _, { loaders }) => {
return loaders.user.load(parent.authorId);
},
comments: async (parent, { first, after }, { loaders }) => {
return loaders.postComments.load({ postId: parent.id, first, after });
},
},
};
DataLoader for the N+1 Problem
The N+1 problem occurs when resolving a list of items triggers one database query per item for a related field. If you query 100 posts and each triggers a SELECT * FROM users WHERE id = ? for the author, you get 101 queries. DataLoader batches and deduplicates within a single event loop tick.
import DataLoader from 'dataloader';
function createLoaders(db) {
return {
user: new DataLoader(async (ids) => {
const users = await db.users.findByIds(ids);
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id) || null);
}),
userPosts: new DataLoader(async (queries) => {
const userIds = queries.map(q => q.userId);
const posts = await db.posts.find({
authorId: { $in: userIds },
published: true,
});
const grouped = new Map(userIds.map(id => [id, []]));
posts.forEach(p => {
const group = grouped.get(p.authorId);
if (group) group.push(p);
});
return queries.map(q => {
const items = grouped.get(q.userId) || [];
return items.slice(0, q.first || 20);
});
}),
};
}
The key insight is that DataLoader's load(id) method does not execute immediately. It collects all calls made during the same tick and dispatches them as a single batch. The loader function receives an array of IDs in the same order they were requested, and must return results in that same order.
Client-Side GraphQL with Apollo
Client-Side GraphQL with Apollo
Apollo Client Setup
Apollo Client manages the cache, handles request deduplication, and provides reactive hooks for React components. The InMemoryCache normalizes responses so every object is stored once by its id, enabling automatic updates when mutations modify related data.
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
const httpLink = createHttpLink({ uri: '/graphql' });
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('accessToken');
return {
headers: { ...headers, authorization: token ? `Bearer ${token}` : '' },
};
});
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
keyArgs: ['filter'],
merge(existing, { edges, pageInfo, totalCount }, { args }) {
return {
edges: [...(existing?.edges || []), ...edges],
pageInfo,
totalCount,
};
},
},
},
},
},
}),
});
The keyArgs option tells the cache which arguments affect the identity of the field. Setting keyArgs: ['filter'] means the same filter always maps to the same cache entry, while pagination arguments are ignored for cache keys — enabling fetchMore to append results.
Queries and Mutations with Hooks
import { useQuery, useMutation, gql } from '@apollo/client';
const GET_POSTS = gql`
query GetPosts($filter: PostFilter, $pagination: PaginationInput) {
posts(filter: $filter, pagination: $pagination) {
edges {
node { id title slug author { name } createdAt }
cursor
}
pageInfo { hasNextPage endCursor }
totalCount
}
}
`;
const CREATE_POST = gql`
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id title slug
}
}
`;
function PostList() {
const { data, loading, error, fetchMore } = useQuery(GET_POSTS, {
variables: { filter: { status: 'published' }, pagination: { first: 10 } },
});
const [createPost] = useMutation(CREATE_POST, {
refetchQueries: [{ query: GET_POSTS }],
});
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return (
<div>
{data.posts.edges.map(({ node }) => (
<article key={node.id}>
<h2>{node.title}</h2>
<p>by {node.author.name}</p>
</article>
))}
{data.posts.pageInfo.hasNextPage && (
<button onClick={() => fetchMore({
variables: { pagination: { after: data.posts.pageInfo.endCursor } },
})}>Load more</button>
)}
</div>
);
}
Optimistic UI and Cache Updates
Apollo Client can update the UI immediately before the server responds, making mutations feel instant. Use optimisticResponse for simple cases and update function for complex cache modifications.
const [createPost] = useMutation(CREATE_POST, {
optimisticResponse: {
createPost: {
__typename: 'Post',
id: 'temp-id',
title: variables.input.title,
slug: variables.input.title.toLowerCase().replace(/\s+/g, '-'),
author: { __typename: 'User', id: 'current-user-id', name: 'You' },
createdAt: new Date().toISOString(),
},
},
update(cache, { data: { createPost } }) {
const existing = cache.readQuery({ query: GET_POSTS });
cache.writeQuery({
query: GET_POSTS,
data: {
posts: {
...existing.posts,
edges: [{ __typename: 'PostEdge', node: createPost, cursor: createPost.id },
...existing.posts.edges],
},
},
});
},
});
Subscriptions and Authentication
Subscriptions and Authentication
GraphQL Subscriptions
Subscriptions provide real-time data over WebSocket connections. They are built on the publish-subscribe pattern: the server publishes events when data changes, and subscribed clients receive those events. Apollo Server uses graphql-ws for the WebSocket transport.
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
const COMMENT_ADDED = 'COMMENT_ADDED';
const typeDefs = makeExecutableSchema({
typeDefs: `type Subscription {
commentAdded(postId: ID!): Comment!
}`,
resolvers: {
Subscription: {
commentAdded: {
subscribe: (_, { postId }, { user }) => {
if (!user) throw new Error('Not authenticated');
return pubsub.asyncIterator(`COMMENT_ADDED_${postId}`);
},
},
},
},
});
// In a mutation resolver, publish after creating the comment
const resolvers = {
Mutation: {
addComment: async (_, { postId, content }, { user, db }) => {
const comment = await db.comments.create({
postId, content, authorId: user.id,
});
pubsub.publish(`COMMENT_ADDED_${postId}`, {
commentAdded: comment,
});
return comment;
},
},
};
WebSocket Server Setup
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { expressMiddleware } from '@apollo/server/express4';
const httpServer = createServer(app);
const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' });
const serverCleanup = useServer({
schema,
context: async (ctx) => {
const token = ctx.connectionParams?.authToken;
const user = token ? await verifyToken(token) : null;
return { user };
},
}, wsServer);
const server = new ApolloServer({
schema,
plugins: [
ApolloServerPluginDrainHttpServer({ httpServer }),
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
},
};
},
},
],
});
Context-Based Authentication
GraphQL does not have built-in HTTP methods, so authentication is handled in the context function. Every resolver can access the authenticated user through context.
import jwt from 'jsonwebtoken';
async function createContext({ req }) {
const token = req.headers.authorization?.replace('Bearer ', '');
let user = null;
if (token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
user = await db.users.findById(decoded.userId);
} catch (err) {
// Token invalid or expired — proceed as unauthenticated
}
}
return {
user,
loaders: createLoaders(db),
db,
};
}
Authorization Directives
Use custom directives to enforce authorization at the schema level. This keeps authorization logic out of resolver code and makes permissions visible in the schema itself.
directive @auth(requires: Role = USER) on FIELD_DEFINITION
type Mutation {
createPost(input: CreatePostInput!): Post! @auth(requires: USER)
deletePost(id: ID!): Boolean! @auth(requires: EDITOR)
manageUsers: [User!]! @auth(requires: ADMIN)
}
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
function authDirectiveTransformer(schema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
if (!authDirective) return fieldConfig;
const { resolve = defaultFieldResolver } = fieldConfig;
const requiredRole = authDirective.requires;
fieldConfig.resolve = async (parent, args, context, info) => {
if (!context.user) throw new GraphQLError('Not authenticated');
if (!hasRole(context.user, requiredRole)) {
throw new GraphQLError('Insufficient permissions');
}
return resolve(parent, args, context, info);
};
return fieldConfig;
},
});
}
Quiz
1. What problem does DataLoader solve in a GraphQL API?
2. Why does GraphQL use cursor-based pagination instead of offset-based?
3. What is the purpose of the `context` function in Apollo Server?
Flashcards
Question
What is the N+1 problem in GraphQL?
Click to reveal answer
Answer
The N+1 problem occurs when resolving a list of N items triggers N additional queries for a related field. For example, fetching 100 posts and then running a separate query for each post's author results in 101 database queries. DataLoader fixes this by batching all the author lookups into a single query.
Question
What is the Relay connection specification?
Click to reveal answer
Answer
A pagination pattern where a Connection type contains edges (each with a node and cursor) and a PageInfo object (hasNextPage, endCursor). The cursor is an opaque string that clients pass as the `after` argument to fetch the next page. This standardizes cursor-based pagination across GraphQL APIs.
Question
How does Apollo Client's InMemoryCache normalization work?
Click to reveal answer
Answer
Apollo Client extracts every object with an `id` field and stores it once in a flat map keyed by `__typename:id`. When a query returns `{ user: { id: '1', name: 'Alice' } }`, the cache stores it as `User:1`. Any other query returning the same object updates the same cache entry, so all components re-render with the new data automatically.
Revision Notes
Key Takeaways
- 1. GraphQL schemas are defined in SDL with Query, Mutation, and Subscription as root operation types
- 2. Use the Relay connection pattern (edges/node/cursor/pageInfo) for all list pagination
- 3. Input types keep mutation signatures clean and enable server-side validation
- 4. DataLoader batches and deduplicates database queries within a single event loop tick to solve N+1
- 5. The context function runs per-request and provides auth, db, and loaders to all resolvers
- 6. Apollo Client's InMemoryCache normalizes objects by __typename:id for automatic cache updates
- 7. Subscriptions use WebSocket connections and the pub/sub pattern for real-time data
- 8. Custom schema directives like @auth enforce authorization rules declaratively
Interview Tips
- • Explain the N+1 problem with a concrete example and how DataLoader's batch function solves it
- • Compare REST and GraphQL tradeoffs: overfetching vs overcomplicating, caching, file uploads, error handling
- • Walk through how you would design a GraphQL schema for a blog platform with users, posts, and comments
- • Describe how cursor pagination works and why it is better than offset for large datasets
- • Explain how Apollo Client normalization enables automatic UI updates after mutations
- • Discuss GraphQL security: query depth limiting, query complexity analysis, and persisted queries
Cheat Sheet
GraphQL Cheat Sheet
Schema Types:
- Scalar: Int, Float, String, Boolean, ID + custom (DateTime)
- Object:
type User { id: ID!, name: String! } - Enum:
enum Role { USER EDITOR ADMIN } - Input:
input CreateUserInput { name: String! } - Interface & Union: for polymorphic types
Pagination Pattern (Relay):
type Connection { edges: [Edge!]!, pageInfo: PageInfo!, totalCount: Int! }
type Edge { node: Node!, cursor: String! }
type PageInfo { hasNextPage: Boolean!, endCursor: String }
Query: users(pagination: { first: 10, after: "cursor" })
Resolver Signature:(parent, args, context, info) => Promise<T>
- parent: previous resolver return value
- args: query arguments
- context: per-request shared state (db, user, loaders)
- info: query metadata
DataLoader Pattern:
new DataLoader(async (ids) => {
const items = await db.findByIds(ids);
return ids.map(id => items.find(i => i.id === id) || null);
});
Apollo Client Key Concepts:
InMemoryCachenormalizes by__typename:idkeyArgscontrols which arguments affect cache identityfetchMoreappends to existing cache entriesoptimisticResponseupdates UI before server respondsrefetchQueriesre-runs queries after mutation
Subscriptions:
- Transport: WebSocket (graphql-ws protocol)
- Pattern: publish/subscribe with PubSub
- Setup:
subscriberesolver returns an AsyncIterator
Security:
- Disable introspection in production
- Limit query depth (5-10 levels)
- Use query complexity analysis
- Persisted queries for production clients
- Auth via context, not HTTP headers per-field