1. Introducción contextual
En la última década la arquitectura de APIs ha evolucionado de manera acelerada. En 2015 Facebook lanzó GraphQL, una alternativa declarativa a los tradicionales REST endpoints. Desde entonces, la adopción ha crecido exponencialmente: según el State of API Report 2023, el 38 % de las empresas de tecnología utilizan GraphQL en producción, frente a un 72 % que siguen con REST, y un 24 % combina ambos enfoques.
El problema de ingeniería que enfrentamos hoy es decidir, bajo criterios de rendimiento, mantenibilidad y experiencia del consumidor, cuál de los dos estilos de API es el más adecuado para un proyecto concreto. No se trata de “GraphQL es siempre mejor” o “REST es el estándar”, sino de comprender los trade‑offs técnicos y de negocio que influyen en la elección.
Resolver este dilema es crítico porque una decisión equivocada se traduce en sobre‑carga de red, latencia innecesaria, escalado costoso y deuda técnica que se paga durante años. Además, los equipos de frontend modernos (React, Next.js, Vue) demandan cada vez más datos granulares y cargas parciales, lo que hace que la flexibilidad de la capa de acceso a datos sea un factor determinante para la velocidad de entrega de productos.
En este artículo, como mentor técnico, desglosaremos los conceptos fundamentales, mostraremos implementaciones reales paso a paso, analizaremos casos de uso empresariales y ofreceremos una comparativa objetiva que te permita decidir con confianza entre GraphQL y REST.
2. Fundamentos y arquitectura
2.1. Principios de REST
REST (Representational State Transfer) se basa en recursos identificados por URLs y en verbos HTTP (GET, POST, PUT, DELETE, PATCH). Cada recurso tiene una representación (JSON, XML) y el servidor es responsable de definir la forma de la respuesta.
| Concepto | Detalle | Trade‑off |
|---|---|---|
| Uniform Interface | Operaciones estándar (GET/POST…) garantizan interoperabilidad. | Puede generar over‑fetching (más datos de los necesarios) o under‑fetching (necesidad de múltiples llamadas). |
| Statelessness | Cada petición lleva toda la información necesaria. | Simplifica el escalado horizontal, pero obliga al cliente a gestionar más lógica (paginación, cache). |
| Cacheability | Respuestas pueden ser cacheadas vía headers HTTP. | Depende de la correcta configuración de ETag, Cache-Control. |
2.2. Principios de GraphQL
GraphQL define un esquema tipado que describe los tipos de datos y las relaciones entre ellos. El cliente envía una consulta que especifica exactamente los campos requeridos. El servidor ejecuta la consulta y devuelve una única respuesta que coincide con la forma solicitada.
| Concepto | Detalle | Trade‑off |
|---|---|---|
| Consulta declarativa | El cliente declara qué necesita, no cómo obtenerlo. | Reduce over‑fetching, pero aumenta la complejidad del resolutor. |
| Tipado fuerte | El esquema (SDL) sirve como contrato y genera documentación automática. | Necesita herramientas de generación (codegen) y validación en tiempo de compilación. |
| Single endpoint | Todas las operaciones pasan por /graphql. | Centraliza la lógica, pero puede crear cuellos de botella si no se segmenta (por ejemplo, mediante @defer). |
2.3. Decisiones de diseño y trade‑offs
| Decisión | REST | GraphQL |
|---|---|---|
| Granularidad de datos | Múltiples endpoints → potencial n+1 calls. | Una única query → control total del payload. |
| Versionado | Versionado explícito (/v1/users). | Evolución mediante deprecations en el esquema. |
| Seguridad | Autorización por recurso (RBAC en rutas). | Autorización por campo (middleware en resolvers). |
| Herramientas de monitoreo | Logs de HTTP, métricas por endpoint. | Necesita tracing de resolvers (Apollo Engine, OpenTelemetry). |
| Curva de aprendizaje | Bajo, basado en HTTP estándar. | Media‑alta: requiere conocimiento de SDL, resolvers, batching. |
En términos de arquitectura, ambos enfoques pueden implementarse bajo microservicios, serverless o monolitos. La diferencia radica en cómo se modela la capa de acceso a datos y cómo se expone a los consumidores.
3. Implementación práctica
A continuación, construiremos una API de gestión de productos (CRUD) en Node.js + TypeScript usando Express (REST) y Apollo Server (GraphQL). Cada ejemplo muestra la evolución: versión naïve → versión optimizada con patrones de diseño, manejo de errores y caching.
3.1. REST – Versión básica
// src/rest/basic/product.controller.ts
import { Request, Response, NextFunction } from 'express';
import { Pool } from 'pg';
// Conexión simple a PostgreSQL (pool reutilizable)
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
/**
* GET /api/products
* Devuelve todos los productos sin paginación ni filtros.
*/
export async function getAllProducts(req: Request, res: Response, next: NextFunction) {
try {
const result = await pool.query('SELECT id, name, price FROM products');
res.json(result.rows);
} catch (err) {
next(err); // delega a middleware de error
}
}
Problemas: sin paginación, sin validación, sin control de tiempo de respuesta.
3.2. REST – Versión optimizada (Repository + Pagination + Cache)
// src/rest/optimized/product.repository.ts
import { Pool, QueryResult } from 'pg';
import LRU from 'lru-cache';
export interface Product {
id: string;
name: string;
price: number;
description?: string;
}
// Cache LRU de 5 min, 500 items máximo
const cache = new LRU<string, Product[]>({ max: 500, ttl: 1000 * 60 * 5 });
export class ProductRepository {
private pool: Pool;
constructor(pool: Pool) {
this.pool = pool;
}
async findAll(page = 1, limit = 20): Promise<Product[]> {
const cacheKey = `products:${page}:${limit}`;
const cached = cache.get(cacheKey);
if (cached) return cached;
const offset = (page - 1) * limit;
const sql = `
SELECT id, name, price, description
FROM products
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`;
const result: QueryResult<Product> = await this.pool.query(sql, [limit, offset]);
cache.set(cacheKey, result.rows);
return result.rows;
}
async findById(id: string): Promise<Product | null> {
const sql = 'SELECT id, name, price, description FROM products WHERE id = $1';
const { rows } = await this.pool.query<Product>(sql, [id]);
return rows[0] ?? null;
}
// Métodos create, update, delete omitidos por brevedad
}
// src/rest/optimized/product.controller.ts
import { Request, Response, NextFunction } from 'express';
import { ProductRepository } from './product.repository';
import { Pool } from 'pg';
const repo = new ProductRepository(new Pool({ connectionString: process.env.DATABASE_URL }));
export async function getProducts(req: Request, res: Response, next: NextFunction) {
try {
const page = Number(req.query.page) || 1;
const limit = Number(req.query.limit) || 20;
const products = await repo.findAll(page, limit);
res.json({ page, limit, data: products });
} catch (err) {
next(err);
}
}
Mejoras: paginación, caching LRU, separación de responsabilidades (Repository pattern), tipado estricto y manejo de errores centralizado.
3.3. GraphQL – Versión básica
// src/graphql/basic/schema.ts
import { gql } from 'apollo-server-express';
export const typeDefs = gql`
type Product {
id: ID!
name: String!
price: Float!
}
type Query {
products: [Product!]!
}
`;
// src/graphql/basic/resolvers.ts
import { Pool } from 'pg';
import { IResolvers } from '@graphql-tools/utils';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const resolvers: IResolvers = {
Query: {
async products() {
const { rows } = await pool.query('SELECT id, name, price FROM products');
return rows;
},
},
};
Problemas: no hay filtrado, paginación ni control de errores.
3.4. GraphQL – Versión optimizada (DataLoader + Validation + Error Formatting)
// src/graphql/optimized/dataloader.ts
import DataLoader from 'dataloader';
import { Pool } from 'pg';
import { Product } from '../types';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
/**
* DataLoader para cargar productos por lote y evitar N+1.
* Cada llamada recibe un array de IDs y devuelve un array de Product en el mismo orden.
*/
export const productLoader = new DataLoader<string, Product>(async (ids) => {
const sql = `
SELECT id, name, price, description
FROM products
WHERE id = ANY($1::uuid[])
`;
const { rows } = await pool.query<Product>(sql, [ids]);
// Mapear resultados al orden de ids solicitados
const rowMap = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => rowMap.get(id) ?? null);
});
// src/graphql/optimized/schema.ts
import { gql } from 'apollo-server-express';
export const typeDefs = gql`
scalar DateTime
type Product {
id: ID!
name: String!
price: Float!
description: String
createdAt: DateTime!
}
input ProductFilter {
minPrice: Float
maxPrice: Float
nameContains: String
}
type Query {
products(page: Int = 1, limit: Int = 20, filter: ProductFilter): [Product!]!
product(id: ID!): Product
}
type Mutation {
createProduct(name: String!, price: Float!, description: String): Product!
updateProduct(id: ID!, name: String, price: Float, description: String): Product!
deleteProduct(id: ID!): Boolean!
}
`;
// src/graphql/optimized/resolvers.ts
import { IResolvers } from '@graphql-tools/utils';
import { productLoader } from './dataloader';
import { Pool } from 'pg';
import { GraphQLError } from 'graphql';
import { validateProductInput } from '../validation';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const resolvers: IResolvers = {
Query: {
async products(_: any, args: { page: number; limit: number; filter?: any }) {
const offset = (args.page - 1) * args.limit;
const conditions: string[] = [];
const values: any[] = [];
if (args.filter?.minPrice !== undefined) {
values.push(args.filter.minPrice);
conditions.push(`price >= $${values.length}`);
}
if (args.filter?.maxPrice !== undefined) {
values.push(args.filter.maxPrice);
conditions.push(`price <= $${values.length}`);
}
if (args.filter?.nameContains) {
values.push(`%${args.filter.nameContains}%`);
conditions.push(`name ILIKE $${values.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const sql = `
SELECT id, name, price, description, created_at
FROM products
${where}
ORDER BY created_at DESC
LIMIT $${values.length + 1} OFFSET $${values.length + 2}
`;
values.push(args.limit, offset);
const { rows } = await pool.query(sql, values);
return rows;
},
async product(_: any, args: { id: string }) {
const product = await productLoader.load(args.id);
if (!product) {
throw new GraphQLError('Producto no encontrado', {
extensions: { code: 'NOT_FOUND' },
});
}
return product;
},
},
Mutation: {
async createProduct(_: any, args: { name: string; price: number; description?: string }) {
const errors = validateProductInput(args);
if (errors.length) {
throw new GraphQLError('Validación fallida', {
extensions: { code: 'BAD_USER_INPUT', validationErrors: errors },
});
}
const sql = `
INSERT INTO products (name, price, description, created_at)
VALUES ($1, $2, $3, NOW())
RETURNING id, name, price, description, created_at
`;
const { rows } = await pool.query(sql, [args.name, args.price, args.description ?? null]);
return rows[0];
},
async updateProduct(_: any, args: { id: string; name?: string; price?: number; description?: string }) {
const fields: string[] = [];
const values: any[] = [];
if (args.name !== undefined) {
values.push(args.name);
fields.push(`name = $${values.length}`);
}
if (args.price !== undefined) {
values.push(args.price);
fields.push(`price = $${values.length}`);
}
if (args.description !== undefined) {
values.push(args.description);
fields.push(`description = $${values.length}`);
}
if (!fields.length) {
throw new GraphQLError('No hay campos para actualizar', {
extensions: { code: 'BAD_USER_INPUT' },
});
}
values.push(args.id);
const sql = `
UPDATE products
SET ${fields.join(', ')}, updated_at = NOW()
WHERE id = $${values.length}
RETURNING id, name, price, description, created_at
`;
const { rows } = await pool.query(sql, values);
if (!rows[0]) {
throw new GraphQLError('Producto no encontrado', { extensions: { code: 'NOT_FOUND' } });
}
// Invalida el loader para que la próxima carga sea fresca
productLoader.clear(args.id);
return rows[0];
},
async deleteProduct(_: any, args: { id: string }) {
const sql = 'DELETE FROM products WHERE id = $1';
const result = await pool.query(sql, [args.id]);
// Invalida caché
productLoader.clear(args.id);
return result.rowCount > 0;
},
},
};
Puntos clave de la versión optimizada:
- DataLoader elimina el problema N+1 en resolvers que requieren relaciones.
- Validación centralizada (
validateProductInput) permite reutilizar lógica de negocio. - Manejo de errores con
GraphQLErrory extensiones personalizadas para que el cliente pueda reaccionar. - Cache invalidation explícita después de mutaciones.
3.5. Bash – Script de migración de schema (REST → GraphQL)
#!/usr/bin/env bash
# scripts/migrate-schema.sh
# Migración automática de endpoints REST a tipos GraphQL usando introspección.
set -euo pipefail
API_URL="${1:-http://localhost:3000/api}"
OUTPUT_DIR="${2:-src/graphql/generated}"
# 1. Obtener la especificación OpenAPI (Swagger) del proyecto REST
curl -s "${API_URL}/swagger.json" -o /tmp/swagger.json
# 2. Convertir OpenAPI a GraphQL SDL usando openapi-to-graphql (npm)
npx openapi-to-graphql-cli \
--input /tmp/swagger.json \
--output "${OUTPUT_DIR}/schema.graphql" \
--default-namespaces \
--verbose
echo "✅
