Construyendo un servidor GraphQL desde cero
Tutorial paso a paso de un servidor GraphQL con TypeScript: diseño del esquema, resolvers, dataloaders contra N+1, autenticación, errores y despliegue.

GraphQL resuelve los problemas de over-fetching y under-fetching de REST permitiendo que los clientes soliciten exactamente los datos que necesitan. En lugar de llamar a 3 endpoints para renderizar una página de perfil de usuario (usuario, posts, seguidores), una sola consulta GraphQL devuelve los tres en la forma que el cliente especifica.
Este tutorial construye un servidor GraphQL listo para producción desde cero usando TypeScript y Apollo Server. Al final, tendrás una API funcional con diseño de esquema, resolvers, prevención del problema N+1, autenticación y manejo de errores.
Diseño del esquema
El esquema define el contrato de tu API: los tipos, consultas y mutaciones disponibles para los clientes. Piénsalo como una interfaz tipada para toda la superficie de tu API.
# schema.graphql
type User {
id: ID!
email: String!
name: String!
role: Role!
posts(limit: Int = 10, offset: Int = 0): [Post!]!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
content: String!
published: Boolean!
author: User!
tags: [Tag!]!
createdAt: DateTime!
updatedAt: DateTime!
}
type Tag {
id: ID!
name: String!
posts: [Post!]!
}
enum Role {
USER
ADMIN
EDITOR
}
scalar DateTime
type Query {
user(id: ID!): User
me: User
posts(
limit: Int = 20
offset: Int = 0
published: Boolean
): [Post!]!
post(id: ID!): Post
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
input CreatePostInput {
title: String!
content: String!
published: Boolean = false
tagIds: [ID!] = []
}
input UpdatePostInput {
title: String
content: String
published: Boolean
}Implementación de los resolvers
Los resolvers son funciones que obtienen los datos de cada campo de tu esquema. La cadena de resolvers comienza en la raíz de la consulta y recorre el grafo de tipos, llamando a los resolvers de cada campo que el cliente solicita.
import { Resolvers } from "./generated/types";
const resolvers: Resolvers = {
Query: {
user: async (_parent, { id }, context) => {
return context.db.user.findUnique({ where: { id } });
},
me: async (_parent, _args, context) => {
if (!context.currentUser) return null;
return context.db.user.findUnique({
where: { id: context.currentUser.id },
});
},
posts: async (_parent, { limit, offset, published }, context) => {
return context.db.post.findMany({
where: published !== undefined ? { published } : {},
take: Math.min(limit ?? 20, 100),
skip: offset ?? 0,
orderBy: { createdAt: "desc" },
});
},
post: async (_parent, { id }, context) => {
return context.db.post.findUnique({ where: { id } });
},
},
Mutation: {
createPost: async (_parent, { input }, context) => {
if (!context.currentUser) {
throw new AuthenticationError("Must be logged in");
}
return context.db.post.create({
data: {
...input,
authorId: context.currentUser.id,
tags: input.tagIds?.length
? { connect: input.tagIds.map((id) => ({ id })) }
: undefined,
},
});
},
updatePost: async (_parent, { id, input }, context) => {
const post = await context.db.post.findUnique({ where: { id } });
if (!post) throw new NotFoundError("Post not found");
if (post.authorId !== context.currentUser?.id) {
throw new ForbiddenError("Not authorized to edit this post");
}
return context.db.post.update({ where: { id }, data: input });
},
deletePost: async (_parent, { id }, context) => {
await context.db.post.delete({ where: { id } });
return true;
},
},
// Field resolvers for nested types
User: {
posts: async (user, { limit, offset }, context) => {
return context.db.post.findMany({
where: { authorId: user.id },
take: limit ?? 10,
skip: offset ?? 0,
});
},
},
Post: {
author: async (post, _args, context) => {
return context.db.user.findUnique({
where: { id: post.authorId },
});
},
tags: async (post, _args, context) => {
return context.db.tag.findMany({
where: { posts: { some: { id: post.id } } },
});
},
},
};Resolviendo el problema N+1 con DataLoader
Sin DataLoader, consultar 20 posts dispara 20 consultas separadas para sus autores: el clásico problema N+1. DataLoader las agrupa en una sola consulta.
import DataLoader from "dataloader";
// ❌ Without DataLoader: N+1 queries
// Query: posts(limit: 20) { author { name } }
// SQL: SELECT * FROM posts LIMIT 20
// SQL: SELECT * FROM users WHERE id = 1 (for post 1)
// SQL: SELECT * FROM users WHERE id = 2 (for post 2)
// ... 20 individual queries for authors
// ✅ With DataLoader: 2 queries total
// SQL: SELECT * FROM posts LIMIT 20
// SQL: SELECT * FROM users WHERE id IN (1, 2, 3, ...)
interface DataLoaders {
userLoader: DataLoader<string, User>;
postTagsLoader: DataLoader<string, Tag[]>;
}
function createLoaders(db: PrismaClient): DataLoaders {
return {
userLoader: new DataLoader(async (userIds) => {
const users = await db.user.findMany({
where: { id: { in: [...userIds] } },
});
// DataLoader requires results in the same order as keys
const userMap = new Map(users.map((u) => [u.id, u]));
return userIds.map((id) => userMap.get(id) ?? null);
}),
postTagsLoader: new DataLoader(async (postIds) => {
const posts = await db.post.findMany({
where: { id: { in: [...postIds] } },
include: { tags: true },
});
const tagMap = new Map(posts.map((p) => [p.id, p.tags]));
return postIds.map((id) => tagMap.get(id) ?? []);
}),
};
}
// Updated resolver using DataLoader
const resolversWithLoader: Resolvers = {
Post: {
author: (post, _args, context) => {
return context.loaders.userLoader.load(post.authorId);
},
tags: (post, _args, context) => {
return context.loaders.postTagsLoader.load(post.id);
},
},
};Autenticación y contexto
El objeto de contexto se crea por cada petición y transporta el estado de autenticación, las conexiones a la base de datos y los DataLoaders.
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import jwt from "jsonwebtoken";
interface Context {
db: PrismaClient;
loaders: DataLoaders;
currentUser: User | null;
}
async function createContext(
req: express.Request
): Promise<Context> {
const db = prisma;
const loaders = createLoaders(db);
// Extract user from JWT token
const token = req.headers.authorization?.replace("Bearer ", "");
let currentUser: User | null = null;
if (token) {
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as {
userId: string;
};
currentUser = await db.user.findUnique({
where: { id: payload.userId },
});
} catch {
// Invalid token — proceed as unauthenticated
}
}
return { db, loaders, currentUser };
}
const server = new ApolloServer<Context>({
typeDefs,
resolvers,
});
await server.start();
app.use(
"/graphql",
expressMiddleware(server, {
context: async ({ req }) => createContext(req),
})
);Manejo de errores
GraphQL devuelve errores junto con datos parciales. Estructura tus errores para que los clientes puedan manejarlos de forma programática.
import { GraphQLError } from "graphql";
class AuthenticationError extends GraphQLError {
constructor(message: string) {
super(message, {
extensions: { code: "UNAUTHENTICATED", http: { status: 401 } },
});
}
}
class ForbiddenError extends GraphQLError {
constructor(message: string) {
super(message, {
extensions: { code: "FORBIDDEN", http: { status: 403 } },
});
}
}
class NotFoundError extends GraphQLError {
constructor(message: string) {
super(message, {
extensions: { code: "NOT_FOUND", http: { status: 404 } },
});
}
}
// ❌ Generic error — client can't distinguish error types
// throw new Error("Something went wrong");
// ✅ Typed error — client checks extensions.code
// throw new AuthenticationError("Must be logged in to create posts");
// Response: { errors: [{ message: "...", extensions: { code: "UNAUTHENTICATED" } }] }Conclusiones clave
- El diseño schema-first define el contrato de tu API — escribe el esquema antes que los resolvers; el esquema es documentación, validación y generación de tipos en un solo artefacto
- DataLoader es obligatorio para consultas anidadas — sin él, consultar una lista de posts con sus autores provoca N+1 consultas a la base de datos; DataLoader las agrupa en una sola
- Crea nuevos DataLoaders por cada petición — DataLoader cachea resultados dentro de una petición; reutilizarlos entre peticiones sirve datos obsoletos
- Usa clases de error tipadas con códigos de extensión — los clientes necesitan distinguir errores de autenticación de errores de no encontrado;
extensions.codeproporciona un campo estable y legible por máquinas - Limita la profundidad y complejidad de las consultas — sin límites, un cliente puede solicitar consultas profundamente anidadas que sobrecarguen tu base de datos; añade limitación de profundidad y análisis de coste de consultas


