1. Introducción contextual
En los últimos cinco años el consumo de servicios web ha crecido de forma exponencial. Según el informe de State of API 2024 de Postman, más del 80 % de las organizaciones consideran sus APIs como activos estratégicos y el 62 % de los proyectos de transformación digital fallan por una arquitectura de API pobremente diseñada. El problema no es sólo la definición de los endpoints, sino la forma en que esos endpoints se estructuran, versionan y se mantienen a lo largo del tiempo.
Una API REST que “funciona” en un entorno de pruebas suele colapsar cuando el tráfico aumenta, cuando el modelo de datos evoluciona o cuando el equipo de desarrollo crece. Los síntomas típicos son: errores de validación inesperados, latencias fuera de los límites de los Core Web Vitals, ruptura de contratos de cliente‑servidor y, sobre todo, una carga operativa que consume tiempo que debería invertirse en nuevas funcionalidades.
Históricamente, la comunidad ha adoptado buenas prácticas como la separación de capas (router → controller → service → repository) y la documentación con Swagger/OpenAPI. Sin embargo, la velocidad con la que se lanzan productos al mercado ha llevado a muchos equipos a sacrificar la arquitectura en favor de entregas rápidas, generando “APIs de emergencia” que se convierten en deuda técnica.
Resolver este problema hoy es crítico porque el API‑first se ha convertido en un requisito para la interoperabilidad entre microservicios, aplicaciones móviles y plataformas SaaS. Una API bien diseñada no solo reduce el coste de mantenimiento, sino que también mejora la experiencia del desarrollador externo, aumenta la velocidad de onboarding y permite escalar horizontalmente sin re‑escribir lógica de negocio. En este artículo, abordaremos los fundamentos, la arquitectura y la implementación práctica de APIs REST que, a diferencia de las típicas “dolorosas”, son predecibles, seguras y fáciles de evolucionar.
2. Fundamentos y Arquitectura
2.1 Principios de diseño RESTful
| Principio | Descripción | Impacto en la mantenibilidad |
|---|---|---|
| Uniform Interface | Utiliza verbos HTTP estándar (GET, POST, PUT, PATCH, DELETE) y recursos identificados por URIs. | Reduce ambigüedad y facilita la generación automática de clientes. |
| Statelessness | Cada petición lleva toda la información necesaria; el servidor no mantiene estado de sesión. | Permite escalar horizontalmente sin replicar sesiones. |
| Cacheability | Respuestas marcadas como cacheables deben poder reutilizarse por clientes y CDNs. | Mejora latencia y disminuye carga en el backend. |
| Layered System | La arquitectura puede componerse de capas (gateway, auth, business, data). | Facilita la inserción de middlewares como rate‑limiting o logging sin tocar la lógica de negocio. |
| Code on Demand (opcional) | Permite que el cliente descargue código ejecutable (p.ej. JSON‑Schema). | Útil para SDKs dinámicos, pero rara vez necesario. |
2.2 Decisiones de diseño y trade‑offs
| Decisión | Ventaja | Desventaja |
|---|---|---|
Versionado en la URL (/v1/users) | Simplicidad para clientes; permite despliegues paralelos. | Puede generar proliferación de rutas si no se controla. |
Versionado en el Header (Accept: application/vnd.myapi.v1+json) | Mantiene URLs limpias; permite versionado granular por recurso. | Requiere middleware para parsing y es menos visible en logs. |
| Uso de DTOs (Data Transfer Objects) | Aísla el modelo de dominio del contrato público; facilita validaciones. | Añade una capa extra de mapeo que necesita mantenimiento. |
| Patrón Repository | Centraliza acceso a datos, facilita pruebas unitarias con mocks. | Puede ser over‑engineering en proyectos muy pequeños. |
| Caching a nivel de aplicación vs CDN | Aplicación: mayor control de invalidación; CDN: menor latencia global. | Aplicación: mayor complejidad; CDN: depende de la consistencia de datos. |
2.3 Diagrama conceptual (descrito en texto)
- Gateway/API‑Edge (NGINX o Cloudflare): gestiona TLS, rate‑limiting y routing a versiones específicas.
- Router (Next.js API Routes o Express): define la tabla de rutas y delega a controllers.
- Controller: traduce la petición HTTP a un service y devuelve la respuesta HTTP.
- Service: contiene la lógica de negocio, orquesta transacciones y aplica reglas de dominio.
- Repository: abstrae la persistencia (PostgreSQL, Supabase, MongoDB).
- Cache Layer (Redis): almacena resultados de consultas idempotentes.
- Observability (OpenTelemetry, Winston): captura trazas, métricas y logs.
Este enfoque permite que cada capa sea testeable de forma aislada y que los cambios en una capa (p.ej. migrar de PostgreSQL a Supabase) no impacten al resto.
3. Implementación práctica
A continuación se muestra una evolución paso a paso de una API User construida con Next.js 14 (API Routes) y TypeScript. Cada bloque contiene al menos 15 líneas de código funcional, incluye manejo de errores, validaciones y patrones de diseño.
3.1 Versión 1 – Enfoque naïve (solo router)
// src/app/api/v1/users/route.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Base de datos simulada en memoria (solo para demo)
const users: Record<string, { id: string; name: string; email: string }> = {};
export async function GET(req: NextRequest) {
// No hay validación, devuelve todo el objeto en crudo
const allUsers = Object.values(users);
return NextResponse.json(allUsers, { status: 200 });
}
export async function POST(req: NextRequest) {
const body = await req.json();
// Asume que el cliente envía id, name y email correctos
users[body.id] = { id: body.id, name: body.name, email: body.email };
return NextResponse.json({ message: 'User created' }, { status: 201 });
}
Problemas detectados:
- No hay tipado fuerte del payload.
- No existe capa de negocio ni manejo de errores.
- La base de datos está acoplada al router, imposible de testear.
3.2 Versión 2 – DTO, validación y controlador
// src/app/api/v1/users/dto.ts
export interface CreateUserDTO {
id: string;
name: string;
email: string;
}
// src/app/api/v1/users/controller.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { CreateUserDTO } from './dto';
import { userService } from '../../services/userService';
import { validateCreateUser } from '../../validators/userValidator';
export async function getUsers(req: NextRequest) {
try {
const users = await userService.list();
return NextResponse.json(users, { status: 200 });
} catch (err) {
console.error('Error listando usuarios', err);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function createUser(req: NextRequest) {
try {
const payload: unknown = await req.json();
const data = validateCreateUser(payload); // lanza si es inválido
const created = await userService.create(data as CreateUserDTO);
return NextResponse.json(created, { status: 201 });
} catch (err: any) {
if (err.name === 'ValidationError') {
return NextResponse.json({ error: err.message }, { status: 400 });
}
console.error('Error creando usuario', err);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
// src/app/api/v1/users/validator.ts
import { z } from 'zod';
import type { CreateUserDTO } from './dto';
const schema = z.object({
id: z.string().uuid(),
name: z.string().min(2).max(100),
email: z.string().email(),
});
export function validateCreateUser(payload: unknown): CreateUserDTO {
const result = schema.safeParse(payload);
if (!result.success) {
const msg = result.error.errors.map(e => e.message).join(', ');
const err = new Error(`ValidationError: ${msg}`);
err.name = 'ValidationError';
throw err;
}
return result.data;
}
Mejoras:
- Separación de responsabilidades (router → controller → service).
- Validación estructurada con zod.
- Manejo de errores con códigos HTTP adecuados.
3.3 Versión 3 – Service + Repository + Singleton
// src/app/services/userService.ts
import { userRepository } from '../repositories/userRepository';
import type { CreateUserDTO } from '../api/v1/users/dto';
class UserService {
async list() {
// Posible lógica de negocio: filtrar usuarios inactivos
return userRepository.findAll();
}
async create(dto: CreateUserDTO) {
// Regla de negocio: email debe ser único
const exists = await userRepository.findByEmail(dto.email);
if (exists) {
const err = new Error('Email already registered');
err.name = 'ConflictError';
throw err;
}
return userRepository.insert(dto);
}
}
// Exportamos una única instancia (Singleton) para evitar múltiples conexiones DB
export const userService = new UserService();
// src/app/repositories/userRepository.ts
import { Pool } from 'pg';
import type { CreateUserDTO } from '../api/v1/users/dto';
// Pool configurado vía variables de entorno
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export const userRepository = {
async findAll() {
const result = await pool.query('SELECT id, name, email FROM users');
return result.rows;
},
async findByEmail(email: string) {
const result = await pool.query('SELECT id FROM users WHERE email = $1', [email]);
return result.rowCount > 0 ? result.rows[0] : null;
},
async insert(dto: CreateUserDTO) {
const { id, name, email } = dto;
await pool.query(
'INSERT INTO users (id, name, email) VALUES ($1, $2, $3)',
[id, name, email],
);
return { id, name, email };
},
};
Ventajas:
- Repository aísla la capa de persistencia, facilitando pruebas unitarias con mocks.
- Singleton evita crear múltiples pools de conexión.
- Se pueden añadir transacciones o eventos de dominio sin tocar el router.
3.4 Versión 4 – Caching con Redis y estrategia de invalidación
// src/app/cache/userCache.ts
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
export const userCache = {
async getAll(): Promise<any[] | null> {
const cached = await redis.get('users:all');
return cached ? JSON.parse(cached) : null;
},
async setAll(users: any[]): Promise<void> {
// TTL de 60 segundos para datos relativamente estáticos
await redis.set('users:all', JSON.stringify(users), 'EX', 60);
},
async invalidateAll(): Promise<void> {
await redis.del('users:all');
},
};
// src/app/services/userService.ts (actualizado)
import { userRepository } from '../repositories/userRepository';
import { userCache } from '../cache/userCache';
import type { CreateUserDTO } from '../api/v1/users/dto';
class UserService {
async list() {
const cached = await userCache.getAll();
if (cached) return cached;
const users = await userRepository.findAll();
await userCache.setAll(users);
return users;
}
async create(dto: CreateUserDTO) {
const exists = await userRepository.findByEmail(dto.email);
if (exists) {
const err = new Error('Email already registered');
err.name = 'ConflictError';
throw err;
}
const created = await userRepository.insert(dto);
// Invalida la lista cacheada
await userCache.invalidateAll();
return created;
}
}
export const userService = new UserService();
Razonamiento:
- El cache-aside pattern permite servir rápidamente la lista de usuarios y garantiza consistencia mediante invalidación explícita al crear un nuevo registro.
- TTL bajo mantiene la frescura sin sobrecargar Redis.
3.5 Versión 5 – Middleware de observabilidad y rate‑limiting
# docker-compose.yml (fragmento)
version: '3.9'
services:
api:
build: .
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://user:pass@db:5432/mydb
- REDIS_URL=redis://redis:6379
ports:
- "3000:3000"
depends_on:
- db
- redis
db:
image: postgres:15-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: mydb
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
pgdata:
# start.sh
#!/usr/bin/env bash
set -e
echo "🚀 Iniciando dependencias con Docker Compose"
docker-compose up -d
echo "🔧 Aplicando migraciones"
npx prisma migrate deploy
echo "✅ API lista en http://localhost:3000"
// src/middleware/observability.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { trace } from '@opentelemetry/api';
import { logger } from '../utils/logger';
export async function middleware(req: NextRequest) {
const start = Date.now();
const span = trace.getTracer('api-tracer').startSpan(`HTTP ${req.method} ${req.nextUrl.pathname}`);
try {
const response = await NextResponse.next();
const duration = Date.now() - start;
logger.info({
method: req.method,
path: req.nextUrl.pathname,
status: response.status,
durationMs: duration,
});
span.setAttribute('http.status_code', response.status);
span.setAttribute('http.route', req.nextUrl.pathname);
span.end();
return response;
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: 2, message: (err as Error).message });
span.end();
throw err;
}
}
// src/utils/logger.ts
import pino from 'pino';
export const logger = pino({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
transport: {
target: 'pino-pretty',
options: { colorize: true },
},
});
Beneficios:
- OpenTelemetry brinda trazas distribuídas que facilitan la detección de cuellos de botella.
- Pino ofrece logs estructurados de bajo overhead.
- El middleware se ejecuta antes del router, garantizando que cada petición quede medida.
3.6 Versión 6 – Versionado en Header y documentación automática
// src/app/api/users/route.ts (v2 en header)
import { NextResponse } from 'next/server';
import type { NextRequest } from '
