En este artículo vemos los seis patrones uno a uno, con el porqué de negocio y la implementación real en NestJS, el framework con el que construimos la mayoría de nuestros backends.
Si prefieres la versión resumida para pegarla en tu IA de confianza y que genere código con estos estándares, la tienes al final del artículo.
1. Versionado desde el día uno
El problema: publicas tu API sin versionar. Seis meses después necesitas cambiar la estructura de una respuesta... y tienes tres clientes (tu web, tu app móvil y una integración de un partner) consumiéndola en producción. Cualquier cambio rompe algo.
La regla: todas las rutas nacen con /v1. Los breaking changes van a /v2. Un contrato publicado no se toca jamás.
En NestJS el versionado por URI viene de serie:
// main.ts
import { VersioningType } from '@nestjs/common';
const app = await NestFactory.create(AppModule);
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1',
});
// invoices.controller.ts
@Controller({ path: 'invoices', version: '1' })
export class InvoicesController {
@Get()
findAll() { /* ... */ }
}
// Cuando llegue el breaking change:
@Controller({ path: 'invoices', version: '2' })
export class InvoicesV2Controller { /* ... */ }
Poner esto el primer día cuesta cinco minutos. Migrar clientes de una API sin versionar cuesta semanas y favores.
2. Idempotencia en operaciones críticas
El problema: un cliente pulsa "Pagar", la red falla justo después de que el servidor procese el cobro, el frontend reintenta... y el cliente paga dos veces. Este bug existe en más sistemas en producción de los que te imaginas.
La regla: las operaciones críticas (pagos, pedidos, altas) exigen un header Idempotency-Key. Si llega la misma clave dos veces, se devuelve la respuesta original sin repetir la operación.
Un interceptor de NestJS lo resuelve de forma transversal:
// idempotency.interceptor.ts
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
async intercept(context: ExecutionContext, next: CallHandler) {
const req = context.switchToHttp().getRequest();
const key = req.headers['idempotency-key'];
if (!key) {
throw new BadRequestException('Idempotency-Key header requerido');
}
const cached = await this.cache.get(`idem:${key}`);
if (cached) return of(cached); // misma clave, misma respuesta
return next.handle().pipe(
tap(async (response) => {
await this.cache.set(`idem:${key}`, response, 86_400_000); // 24h
}),
);
}
}
// payments.controller.ts
@Post()
@UseInterceptors(IdempotencyInterceptor)
createPayment(@Body() dto: CreatePaymentDto) { /* ... */ }
En producción, el almacén de claves va a Redis y conviene guardar también un estado "en proceso" para bloquear reintentos concurrentes. Pero el principio es este: un reintento jamás duplica una operación.
3. Paginación siempre
El problema: el endpoint GET /clients devuelve la lista completa. Hoy son 50 registros y va perfecto. En un año son 50.000, la consulta tarda 12 segundos y tumba el servidor un lunes a las 9:00.
La regla: toda colección se devuelve paginada, con un límite máximo por página que el cliente no puede saltarse.
// pagination.dto.ts
export class PaginationDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100) // el cliente nunca puede pedir más de 100
limit = 20;
@IsOptional()
@IsString()
cursor?: string; // cursor opaco: más estable que offset en datos que cambian
}
// clients.controller.ts
@Get()
findAll(@Query() pagination: PaginationDto) {
return this.clientsService.findPaginated(pagination);
}
La respuesta incluye siempre el cursor siguiente:
{
"data": [ ... ],
"meta": { "nextCursor": "eyJpZCI6MTIwfQ==", "hasMore": true }
}
Preferimos cursor a offset porque no se rompe cuando se insertan registros entre página y página, y porque escala mejor en tablas grandes.
4. Errores consistentes (RFC 9457)
El problema: cada endpoint devuelve los errores a su manera. Un 500 sin contexto por aquí, un { "error": true } por allá. Resultado: cada fallo es un ticket de soporte y una sesión de arqueología en los logs.
La regla: un único formato de error en toda la API, siguiendo el estándar RFC 9457 (Problem Details): type, title, status, detail. Códigos HTTP correctos, mensajes accionables y cero detalles internos expuestos.
Un exception filter global lo garantiza:
// problem-details.filter.ts
@Catch()
export class ProblemDetailsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse();
const isHttp = exception instanceof HttpException;
const status = isHttp ? exception.getStatus() : 500;
res.status(status).type('application/problem+json').json({
type: `https://api.ensodev.eu/errors/${status}`,
title: isHttp ? exception.message : 'Error interno',
status,
detail: isHttp
? this.extractDetail(exception)
: 'Ha ocurrido un error inesperado. Nuestro equipo ha sido notificado.',
// nunca: stack traces, queries SQL ni rutas internas
});
}
}
// main.ts
app.useGlobalFilters(new ProblemDetailsFilter());
Un error bien diseñado se resuelve solo: quien consume la API sabe qué pasó y qué hacer, sin abrir un ticket.
5. Rate limiting desde el principio
El problema: nadie pone límites "porque aún hay poco tráfico". Hasta que un bot, un bucle infinito en un cliente o un scraper descubren tu API y se la comen. El rate limiting se instala siempre después del primer susto — nosotros preferimos instalarlo antes.
La regla: límites por cliente desde el primer despliegue. Respuesta 429 con header Retry-After.
Con @nestjs/throttler son diez líneas:
// app.module.ts
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
@Module({
imports: [
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]), // 100 req/min
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}
Y límites más estrictos donde duele:
@Throttle({ default: { ttl: 60_000, limit: 5 } }) // login: 5 intentos/min
@Post('login')
login(@Body() dto: LoginDto) { /* ... */ }
6. Contrato documentado (OpenAPI)
El problema: la única documentación de la API vive en la cabeza de quien la escribió. Cuando esa persona no está, cada integración es ingeniería inversa.
La regla: especificación OpenAPI como fuente de verdad, generada desde el código, versionada en el repositorio y validada en CI.
// main.ts
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
const config = new DocumentBuilder()
.setTitle('EnsoDev API')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
Y en el pipeline de CI, un paso que exporta el openapi.json y falla si hay cambios de contrato no revisados. Si no está documentado, no existe — ni para tu equipo, ni para el que venga después, ni para la IA que genere el siguiente cliente.
El resumen para tu IA
Copia este bloque y pégalo en tu asistente de IA (Claude, ChatGPT, Copilot...) para que aplique estos estándares cada vez que diseñe o genere una API:
Estándares no negociables al diseñar o generar una API REST:
1. VERSIONADO: prefija todas las rutas con /v1. Los breaking changes van a /v2. Nunca modifiques un contrato ya publicado.
2. IDEMPOTENCIA: en operaciones críticas (pagos, pedidos, altas) exige un header Idempotency-Key y devuelve la misma respuesta ante reintentos.
3. PAGINACIÓN: toda colección se devuelve paginada (limit + cursor) con un máximo por página. Nunca devuelvas listas completas sin límite.
4. ERRORES: formato único en toda la API (RFC 9457 Problem Details: type, title, status, detail). Códigos HTTP correctos, sin exponer detalles internos.
5. RATE LIMITING: límites por cliente desde el primer despliegue. Responde 429 con header Retry-After.
6. CONTRATO: especificación OpenAPI como fuente de verdad, versionada en el repositorio y validada en CI.
Aplica estos 6 estándares a cualquier API que diseñes, revises o generes.
Preguntas frecuentes
¿Por qué versionar una API desde el primer día si aún no hay clientes externos?
Porque cuando llega el primer breaking change ya suele haber varios consumidores en producción (web, app, integraciones). Prefijar las rutas con /v1 desde el inicio evita migrar clientes bajo presión.
¿Qué diferencia hay entre idempotencia y rate limiting?
La idempotencia evita que un mismo reintento duplique una operación crítica como un pago. El rate limiting evita que un cliente sature la API con demasiadas peticiones. Son controles complementarios, no intercambiables.
¿Hace falta aplicar los 6 patrones desde el primer commit?
No es obligatorio, pero cuanto antes se incorporen, menos coste tiene hacerlo. Todos se pueden añadir de forma incremental a una API que ya está en producción.
¿Y si tu API ya está en producción?
Si tu sistema ya está funcionando y no cumple alguno de estos puntos, la buena noticia es que ninguno exige rehacer nada: todos se pueden incorporar de forma incremental. La menos buena es que cuanto más tarde, más caro.
En EnsoDev hacemos diagnósticos técnicos gratuitos: revisamos tu API o tu sistema, te decimos qué está bien, qué es urgente y qué puede esperar — con claridad y sin humo.