Aller au contenu principal

GraphQL Resolver with Apollo Server

This example shows how to create professional GraphQL resolvers with Apollo Server, TypeScript and validation.

Command used

/dev:dev-graphql "Create queries and mutations for users"

Generated structure

src/graphql/
├── schema/
│ ├── typeDefs.ts # GraphQL schema
│ └── index.ts
├── resolvers/
│ ├── userResolver.ts # User resolvers
│ ├── index.ts
│ └── scalars.ts # Custom scalars
├── dataloaders/
│ └── userLoader.ts # DataLoader for N+1
├── context.ts # GraphQL context
└── server.ts # Apollo configuration

Resolver code

schema/typeDefs.ts

import { gql } from 'graphql-tag';

export const typeDefs = gql`
scalar DateTime

enum Role {
ADMIN
USER
GUEST
}

enum SortOrder {
ASC
DESC
}

type User {
id: ID!
email: String!
name: String
avatarUrl: String
role: Role!
posts: [Post!]!
postsCount: Int!
createdAt: DateTime!
updatedAt: DateTime!
}

type Post {
id: ID!
title: String!
content: String!
author: User!
published: Boolean!
createdAt: DateTime!
}

type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}

type UserEdge {
node: User!
cursor: String!
}

type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}

input CreateUserInput {
email: String!
name: String
role: Role
}

input UpdateUserInput {
email: String
name: String
role: Role
}

input UsersFilterInput {
search: String
role: Role
}

input UsersSortInput {
field: UserSortField!
order: SortOrder!
}

enum UserSortField {
NAME
EMAIL
CREATED_AT
}

type Query {
# Get a user by ID
user(id: ID!): User

# Get the currently logged-in user
me: User

# List users with cursor-based pagination
users(
first: Int
after: String
last: Int
before: String
filter: UsersFilterInput
sort: UsersSortInput
): UserConnection!
}

type Mutation {
# Create a new user
createUser(input: CreateUserInput!): User!

# Update a user
updateUser(id: ID!, input: UpdateUserInput!): User!

# Delete a user
deleteUser(id: ID!): Boolean!

# Change a user's role (admin only)
changeUserRole(id: ID!, role: Role!): User!
}
`;

resolvers/userResolver.ts

import { GraphQLError } from 'graphql';
import { z } from 'zod';
import type { Context } from '../context';
import { prisma } from '../../lib/prisma';
import { encodeCursor, decodeCursor } from '../../utils/cursor';

// Validation schemas
const createUserSchema = z.object({
email: z.string().email('Invalid email'),
name: z.string().min(2).max(100).optional(),
role: z.enum(['ADMIN', 'USER', 'GUEST']).default('USER'),
});

const updateUserSchema = z.object({
email: z.string().email('Invalid email').optional(),
name: z.string().min(2).max(100).optional(),
role: z.enum(['ADMIN', 'USER', 'GUEST']).optional(),
});

export const userResolver = {
Query: {
user: async (_: unknown, { id }: { id: string }) => {
return prisma.user.findUnique({ where: { id } });
},

me: async (_: unknown, __: unknown, { user }: Context) => {
if (!user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return prisma.user.findUnique({ where: { id: user.id } });
},

users: async (
_: unknown,
args: {
first?: number;
after?: string;
last?: number;
before?: string;
filter?: { search?: string; role?: string };
sort?: { field: string; order: 'ASC' | 'DESC' };
}
) => {
const { first = 10, after, last, before, filter, sort } = args;
const take = first || last || 10;
const cursor = after ? decodeCursor(after): before ? decodeCursor(before): undefined;

// Build the filter
const where = {
...(filter?.search && {
OR: [
{ name: { contains: filter.search, mode: 'insensitive' as const } },
{ email: { contains: filter.search, mode: 'insensitive' as const } },
],
}),
...(filter?.role && { role: filter.role }),
};

// Sort order
const orderBy = sort
? { [sort.field.toLowerCase()]: sort.order.toLowerCase() }
: { createdAt: 'desc' as const };

// Queries
const [users, totalCount] = await Promise.all([
prisma.user.findMany({
where,
take: take + 1, // +1 to know if there is a next page
skip: cursor ? 1: 0,
cursor: cursor ? { id: cursor }: undefined,
orderBy,
}),
prisma.user.count({ where }),
]);

const hasMore = users.length > take;
const nodes = hasMore ? users.slice(0, -1): users;

return {
edges: nodes.map((user) => ({
node: user,
cursor: encodeCursor(user.id),
})),
pageInfo: {
hasNextPage: hasMore,
hasPreviousPage: !!cursor,
startCursor: nodes[0] ? encodeCursor(nodes[0].id): null,
endCursor: nodes[nodes.length - 1]
? encodeCursor(nodes[nodes.length - 1].id)
: null,
},
totalCount,
};
},
},

Mutation: {
createUser: async (
_: unknown,
{ input }: { input: z.infer<typeof createUserSchema> },
{ user }: Context
) => {
// Validation
const data = createUserSchema.parse(input);

// Check that the email does not already exist
const existing = await prisma.user.findUnique({
where: { email: data.email },
});

if (existing) {
throw new GraphQLError('A user with this email already exists', {
extensions: { code: 'BAD_USER_INPUT', field: 'email' },
});
}

return prisma.user.create({ data });
},

updateUser: async (
_: unknown,
{ id, input }: { id: string; input: z.infer<typeof updateUserSchema> },
{ user }: Context
) => {
if (!user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}

// Validation
const data = updateUserSchema.parse(input);

// Check that the user exists
const existing = await prisma.user.findUnique({ where: { id } });

if (!existing) {
throw new GraphQLError('User not found', {
extensions: { code: 'NOT_FOUND' },
});
}

// Check permissions (self or admin)
if (existing.id !== user.id && user.role !== 'ADMIN') {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
}

// Check email uniqueness if changed
if (data.email && data.email !== existing.email) {
const emailExists = await prisma.user.findUnique({
where: { email: data.email },
});
if (emailExists) {
throw new GraphQLError('This email is already in use', {
extensions: { code: 'BAD_USER_INPUT', field: 'email' },
});
}
}

return prisma.user.update({ where: { id }, data });
},

deleteUser: async (
_: unknown,
{ id }: { id: string },
{ user }: Context
) => {
if (!user || user.role !== 'ADMIN') {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
}

await prisma.user.delete({ where: { id } });
return true;
},

changeUserRole: async (
_: unknown,
{ id, role }: { id: string; role: string },
{ user }: Context
) => {
if (!user || user.role !== 'ADMIN') {
throw new GraphQLError('Only admins can change roles', {
extensions: { code: 'FORBIDDEN' },
});
}

return prisma.user.update({
where: { id },
data: { role },
});
},
},

// Field resolvers
User: {
posts: async (parent: { id: string }, _: unknown, { loaders }: Context) => {
// Use DataLoader to avoid N+1
return loaders.postsByAuthorId.load(parent.id);
},

postsCount: async (parent: { id: string }) => {
return prisma.post.count({ where: { authorId: parent.id } });
},
},
};

dataloaders/userLoader.ts

import DataLoader from 'dataloader';
import { prisma } from '../../lib/prisma';

export const createLoaders = () => ({
// Load users by ID (to avoid N+1 on author)
userById: new DataLoader<string, any>(async (ids) => {
const users = await prisma.user.findMany({
where: { id: { in: [...ids] } },
});

const userMap = new Map(users.map((u) => [u.id, u]));
return ids.map((id) => userMap.get(id) || null);
}),

// Load posts by authorId (for the posts field resolver)
postsByAuthorId: new DataLoader<string, any[]>(async (authorIds) => {
const posts = await prisma.post.findMany({
where: { authorId: { in: [...authorIds] } },
});

const postsByAuthor = new Map<string, any[]>();
posts.forEach((post) => {
const existing = postsByAuthor.get(post.authorId) || [];
existing.push(post);
postsByAuthor.set(post.authorId, existing);
});

return authorIds.map((id) => postsByAuthor.get(id) || []);
}),
});

context.ts

import type { Request } from 'express';
import { createLoaders } from './dataloaders/userLoader';
import { verifyToken } from '../utils/jwt';

export interface Context {
user: {
id: string;
email: string;
role: string;
} | null;
loaders: ReturnType<typeof createLoaders>;
}

export async function createContext({ req }: { req: Request }): Promise<Context> {
// Extract the token from the Authorization header
const token = req.headers.authorization?.replace('Bearer ', '');

let user = null;
if (token) {
try {
user = verifyToken(token);
} catch {
// Invalid token, user stays null
}
}

return {
user,
loaders: createLoaders(),
};
}

server.ts

import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import express from 'express';
import http from 'http';
import cors from 'cors';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext } from './context';

async function startServer() {
const app = express();
const httpServer = http.createServer(app);

const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
formatError: (formattedError, error) => {
// Log the full error on the server side
console.error('GraphQL Error:', error);

// Do not expose internal error details in production
if (process.env.NODE_ENV === 'production') {
if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return {
message: 'An internal error occurred',
extensions: { code: 'INTERNAL_SERVER_ERROR' },
};
}
}

return formattedError;
},
});

await server.start();

app.use(
'/graphql',
cors<cors.CorsRequest>(),
express.json(),
expressMiddleware(server, {
context: createContext,
})
);

const PORT = process.env.PORT || 4000;
await new Promise<void>((resolve) => {
httpServer.listen({ port: PORT }, resolve);
});

console.log(`🚀 Server ready at http://localhost:${PORT}/graphql`);
}

startServer().catch(console.error);

Example queries

Query

# Get the currently logged-in user
query Me {
me {
id
email
name
role
postsCount
}
}

# List with pagination and filters
query Users($first: Int, $after: String, $filter: UsersFilterInput) {
users(first: $first, after: $after, filter: $filter) {
edges {
node {
id
email
name
role
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}

Mutation

mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
email
name
role
}
}

mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {
updateUser(id: $id, input: $input) {
id
email
name
}
}

Key points

AspectImplementation
PaginationCursor-based (Relay style)
N+1DataLoader for relations
ValidationZod inside the resolvers
ErrorsGraphQLError with codes
AuthContext with user and loaders
  • /dev:dev-test - Generate resolver tests
  • /qa:qa-security - GraphQL security audit
  • /doc:doc-api-spec - Schema documentation

Codegen

Use graphql-codegen to generate the TypeScript types from the schema:

npx graphql-codegen