Volver al blog

GraphQL en 2025: Guía Completa con Apollo Server Para APIs Optimizadas

Hola HaWkers, GraphQL continúa ganando adopción en 2025 como una alternativa poderosa al REST tradicional. Si todavía no exploraste esta tecnología o quieres profundizar tus conocimientos, este es el momento ideal.

¿Ya te frustraste con APIs REST que retornan datos de más o de menos? GraphQL resuelve exactamente ese problema, permitiendo que el cliente solicite apenas los datos que necesita.

Por Qué GraphQL en 2025

GraphQL no vino para substituir REST completamente, pero ofrece ventajas significativas en escenarios específicos.

GraphQL

Ventajas del GraphQL

Para el Cliente:

  • Solicita exactamente los datos necesarios
  • Una única requisición para datos relacionados
  • Tipado fuerte y documentación automática
  • Introspección del schema

Para el Servidor:

  • Schema como contrato de API
  • Validación automática de queries
  • Evolución de la API sin versionamiento
  • Mejor observabilidad de uso

Cuándo Usar GraphQL

Usa GraphQL cuando:

  • Clientes diferentes necesitan datos diferentes
  • Múltiples recursos relacionados son necesarios
  • Performance de red es crítica (mobile)
  • API pública con muchos consumidores

Prefiere REST cuando:

  • Operaciones CRUD simples
  • Caching HTTP es prioritario
  • Equipo sin experiencia con GraphQL
  • Integración con sistemas legados

Configurando Apollo Server

Vamos a crear una API GraphQL completa desde cero con Apollo Server 4 y TypeScript.

Setup Inicial

// src/index.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, Context } from './context';

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

  const server = new ApolloServer<Context>({
    typeDefs,
    resolvers,
    plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
    introspection: process.env.NODE_ENV !== 'production'
  });

  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);

Definiendo el Schema

// src/schema.ts
export const typeDefs = `#graphql
  # Tipos básicos
  type Usuario {
    id: ID!
    nombre: String!
    email: String!
    avatar: String
    posts: [Post!]!
    comentarios: [Comentario!]!
    creadoEn: String!
    actualizadoEn: String!
  }

  type Post {
    id: ID!
    titulo: String!
    contenido: String!
    publicado: Boolean!
    autor: Usuario!
    categorias: [Categoria!]!
    comentarios: [Comentario!]!
    visualizaciones: Int!
    creadoEn: String!
    actualizadoEn: String!
  }

  type Categoria {
    id: ID!
    nombre: String!
    slug: String!
    posts: [Post!]!
  }

  type Comentario {
    id: ID!
    contenido: String!
    autor: Usuario!
    post: Post!
    creadoEn: String!
  }

  # Inputs para mutations
  input CrearUsuarioInput {
    nombre: String!
    email: String!
    password: String!
    avatar: String
  }

  input ActualizarUsuarioInput {
    nombre: String
    avatar: String
  }

  input CrearPostInput {
    titulo: String!
    contenido: String!
    publicado: Boolean = false
    categoriaIds: [ID!]
  }

  input ActualizarPostInput {
    titulo: String
    contenido: String
    publicado: Boolean
    categoriaIds: [ID!]
  }

  input FiltroPost {
    publicado: Boolean
    autorId: ID
    categoriaId: ID
    busqueda: String
  }

  # Paginación
  type PaginacionInfo {
    total: Int!
    pagina: Int!
    porPagina: Int!
    totalPaginas: Int!
    tieneProxima: Boolean!
    tieneAnterior: Boolean!
  }

  type PostsPaginados {
    datos: [Post!]!
    paginacion: PaginacionInfo!
  }

  # Queries
  type Query {
    # Usuarios
    usuarios: [Usuario!]!
    usuario(id: ID!): Usuario
    usuarioPorEmail(email: String!): Usuario

    # Posts
    posts(
      filtro: FiltroPost
      pagina: Int = 1
      porPagina: Int = 10
    ): PostsPaginados!
    post(id: ID!): Post

    # Categorías
    categorias: [Categoria!]!
    categoria(slug: String!): Categoria
  }

  # Mutations
  type Mutation {
    # Usuarios
    crearUsuario(input: CrearUsuarioInput!): Usuario!
    actualizarUsuario(id: ID!, input: ActualizarUsuarioInput!): Usuario!
    eliminarUsuario(id: ID!): Boolean!

    # Posts
    crearPost(input: CrearPostInput!): Post!
    actualizarPost(id: ID!, input: ActualizarPostInput!): Post!
    eliminarPost(id: ID!): Boolean!
    publicarPost(id: ID!): Post!

    # Comentarios
    crearComentario(postId: ID!, contenido: String!): Comentario!
    eliminarComentario(id: ID!): Boolean!
  }

  # Subscriptions (opcional)
  type Subscription {
    nuevoComentario(postId: ID!): Comentario!
    postActualizado(id: ID!): Post!
  }
`;

Implementando Resolvers

// src/resolvers/index.ts
import { usuarioResolvers } from './usuario';
import { postResolvers } from './post';
import { categoriaResolvers } from './categoria';
import { comentarioResolvers } from './comentario';

export const resolvers = {
  Query: {
    ...usuarioResolvers.Query,
    ...postResolvers.Query,
    ...categoriaResolvers.Query
  },
  Mutation: {
    ...usuarioResolvers.Mutation,
    ...postResolvers.Mutation,
    ...comentarioResolvers.Mutation
  },
  // Field resolvers
  Usuario: usuarioResolvers.Usuario,
  Post: postResolvers.Post,
  Categoria: categoriaResolvers.Categoria,
  Comentario: comentarioResolvers.Comentario
};

// src/resolvers/post.ts
import { Context } from '../context';
import { GraphQLError } from 'graphql';

interface FiltroPost {
  publicado?: boolean;
  autorId?: string;
  categoriaId?: string;
  busqueda?: string;
}

export const postResolvers = {
  Query: {
    posts: async (
      _: unknown,
      args: { filtro?: FiltroPost; pagina?: number; porPagina?: number },
      { db, usuario }: Context
    ) => {
      const { filtro = {}, pagina = 1, porPagina = 10 } = args;
      const offset = (pagina - 1) * porPagina;

      // Construir query dinámica
      let where: any = {};

      if (filtro.publicado !== undefined) {
        where.publicado = filtro.publicado;
      }

      if (filtro.autorId) {
        where.autorId = filtro.autorId;
      }

      if (filtro.busqueda) {
        where.OR = [
          { titulo: { contains: filtro.busqueda, mode: 'insensitive' } },
          { contenido: { contains: filtro.busqueda, mode: 'insensitive' } }
        ];
      }

      const [posts, total] = await Promise.all([
        db.post.findMany({
          where,
          skip: offset,
          take: porPagina,
          orderBy: { creadoEn: 'desc' }
        }),
        db.post.count({ where })
      ]);

      const totalPaginas = Math.ceil(total / porPagina);

      return {
        datos: posts,
        paginacion: {
          total,
          pagina,
          porPagina,
          totalPaginas,
          tieneProxima: pagina < totalPaginas,
          tieneAnterior: pagina > 1
        }
      };
    },

    post: async (_: unknown, { id }: { id: string }, { db }: Context) => {
      const post = await db.post.findUnique({ where: { id } });

      if (!post) {
        throw new GraphQLError('Post no encontrado', {
          extensions: { code: 'NOT_FOUND' }
        });
      }

      // Incrementar visualizaciones
      await db.post.update({
        where: { id },
        data: { visualizaciones: { increment: 1 } }
      });

      return post;
    }
  },

  Mutation: {
    crearPost: async (
      _: unknown,
      { input }: { input: any },
      { db, usuario }: Context
    ) => {
      if (!usuario) {
        throw new GraphQLError('No autorizado', {
          extensions: { code: 'UNAUTHORIZED' }
        });
      }

      const { categoriaIds, ...datos } = input;

      return db.post.create({
        data: {
          ...datos,
          autorId: usuario.id,
          categorias: categoriaIds
            ? { connect: categoriaIds.map((id: string) => ({ id })) }
            : undefined
        }
      });
    },

    actualizarPost: async (
      _: unknown,
      { id, input }: { id: string; input: any },
      { db, usuario }: Context
    ) => {
      if (!usuario) {
        throw new GraphQLError('No autorizado', {
          extensions: { code: 'UNAUTHORIZED' }
        });
      }

      const post = await db.post.findUnique({ where: { id } });

      if (!post) {
        throw new GraphQLError('Post no encontrado', {
          extensions: { code: 'NOT_FOUND' }
        });
      }

      if (post.autorId !== usuario.id) {
        throw new GraphQLError('Sin permiso para editar este post', {
          extensions: { code: 'FORBIDDEN' }
        });
      }

      const { categoriaIds, ...datos } = input;

      return db.post.update({
        where: { id },
        data: {
          ...datos,
          categorias: categoriaIds
            ? { set: categoriaIds.map((catId: string) => ({ id: catId })) }
            : undefined,
          actualizadoEn: new Date()
        }
      });
    },

    publicarPost: async (
      _: unknown,
      { id }: { id: string },
      { db, usuario }: Context
    ) => {
      if (!usuario) {
        throw new GraphQLError('No autorizado', {
          extensions: { code: 'UNAUTHORIZED' }
        });
      }

      return db.post.update({
        where: { id },
        data: { publicado: true, actualizadoEn: new Date() }
      });
    }
  },

  // Field resolvers para relaciones
  Post: {
    autor: (post: any, _: unknown, { loaders }: Context) => {
      return loaders.usuario.load(post.autorId);
    },

    categorias: (post: any, _: unknown, { db }: Context) => {
      return db.categoria.findMany({
        where: { posts: { some: { id: post.id } } }
      });
    },

    comentarios: (post: any, _: unknown, { db }: Context) => {
      return db.comentario.findMany({
        where: { postId: post.id },
        orderBy: { creadoEn: 'desc' }
      });
    }
  }
};

DataLoader: Evitando N+1

El problema N+1 es uno de los mayores villanos de performance en GraphQL. DataLoader resuelve esto con batching.

// src/loaders.ts
import DataLoader from 'dataloader';
import { PrismaClient } from '@prisma/client';

export function createLoaders(db: PrismaClient) {
  return {
    usuario: new DataLoader(async (ids: readonly string[]) => {
      const usuarios = await db.usuario.findMany({
        where: { id: { in: [...ids] } }
      });

      // Mapear para mantener el orden
      const usuarioMap = new Map(usuarios.map((u) => [u.id, u]));
      return ids.map((id) => usuarioMap.get(id) || null);
    }),

    post: new DataLoader(async (ids: readonly string[]) => {
      const posts = await db.post.findMany({
        where: { id: { in: [...ids] } }
      });

      const postMap = new Map(posts.map((p) => [p.id, p]));
      return ids.map((id) => postMap.get(id) || null);
    }),

    // Loader para posts por autor (batch por autorId)
    postsPorAutor: new DataLoader(async (autorIds: readonly string[]) => {
      const posts = await db.post.findMany({
        where: { autorId: { in: [...autorIds] } }
      });

      // Agrupar por autorId
      const postsPorAutor = new Map<string, any[]>();
      posts.forEach((post) => {
        const lista = postsPorAutor.get(post.autorId) || [];
        lista.push(post);
        postsPorAutor.set(post.autorId, lista);
      });

      return autorIds.map((id) => postsPorAutor.get(id) || []);
    }),

    // Loader para conteo de comentarios
    conteoComentarios: new DataLoader(async (postIds: readonly string[]) => {
      const conteos = await db.comentario.groupBy({
        by: ['postId'],
        where: { postId: { in: [...postIds] } },
        _count: { id: true }
      });

      const conteoMap = new Map(
        conteos.map((c) => [c.postId, c._count.id])
      );

      return postIds.map((id) => conteoMap.get(id) || 0);
    })
  };
}

// src/context.ts
import { PrismaClient } from '@prisma/client';
import { createLoaders } from './loaders';
import { verificarToken } from './auth';

const db = new PrismaClient();

export interface Context {
  db: PrismaClient;
  loaders: ReturnType<typeof createLoaders>;
  usuario: { id: string; email: string } | null;
}

export async function createContext({ req }: { req: any }): Promise<Context> {
  const token = req.headers.authorization?.replace('Bearer ', '');
  const usuario = token ? await verificarToken(token) : null;

  return {
    db,
    loaders: createLoaders(db), // Nuevos loaders por requisición
    usuario
  };
}

Queries y Mutations en la Práctica

Veamos cómo usar la API del lado del cliente.

Queries

# Buscar lista de posts paginada
query ListarPosts($filtro: FiltroPost, $pagina: Int) {
  posts(filtro: $filtro, pagina: $pagina, porPagina: 10) {
    datos {
      id
      titulo
      contenido
      publicado
      visualizaciones
      creadoEn
      autor {
        id
        nombre
        avatar
      }
      categorias {
        id
        nombre
        slug
      }
    }
    paginacion {
      total
      pagina
      totalPaginas
      tieneProxima
      tieneAnterior
    }
  }
}

# Buscar post con todos los detalles
query BuscarPost($id: ID!) {
  post(id: $id) {
    id
    titulo
    contenido
    publicado
    visualizaciones
    creadoEn
    actualizadoEn
    autor {
      id
      nombre
      email
      avatar
    }
    categorias {
      id
      nombre
      slug
    }
    comentarios {
      id
      contenido
      creadoEn
      autor {
        id
        nombre
        avatar
      }
    }
  }
}

Mutations

# Crear nuevo post
mutation CrearPost($input: CrearPostInput!) {
  crearPost(input: $input) {
    id
    titulo
    contenido
    publicado
    creadoEn
  }
}

# Actualizar post existente
mutation ActualizarPost($id: ID!, $input: ActualizarPostInput!) {
  actualizarPost(id: $id, input: $input) {
    id
    titulo
    contenido
    publicado
    actualizadoEn
  }
}

# Publicar post
mutation PublicarPost($id: ID!) {
  publicarPost(id: $id) {
    id
    publicado
    actualizadoEn
  }
}

# Agregar comentario
mutation AgregarComentario($postId: ID!, $contenido: String!) {
  crearComentario(postId: $postId, contenido: $contenido) {
    id
    contenido
    creadoEn
    autor {
      id
      nombre
    }
  }
}

Optimizaciones Avanzadas

Persisted Queries

Para producción, persisted queries mejoran seguridad y performance:

// src/plugins/persistedQueries.ts
import { ApolloServerPlugin } from '@apollo/server';
import { createHash } from 'crypto';

const queryStore = new Map<string, string>();

export const persistedQueriesPlugin: ApolloServerPlugin = {
  async requestDidStart() {
    return {
      async didResolveOperation({ request, document }) {
        if (request.extensions?.persistedQuery) {
          const { sha256Hash } = request.extensions.persistedQuery;

          // Verificar si query está registrada
          if (!queryStore.has(sha256Hash)) {
            if (request.query) {
              // Registrar nueva query
              const hash = createHash('sha256')
                .update(request.query)
                .digest('hex');

              if (hash === sha256Hash) {
                queryStore.set(sha256Hash, request.query);
              }
            }
          }
        }
      }
    };
  }
};

Query Complexity

Limitar la complejidad de las queries evita abusos:

// src/plugins/queryComplexity.ts
import { GraphQLError } from 'graphql';
import {
  fieldExtensionsEstimator,
  getComplexity,
  simpleEstimator
} from 'graphql-query-complexity';

const MAX_COMPLEXITY = 1000;

export function validateQueryComplexity(schema: any, query: any, variables: any) {
  const complexity = getComplexity({
    schema,
    query,
    variables,
    estimators: [
      fieldExtensionsEstimator(),
      simpleEstimator({ defaultComplexity: 1 })
    ]
  });

  if (complexity > MAX_COMPLEXITY) {
    throw new GraphQLError(
      `Query muy compleja: ${complexity}. Máximo permitido: ${MAX_COMPLEXITY}`,
      { extensions: { code: 'QUERY_TOO_COMPLEX' } }
    );
  }

  return complexity;
}

Caching con Redis

// src/plugins/caching.ts
import Redis from 'ioredis';
import { createHash } from 'crypto';

const redis = new Redis(process.env.REDIS_URL);

export async function cacheResolver<T>(
  key: string,
  ttl: number,
  resolver: () => Promise<T>
): Promise<T> {
  const cached = await redis.get(key);

  if (cached) {
    return JSON.parse(cached);
  }

  const result = await resolver();

  await redis.setex(key, ttl, JSON.stringify(result));

  return result;
}

// Uso en los resolvers
posts: async (_: unknown, args: any, { db }: Context) => {
  const cacheKey = `posts:${createHash('md5').update(JSON.stringify(args)).digest('hex')}`;

  return cacheResolver(cacheKey, 300, async () => {
    return db.post.findMany({ /* ... */ });
  });
}

Tratamiento de Errores

// src/errors.ts
import { GraphQLError } from 'graphql';

export class ErrorValidacion extends GraphQLError {
  constructor(mensaje: string, campo?: string) {
    super(mensaje, {
      extensions: {
        code: 'VALIDATION_ERROR',
        campo
      }
    });
  }
}

export class ErrorAutenticacion extends GraphQLError {
  constructor(mensaje = 'No autorizado') {
    super(mensaje, {
      extensions: { code: 'UNAUTHENTICATED' }
    });
  }
}

export class ErrorPermiso extends GraphQLError {
  constructor(mensaje = 'Sin permiso') {
    super(mensaje, {
      extensions: { code: 'FORBIDDEN' }
    });
  }
}

export class ErrorNoEncontrado extends GraphQLError {
  constructor(recurso: string) {
    super(`${recurso} no encontrado`, {
      extensions: { code: 'NOT_FOUND' }
    });
  }
}

Si quieres aprender más sobre arquitecturas de backend modernas, confiere el artículo Arquitectura Serverless con JavaScript que complementa muy bien GraphQL.

¡Vamos a por ello! 🦅

Comentarios (0)

Este artículo aún no tiene comentarios 😢. ¡Sé el primero! 🚀🦅

Añadir comentarios