"Vamos a usar GraphQL porque es moderno." Hemos escuchado esta frase docenas de veces. Y en la mitad de los casos, la respuesta correcta era una API REST bien diseñada. La tecnología que eliges para tu API tiene implicaciones directas en la complejidad del proyecto, el rendimiento y la capacidad de tu equipo para mantenerla a largo plazo.
En este artículo compartimos el framework de decisión que usamos internamente para elegir entre REST, GraphQL y alternativas como tRPC, con ejemplos reales de proyectos que hemos construido.
REST: El estándar que sigue funcionando
REST no es sexy, pero es predecible. Y en ingeniería de software, predecible es bueno.
Cuándo REST es la mejor opción
- APIs públicas o de terceros. Si tu API va a ser consumida por clientes que no controlas (apps móviles de terceros, integraciones, partners), REST es el estándar que todos entienden.
- Operaciones CRUD simples. Si tu dominio se mapea bien a recursos con operaciones crear/leer/actualizar/eliminar, REST es natural.
- Equipo con experiencia limitada en GraphQL. La curva de aprendizaje de GraphQL no es trivial. Si tu equipo no lo conoce y el proyecto tiene deadline, REST es la opción segura.
- Cacheo agresivo. REST se integra nativamente con HTTP caching (ETags, Cache-Control, CDNs). GraphQL, al usar POST para todo, hace que el cacheo HTTP sea mucho más complejo.
Buenas prácticas que seguimos
Después de diseñar más de 30 APIs REST, estas son las convenciones que hemos estandarizado:
1. Nombres de recursos en plural, sin verbos
# Bien
GET /api/v1/users
GET /api/v1/users/123
POST /api/v1/users
PATCH /api/v1/users/123
DELETE /api/v1/users/123
# Mal
GET /api/v1/getUsers
POST /api/v1/createUser
POST /api/v1/user/delete/123
2. Respuestas consistentes con envelope
Todas nuestras APIs devuelven la misma estructura. El cliente siempre sabe qué esperar:
// Respuesta exitosa
{
"data": { ... },
"meta": {
"page": 1,
"pageSize": 20,
"total": 156
}
}
// Respuesta de error
{
"error": {
"code": "VALIDATION_ERROR",
"message": "El campo email es obligatorio",
"details": [
{ "field": "email", "rule": "required" }
]
}
}
3. Filtrado, ordenación y paginación estandarizados
GET /api/v1/orders?status=pending&sort=-createdAt&page=2&pageSize=20
Usamos convenciones simples: - para orden descendente, query params para filtros, page y pageSize para paginación cursor-based o offset.
4. Versionado en la URL
Hay debate sobre si versionar en la URL (/v1/) o en los headers (Accept: application/vnd.api.v1+json). Nosotros usamos la URL porque es más visible, más fácil de debuggear y funciona bien con herramientas de documentación como OpenAPI.
/api/v1/users # Versión estable
/api/v2/users # Nueva versión con breaking changes
La regla: nunca rompas la v1 sin lanzar la v2. Los clientes existentes deben seguir funcionando.
GraphQL: Cuándo realmente brilla
GraphQL no es "REST pero mejor". Es una herramienta diferente con trade-offs diferentes. Estos son los escenarios donde lo elegimos activamente:
1. Frontends con datos heterogéneos
Cuando un dashboard necesita datos de 5 fuentes distintas en una sola vista, GraphQL evita el problema de hacer 5 llamadas REST secuenciales:
# Una sola query para toda la vista del dashboard
query DashboardData {
currentUser {
name
role
avatar
}
recentOrders(limit: 5) {
id
total
status
customer { name }
}
salesMetrics(period: LAST_30_DAYS) {
revenue
orderCount
averageOrderValue
}
notifications(unreadOnly: true) {
id
message
createdAt
}
}
Con REST, esto serían 4 endpoints diferentes, 4 round-trips al servidor y potencialmente datos innecesarios en cada respuesta.
2. Aplicaciones móviles con ancho de banda limitado
GraphQL permite al cliente pedir exactamente los campos que necesita. En una app móvil donde cada kilobyte cuenta, esto marca la diferencia:
# App móvil: solo necesita nombre y avatar
query { users { name avatar } }
# Dashboard web: necesita todo el perfil
query { users { name avatar email role lastLogin department } }
3. Equipos frontend y backend desacoplados
Con GraphQL, el frontend puede evolucionar sin esperar cambios en el backend. Si el diseñador añade un campo nuevo a la UI, el frontend developer puede pedirlo directamente si ya existe en el schema — sin esperar a que backend exponga un nuevo endpoint.
Complejidades que debes considerar
GraphQL no es gratis. Estos son los costes reales que hemos encontrado:
- N+1 queries. El problema más común. Sin DataLoader o soluciones equivalentes, una query que pide
orders { customer { name } }ejecuta una query a la base de datos por cada pedido. La solución es obligatoria, no opcional.
// Sin DataLoader: N+1 queries
// 1 query para orders + N queries para customers
// Con DataLoader: 2 queries totales
const customerLoader = new DataLoader(async (customerIds) => {
const customers = await db.customer.findMany({
where: { id: { in: customerIds } },
});
return customerIds.map(id => customers.find(c => c.id === id));
});
- Complejidad de queries. Un cliente malicioso puede construir queries profundamente anidadas que tumben tu servidor. Necesitas limitar la profundidad y complejidad de las queries.
- Cacheo HTTP. Al usar POST para todo, pierdes la cache del navegador y de CDNs. Necesitas soluciones como persisted queries o cacheo a nivel de aplicación.
- Curva de aprendizaje. Schema design, resolvers, subscriptions, directivas... GraphQL tiene una superficie de API grande. Planifica tiempo de formación para el equipo.
tRPC: La alternativa para full-stack TypeScript
Si tu frontend y backend son TypeScript y los controla el mismo equipo, tRPC merece una mirada seria. Elimina la capa de serialización y te da type safety de extremo a extremo sin generar código.
// Definición del router (servidor)
const appRouter = router({
user: router({
getById: procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return db.user.findUnique({ where: { id: input.id } });
}),
create: procedure
.input(z.object({
name: z.string().min(2),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
return db.user.create({ data: input });
}),
}),
});
// Uso en el cliente (React) - Full type safety sin codegen
const { data: user } = trpc.user.getById.useQuery({ id: '123' });
// user está tipado automáticamente como User | undefined
Cuándo elegimos tRPC:
- Proyectos full-stack donde controlamos ambos lados
- Equipos pequeños (2-5 devs) donde la productividad importa más que la flexibilidad
- MVPs y prototipos que necesitan velocidad de desarrollo
Cuándo NO lo elegimos:
- APIs públicas (tRPC es inherentemente un protocolo interno)
- Equipos con frontend y backend en lenguajes diferentes
- Cuando necesitas una capa de documentación estándar como OpenAPI
La tabla de decisión
Este es el cheat sheet que usamos internamente cuando arrancamos un proyecto nuevo:
| Criterio | REST | GraphQL | tRPC | |----------|------|---------|------| | API pública / terceros | Ideal | Posible | No aplica | | Múltiples clientes (web, móvil, IoT) | Bueno | Ideal | No aplica | | Full-stack TypeScript, mismo equipo | Bueno | Bueno | Ideal | | Cacheo HTTP / CDN | Nativo | Complejo | No aplica | | Datos heterogéneos por vista | Lento (N+1 calls) | Ideal | Bueno | | Curva de aprendizaje | Baja | Media-Alta | Baja | | Documentación automática | OpenAPI/Swagger | GraphQL Playground | Panel tRPC | | Tiempo real (subscriptions) | WebSockets manual | Nativo | WebSockets manual |
Patrones de autenticación que usamos
Independientemente de REST o GraphQL, la autenticación sigue los mismos principios:
JWT + Refresh Tokens
Nuestro patrón estándar para SPAs y apps móviles:
// Middleware de autenticación (Express/Fastify)
async function authenticate(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({
error: { code: 'UNAUTHORIZED', message: 'Token requerido' }
});
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload;
next();
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
return res.status(401).json({
error: { code: 'TOKEN_EXPIRED', message: 'Token expirado' }
});
}
return res.status(401).json({
error: { code: 'INVALID_TOKEN', message: 'Token inválido' }
});
}
}
Reglas que seguimos:
- Access token con expiración corta (15 minutos)
- Refresh token con expiración larga (7 días), almacenado en httpOnly cookie
- Rotación de refresh tokens: cada vez que se usa, se invalida y se emite uno nuevo
- Blacklist de tokens revocados en Redis para logout inmediato
Rate limiting
Toda API pública necesita rate limiting. Usamos una combinación de rate limiting por IP y por usuario autenticado:
// Rate limiting con ventana deslizante
const rateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutos
max: (req) => {
// Usuarios autenticados: 1000 req/15min
// Anónimos: 100 req/15min
return req.user ? 1000 : 100;
},
standardHeaders: true, // RateLimit-* headers
legacyHeaders: false,
message: {
error: {
code: 'RATE_LIMITED',
message: 'Demasiadas peticiones. Inténtalo de nuevo más tarde.',
}
},
});
Documentación: Si no está documentada, no existe
Cada API que entregamos incluye documentación generada automáticamente:
- REST: OpenAPI 3.1 spec generada desde el código con
@nestjs/swaggerozod-to-openapi. Publicada en Swagger UI. - GraphQL: Schema introspection con GraphQL Playground o Apollo Studio. Cada tipo y campo tiene descripción.
- tRPC: Panel de documentación integrado que muestra todos los procedimientos con sus tipos de entrada y salida.
Además, incluimos siempre:
- Guía de inicio rápido con ejemplos de autenticación y primera llamada
- Colección de Postman/Insomnia lista para importar
- Changelog con cada versión y sus breaking changes
Errores comunes que hemos visto
Después de revisar y consumir decenas de APIs (propias y ajenas), estos son los errores que más se repiten:
-
Usar códigos HTTP incorrectos. Un 200 con
{ "success": false }no es una respuesta de error — es una mentira. Usa 400 para errores del cliente, 401 para autenticación, 403 para autorización, 404 para recursos inexistentes, 422 para validación, 500 para errores del servidor. -
No paginar desde el principio. Si tu endpoint devuelve una lista, paginala. Siempre. Aunque hoy solo tengas 10 registros, mañana tendrás 10.000 y tu cliente se romperá.
-
Exponer IDs internos. Usar IDs autoincremntales expone información sobre el volumen de tu negocio y facilita ataques de enumeración. Preferimos UUIDs o IDs opacos como
usr_a1b2c3d4. -
Ignorar las migraciones de schema. Si cambias la estructura de respuesta sin versionar, rompes clientes existentes. El versionado no es opcional en APIs públicas.
-
Autenticación en query params. Nunca pongas tokens en la URL (
?token=abc123). Las URLs se loguean en servidores intermedios, se almacenan en el historial del navegador y aparecen en cabeceras Referer.
Conclusión
No hay una respuesta universal sobre qué tipo de API usar. Lo que hay son preguntas correctas:
- ¿Quién va a consumir esta API?
- ¿Cuántos tipos de cliente habrá?
- ¿Qué nivel de control tienes sobre los consumidores?
- ¿Cuál es la experiencia de tu equipo?
- ¿Qué tan complejo es el grafo de datos?
Responde a estas preguntas con honestidad y la elección se hace obvia. Y si no es obvia, empieza con REST. Siempre puedes añadir una capa GraphQL después si los datos lo justifican.
¿Estás diseñando una API y no tienes claro por dónde empezar? Hablemos y te ayudamos a elegir la arquitectura que mejor se adapte a tu proyecto.