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.

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.

