Saltar al contenido

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.

4 min de lectura
Interfaz de GraphQL playground mostrando una consulta, una respuesta y un panel explorador del esquema lado a lado

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.

graphqlgraphql
# 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.

tstypescript
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.

tstypescript
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.

tstypescript
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.

tstypescript
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

  1. 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
  2. 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
  3. Crea nuevos DataLoaders por cada petición — DataLoader cachea resultados dentro de una petición; reutilizarlos entre peticiones sirve datos obsoletos
  4. 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.code proporciona un campo estable y legible por máquinas
  5. 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
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX