En 03-01 elegimos Express y no discutimos la decisión: había que empezar por algún sitio y Express es el punto de partida más didáctico del ecosistema Node, porque no esconde nada. Cada middleware que registramos en src/app.js lo escribimos nosotros, y por eso entendemos qué hace cada uno.
Pero Express no es la única opción, ni siquiera dentro de JavaScript, y la pregunta «¿qué framework usamos?» aparece en el primer día de cualquier proyecto nuevo. Se responde casi siempre mal: por costumbre, por moda o por un benchmark leído a medias.
Esta lección da la perspectiva que falta. Vamos a implementar el mismo endpoint, GET /v1/cafes, en nueve frameworks distintos de cinco lenguajes, con el mismo contrato: los filtros de 02-06, la paginación obligatoria, la respuesta {datos, total} y la validación que produce el 400 del catálogo. Compararemos qué hace el framework por ti y qué te deja a ti, y terminaremos con los criterios honestos de elección —los que no salen en los gráficos de peticiones por segundo— y con la observación más importante del módulo: casi nada de lo aprendido en este curso depende del framework.
Esta lección no es un tutorial de instalación. No vas a montar nueve proyectos. Los fragmentos están para ser leídos y comparados, no ejecutados; cada uno asume el proyecto ya creado con las herramientas del lenguaje correspondiente.
Contenido
- Por qué esta lección existe
- Qué se espera hoy de un framework de API
- El endpoint de referencia
- Express: el mínimo, todo a tu cargo
- Fastify: esquemas, plugins y velocidad
- NestJS: arquitectura opinada para equipos grandes
- Hono: ligero y multi-runtime
- FastAPI (Python): el tipado como contrato
- Django REST Framework (Python): serializers y viewsets
- Spring Boot (Java): el estándar empresarial
- ASP.NET Core (C#): minimal APIs y rendimiento
- Menciones: Laravel, Rails API y Go
- La tabla comparativa
- Criterios de elección honestos
- Lo que no depende del framework
- Migrar entre frameworks: de Express a Fastify
- Runtimes alternativos y serverless
- Por qué esta lección existe
Tres situaciones concretas hacen que esta comparación importe:
- Empiezas un proyecto y tienes que elegir. La decisión condiciona los siguientes años: contrataciones, formación, dependencias y velocidad de entrega. Se toma en una reunión de una hora y se paga durante cinco años.
- Cambias de trabajo y el proyecto está en otro framework. Si entiendes qué problema resuelve cada uno, aterrizas en días en lugar de en meses.
- Alguien propone migrar. Necesitas argumentos mejores que «Fastify es más rápido» para decidir si compensa.
Y hay un cuarto motivo, más de fondo: ver el mismo endpoint nueve veces enseña qué es esencial en una API REST —el contrato, los códigos, la validación, la paginación— y qué es accidental, propio del framework de turno. Es la mejor vacuna contra confundir Express con REST.
- Qué se espera hoy de un framework de API
Un framework de API se juzga por cuánto de esta lista te da resuelto y con qué calidad:
| Capacidad | Qué significa | Cómo lo resolvimos en Express |
|---|---|---|
| Enrutado | Asociar método + ruta a una función | Router de Express |
| Middleware | Cadena de funciones antes y después del manejador | 16 posiciones en src/app.js |
| Validación de entrada | Rechazar datos inválidos antes de la lógica | Zod + middleware/validacion.js (03-04) |
| Serialización de salida | Convertir objetos de dominio a JSON del contrato | Mapeadores a mano (03-03) |
| Inyección de dependencias | Que las capas no se instancien entre sí | Imports directos y repositorios/indice.js |
| Documentación automática | Producir OpenAPI sin escribirlo aparte | A mano (02-08, 05-02) |
| Manejo de errores | Un solo punto que traduce excepciones a HTTP | middleware/errores.js (03-07) |
| Rendimiento | Peticiones por segundo y latencia bajo carga | Suficiente; medido con autocannon (04-06) |
| Tipado | Que el compilador detecte errores de contrato | Ninguno: JavaScript sin tipos |
| Ecosistema | Que exista un plugin para lo que necesitas | Enorme |
| Madurez y soporte | Que siga vivo dentro de cinco años | Máxima |
Observa cuántas casillas de la columna derecha dicen «a mano». Eso no es un defecto de Express: es su propuesta. La pregunta de esta lección es qué ganas y qué pierdes cuando otro framework rellena esas casillas por ti.
- El endpoint de referencia
El contrato que implementarán los nueve, tal como lo fijamos en 02-06 y 05-02:
GET /v1/cafes?tueste=claro&precioMax=15&limite=20&desplazamiento=0&ordenar=-precioEuros
Authorization: Bearer <jwt>{
"datos": [
{
"id": "caf_001",
"nombre": "Etiopía Yirgacheffe",
"origen": "Etiopía",
"tueste": "claro",
"precioEuros": 14.50,
"stock": 120
}
],
"total": 137
}Reglas que cada implementación debe cumplir:
limitepor defecto 20, máximo 100;desplazamientomáximo 10.000.tuestesolo admiteclaro,mediouoscuro.- Un parámetro inválido produce
400con{"error": {"codigo": "parametro_invalido", ...}}. - El precio se almacena en céntimos enteros y se serializa en euros con dos decimales.
- Requiere autenticación.
Esa cuarta regla es la más reveladora: es donde se ve si el framework serializa por ti y si te deja controlar la transformación.
- Express: el mínimo, todo a tu cargo
Nuestro punto de partida, condensado para poder compararlo:
// src/rutas/cafes.js
import { Router } from 'express';
import { autenticar } from '../middleware/autenticacion.js';
import { validar } from '../middleware/validacion.js';
import { asincrono } from '../middleware/asincrono.js';
import { esquemaConsultaCafes } from '../esquemas/cafes.js';
import { obtenerCafes } from '../controladores/cafes.js';
export const rutasCafes = Router();
rutasCafes.get(
'/',
autenticar, // 03-06
validar(esquemaConsultaCafes, 'query'), // 03-04
asincrono(obtenerCafes), // 03-07: captura promesas rechazadas
);// src/esquemas/cafes.js — el contrato de entrada, en Zod
import { z } from 'zod';
export const esquemaConsultaCafes = z.object({
tueste: z.enum(['claro', 'medio', 'oscuro']).optional(),
origen: z.string().min(2).max(60).optional(),
precioMin: z.coerce.number().min(0).optional(),
precioMax: z.coerce.number().min(0).optional(),
limite: z.coerce.number().int().min(1).max(100).default(20),
desplazamiento: z.coerce.number().int().min(0).max(10000).default(0),
ordenar: z.string().default('nombre'),
}).strict(); // .strict(): un parámetro desconocido produce 400// src/controladores/cafes.js
import { servicioCafes } from '../servicios/cafes.js';
import { aCafePublico } from '../servicios/mapeadores.js';
export async function obtenerCafes(req, res) {
const { datos, total } = await servicioCafes.listar(req.validado.query);
// El mapeador convierte céntimos a euros: la conversión es EXPLÍCITA y nuestra
res.json({ datos: datos.map(aCafePublico), total });
}Balance de Express. El código es transparente: se lee de arriba abajo y no hay magia. Cada garantía del contrato existe porque la escribimos. El coste es que la lista de "a mano" del apartado 2 es larga, y que nada te obliga: es perfectamente posible que un compañero registre una ruta sin validar y nadie se entere hasta que llegue un 500.
- Fastify: esquemas, plugins y velocidad
Fastify nació preguntándose si se podía tener la simplicidad de Express con mejor rendimiento. La respuesta fue sí, y el mecanismo es interesante: JSON Schema como pieza central.
// rutas/cafes.js — Fastify
// El esquema NO es solo validación: también genera la documentación
// y compila un serializador específico, que es de donde sale la velocidad.
const esquemaListarCafes = {
tags: ['Cafés'],
summary: 'Lista el catálogo de cafés',
operationId: 'obtenerCafes',
security: [{ bearerJWT: [] }],
querystring: {
type: 'object',
additionalProperties: false, // parámetro desconocido → 400 automático
properties: {
tueste: { type: 'string', enum: ['claro', 'medio', 'oscuro'] },
origen: { type: 'string', minLength: 2, maxLength: 60 },
precioMin: { type: 'number', minimum: 0 },
precioMax: { type: 'number', minimum: 0 },
limite: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
desplazamiento: { type: 'integer', minimum: 0, maximum: 10000, default: 0 },
ordenar: { type: 'string', default: 'nombre' },
},
},
response: {
200: {
type: 'object',
properties: {
datos: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
nombre: { type: 'string' },
origen: { type: 'string' },
tueste: { type: 'string' },
precioEuros: { type: 'number' },
stock: { type: 'integer' },
},
},
},
total: { type: 'integer' },
},
},
400: { $ref: 'Error#' },
},
};
export default async function rutasCafes(fastify) {
fastify.get(
'/cafes',
{ schema: esquemaListarCafes, preHandler: [fastify.autenticar] },
async (peticion) => {
// peticion.query ya está validada Y con los valores por defecto aplicados
const { datos, total } = await fastify.servicios.cafes.listar(peticion.query);
// No hace falta res.json(): devolver el objeto basta.
return { datos: datos.map(aCafePublico), total };
},
);
}Tres cosas que Fastify hace distinto y que conviene entender:
- La validación es declarativa y automática. No hay middleware
validar: el esquemaquerystringlo hace el framework, y produce el400solo. Ganas garantía —no puedes olvidarte— y pierdes control sobre el formato del error, que hay que personalizar consetErrorHandlerpara que encaje con nuestro catálogo. - El esquema de respuesta compila un serializador. Fastify convierte ese
response.200en una función de serialización especializada, más rápida queJSON.stringifygenérico. Efecto secundario crucial: los campos no declarados en el esquema se eliminan de la respuesta. Es una protección estupenda contra fugas accidentales de datos (04-02) y, a la vez, la causa número uno de «he añadido un campo y no aparece». - Los plugins tienen encapsulación real. Un plugin registrado en un ámbito no contamina a los demás, a diferencia de
app.useen Express, que es global. Eso permite, por ejemplo, aplicar un rate limiting distinto a/v1/sesionessin trucos.
La documentación es casi gratis, porque los esquemas ya están escritos:
// servidor.js — Fastify genera OpenAPI a partir de los esquemas de las rutas
await fastify.register(import('@fastify/swagger'), {
openapi: {
info: { title: 'API de Tienda Aroma', version: '1.7.0' },
servers: [{ url: 'https://api.tiendaaroma.example/v1' }],
},
});
await fastify.register(import('@fastify/swagger-ui'), { routePrefix: '/docs' });Aquí está la diferencia práctica con 05-02: en Express escribimos openapi.yaml a mano y arriesgamos deriva; en Fastify el esquema es la validación y es la documentación. A cambio, la especificación resultante es más pobre en descripciones y ejemplos si nadie los escribe.
- NestJS: arquitectura opinada para equipos grandes
NestJS es el framework más opinado del ecosistema Node. Trae TypeScript, decoradores, módulos e inyección de dependencias; su modelo mental viene de Angular y, más atrás, de Spring.
// cafes/dto/consulta-cafes.dto.ts — el contrato de entrada como CLASE
import { IsOptional, IsIn, IsInt, IsNumber, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class ConsultaCafesDto {
@ApiPropertyOptional({ enum: ['claro', 'medio', 'oscuro'] })
@IsOptional()
@IsIn(['claro', 'medio', 'oscuro'])
tueste?: 'claro' | 'medio' | 'oscuro';
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@Type(() => Number) // la query llega como string: hay que convertirla
@IsNumber()
@Min(0)
precioMax?: number;
@ApiPropertyOptional({ default: 20, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limite: number = 20;
@ApiPropertyOptional({ default: 0, maximum: 10000 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(10000)
desplazamiento: number = 0;
}// cafes/cafes.controller.ts
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOkResponse, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CafesService } from './cafes.service';
import { ColeccionCafesDto } from './dto/coleccion-cafes.dto';
@ApiTags('Cafés')
@ApiBearerAuth()
@Controller('cafes') // el prefijo /v1 se pone globalmente
@UseGuards(JwtAuthGuard) // autenticación para todo el controlador
export class CafesController {
// Inyección de dependencias por constructor: Nest resuelve CafesService solo.
// Esto es lo que hace trivial sustituirlo por un doble en las pruebas.
constructor(private readonly cafesService: CafesService) {}
@Get()
@ApiOkResponse({ type: ColeccionCafesDto })
async obtenerCafes(@Query() consulta: ConsultaCafesDto): Promise<ColeccionCafesDto> {
// consulta ya está validada y transformada por el ValidationPipe global.
return this.cafesService.listar(consulta);
}
}// cafes/cafes.module.ts — el módulo declara qué usa y qué expone
import { Module } from '@nestjs/common';
import { CafesController } from './cafes.controller';
import { CafesService } from './cafes.service';
import { CafesRepositorio } from './cafes.repositorio';
@Module({
controllers: [CafesController],
providers: [
CafesService,
// El repositorio se inyecta por token: cambiar SQLite por PostgreSQL
// o por un doble en pruebas es cambiar esta línea, nada más.
{ provide: 'REPOSITORIO_CAFES', useClass: CafesRepositorio },
],
exports: [CafesService],
})
export class CafesModule {}Qué ganas. Estructura idéntica en todos los proyectos y equipos, lo que reduce a días el tiempo de incorporación de alguien nuevo. Inyección de dependencias de verdad, que hace triviales las pruebas con dobles —lo que en 03-08 conseguimos a mano con el repositorio en memoria—. Documentación OpenAPI generada desde los DTO con @nestjs/swagger. Y una convención tan fuerte que las discusiones sobre estructura de carpetas desaparecen.
Qué pagas. Mucho código para poco: el DTO anterior son treinta líneas para lo que en Zod son seis. Curva de aprendizaje real —módulos, proveedores, ámbitos, guardias, interceptores, tuberías—. Arranque y consumo de memoria mayores. Y una capa de abstracción que, cuando falla, obliga a entender sus interioridades.
Cuándo compensa. Equipos de más de cinco personas, proyectos de largo recorrido, dominios complejos con muchos módulos. Para una API de seis endpoints es un traje demasiado grande.
- Hono: ligero y multi-runtime
Hono responde a una pregunta nueva: ¿y si tu API no corre en un servidor Node, sino en el borde de la red —Cloudflare Workers, Deno Deploy, Bun—?
// rutas/cafes.ts — Hono
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { autenticar } from '../middleware/autenticacion';
const esquemaConsulta = z.object({
tueste: z.enum(['claro', 'medio', 'oscuro']).optional(),
precioMax: z.coerce.number().min(0).optional(),
limite: z.coerce.number().int().min(1).max(100).default(20),
desplazamiento: z.coerce.number().int().min(0).max(10000).default(0),
});
export const rutasCafes = new Hono();
rutasCafes.get(
'/cafes',
autenticar,
zValidator('query', esquemaConsulta, (resultado, c) => {
// Manejador de error propio: así el 400 encaja con NUESTRO catálogo
if (!resultado.success) {
return c.json({
error: {
codigo: 'parametro_invalido',
mensaje: 'Parámetros de consulta inválidos.',
detalles: resultado.error.issues.map((i) => ({
campo: i.path.join('.'),
problema: i.message,
})),
},
}, 400);
}
}),
async (c) => {
const consulta = c.req.valid('query'); // tipado, sin aserciones
const { datos, total } = await listarCafes(c.env.BD, consulta);
return c.json({ datos: datos.map(aCafePublico), total });
},
);Lo interesante de Hono no es la sintaxis —muy parecida a Express— sino que usa APIs web estándar: Request, Response, fetch. El mismo código corre en Node, Deno, Bun, Cloudflare Workers y AWS Lambda. Es diminuto (unos pocos kilobytes), lo que importa mucho en entornos donde el arranque en frío se mide en milisegundos.
Su límite es el otro lado de la misma moneda: en el borde no tienes sistema de ficheros ni conexiones TCP persistentes, así que better-sqlite3 y el pool de PostgreSQL de 03-05 no existen; hay que usar servicios de datos con API HTTP. Y su ecosistema es mucho menor que el de Express.
- FastAPI (Python): el tipado como contrato
FastAPI es probablemente la mejor demostración de una idea: si el lenguaje tiene anotaciones de tipos, el framework puede deducir de ellas la validación, la serialización y la documentación.
# rutas/cafes.py — FastAPI
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
router = APIRouter(prefix="/cafes", tags=["Cafés"])
class Cafe(BaseModel):
"""Un café del catálogo, tal como se expone en la API."""
id: str = Field(pattern=r"^caf_[A-Za-z0-9]+$", examples=["caf_001"])
nombre: str = Field(max_length=120)
origen: str
tueste: Literal["claro", "medio", "oscuro"]
precio_euros: float = Field(serialization_alias="precioEuros", ge=0)
stock: int = Field(ge=0)
class ColeccionCafes(BaseModel):
datos: list[Cafe]
total: int = Field(description="Total de elementos que cumplen el filtro.")
@router.get(
"",
response_model=ColeccionCafes,
operation_id="obtenerCafes",
summary="Lista el catálogo de cafés",
responses={400: {"description": "Parámetro de consulta inválido."}},
)
async def obtener_cafes(
# Cada parámetro es un argumento tipado: FastAPI lo valida y lo documenta.
tueste: Annotated[Literal["claro", "medio", "oscuro"] | None, Query()] = None,
precio_max: Annotated[float | None, Query(ge=0, alias="precioMax")] = None,
limite: Annotated[int, Query(ge=1, le=100)] = 20,
desplazamiento: Annotated[int, Query(ge=0, le=10_000)] = 0,
ordenar: Annotated[str, Query()] = "nombre",
# Dependencia inyectada: valida el JWT y devuelve el usuario, o lanza 401.
usuario: Annotated[Usuario, Depends(usuario_actual)] = None,
) -> ColeccionCafes:
datos, total = await servicio_cafes.listar(
tueste=tueste, precio_max=precio_max,
limite=limite, desplazamiento=desplazamiento, ordenar=ordenar,
)
return ColeccionCafes(datos=datos, total=total)Lo que ocurre con esas veinte líneas, sin escribir nada más:
- Validación completa de tipos y rangos, con
422automático (personalizable a nuestro400). - Conversión de tipos:
limite=20llega como texto y se entrega comoint. - Documentación OpenAPI 3.1 completa, servida en
/docscon Swagger UI y en/redoccon Redoc, sin una línea extra. - Serialización con los alias
precio_euros→precioEuros, que resuelve el eterno choque entre elsnake_casede Python y elcamelCasedel JSON. - Inyección de dependencias con
Depends, que además hace trivial sustituirusuario_actualen las pruebas.
FastAPI es la respuesta más elegante del panorama a la deriva entre código y contrato de 05-02. Sus límites: Python es más lento que los runtimes compilados —aunque FastAPI, sobre async, es de lo más rápido del ecosistema—, y el async de Python es fácil de estropear: una llamada bloqueante dentro de una función async congela el bucle de eventos entero, un error tan clásico como el await olvidado en Node.
- Django REST Framework (Python): serializers y viewsets
DRF es la otra escuela: no un framework de API, sino una capa de API sobre un framework web completo. Su premisa es que ya tienes modelos de Django y quieres exponerlos.
# cafes/serializers.py
from rest_framework import serializers
from .models import Cafe
class CafeSerializer(serializers.ModelSerializer):
"""Convierte el modelo Cafe a la representación pública y viceversa."""
# El modelo guarda céntimos enteros; el contrato expone euros.
precioEuros = serializers.SerializerMethodField()
notasCata = serializers.ListField(source="notas_cata", child=serializers.CharField())
class Meta:
model = Cafe
fields = ["id", "nombre", "origen", "tueste", "precioEuros", "stock", "notasCata"]
read_only_fields = ["id"]
def get_precioEuros(self, obj) -> float:
return obj.precio_centimos / 100# cafes/views.py
from rest_framework import viewsets, permissions
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import OrderingFilter
from .models import Cafe
from .serializers import CafeSerializer
from .pagination import PaginacionAroma
class CafeViewSet(viewsets.ModelViewSet):
"""
Un ViewSet genera de golpe list, retrieve, create, update y destroy.
Es la máxima expresión del "convención sobre configuración" de Django.
"""
queryset = Cafe.objects.all()
serializer_class = CafeSerializer
permission_classes = [permissions.IsAuthenticated]
pagination_class = PaginacionAroma # produce {"datos": [...], "total": n}
filter_backends = [DjangoFilterBackend, OrderingFilter]
filterset_fields = {
"tueste": ["exact", "in"],
"origen": ["exact", "icontains"],
"precio_centimos": ["gte", "lte"],
}
ordering_fields = ["nombre", "precio_centimos", "stock"]
ordering = ["nombre"]# cafes/urls.py — el router genera todas las rutas del recurso
from rest_framework.routers import DefaultRouter
from .views import CafeViewSet
router = DefaultRouter()
router.register(r"cafes", CafeViewSet, basename="cafe")
urlpatterns = router.urlsCuarenta líneas producen los siete endpoints del recurso completo, con filtrado, ordenación, paginación, permisos y una consola HTML navegable. Es, con diferencia, la mayor productividad inicial de todo este recorrido.
El precio. Los ViewSets exponen la forma de tu modelo de datos, no la de tu contrato de API, y ahí choca de frente con 02-02: el día que el contrato deba divergir del modelo —campos calculados, agregaciones, nombres distintos, subrecursos de transición de estado como /pedidos/{id}/pago— peleas contra el framework. Se puede hacer, con serializers y acciones personalizadas, pero cada excepción cuesta más que si lo hubieras escrito a mano. Además, Django arrastra una filosofía completa: su ORM, su sistema de migraciones, su admin. Si no vas a usarlos, DRF es un peso muerto.
Cuándo compensa: un CRUD sobre un modelo relacional que ya existe, con panel de administración incluido. Es imbatible en ese terreno.
- Spring Boot (Java): el estándar empresarial
Spring Boot es el framework que más APIs corporativas sostiene en el mundo. Su modelo —anotaciones, inyección de dependencias, capas— es el que NestJS imita.
// CafeControlador.java
package example.tiendaaroma.cafes;
import jakarta.validation.constraints.*;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@RestController
@RequestMapping("/v1/cafes")
@Tag(name = "Cafés", description = "Catálogo de cafés de especialidad")
public class CafeControlador {
private final CafeServicio cafeServicio;
// Inyección por constructor: Spring resuelve la dependencia al arrancar.
public CafeControlador(CafeServicio cafeServicio) {
this.cafeServicio = cafeServicio;
}
@GetMapping
@PreAuthorize("isAuthenticated()")
@Operation(operationId = "obtenerCafes", summary = "Lista el catálogo de cafés")
public ResponseEntity<ColeccionCafes> obtenerCafes(
@RequestParam(required = false)
@Pattern(regexp = "claro|medio|oscuro", message = "tueste inválido")
String tueste,
@RequestParam(required = false) @DecimalMin("0") BigDecimal precioMax,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int limite,
@RequestParam(defaultValue = "0") @Min(0) @Max(10000) int desplazamiento,
@RequestParam(defaultValue = "nombre") String ordenar) {
var filtro = new FiltroCafes(tueste, precioMax, ordenar);
var pagina = cafeServicio.listar(filtro, limite, desplazamiento);
return ResponseEntity.ok()
.eTag(pagina.etag()) // 04-06
.header("Link", pagina.cabeceraLink()) // 02-06
.body(new ColeccionCafes(pagina.datos(), pagina.total()));
}
}// ColeccionCafes.java — un record: inmutable y conciso
public record ColeccionCafes(List<CafeDto> datos, long total) {}
// CafeDto.java — la representación pública, separada de la entidad JPA
public record CafeDto(
String id,
String nombre,
String origen,
String tueste,
BigDecimal precioEuros, // BigDecimal, NUNCA double, para dinero
int stock) {}Java resuelve limpiamente algo que en JavaScript es un problema real: BigDecimal para el dinero. En JavaScript, 0.1 + 0.2 no es 0.3, y por eso en 02-05 decidimos guardar céntimos enteros. Java tiene un tipo decimal exacto de serie, igual que C# con decimal.
Su ecosistema es el mayor argumento: Spring Security para OAuth y OIDC (04-03), Spring Data para persistencia, Actuator que da /salud y métricas de Prometheus (04-07) prácticamente gratis, springdoc-openapi para la documentación. Todo lo que en el módulo 4 construimos pieza a pieza existe aquí como dependencia estándar, revisada y con soporte comercial.
El precio: verbosidad, arranque lento (segundos, aunque GraalVM lo mitiga), consumo de memoria alto, y una curva de aprendizaje larga donde el problema no es Java sino la cantidad de conceptos de Spring.
- ASP.NET Core (C#): minimal APIs y rendimiento
ASP.NET Core es, en los benchmarks públicos, uno de los frameworks web más rápidos que existen, y su modo minimal API elimina buena parte de la ceremonia clásica de C#.
// Program.cs — minimal API completa
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<ICafeServicio, CafeServicio>(); // inyección de dependencias
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
builder.Services.AddEndpointsApiExplorer(); // metadatos para OpenAPI
builder.Services.AddSwaggerGen();
var app = builder.Build();
app.MapGet("/v1/cafes", async (
[AsParameters] ConsultaCafes consulta,
ICafeServicio servicio) =>
{
// La validación de rangos se hace con un filtro de endpoint o con
// una biblioteca como FluentValidation; C# no la trae de serie.
if (consulta.Limite is < 1 or > 100)
{
return Results.BadRequest(new ErrorApi(
"parametro_invalido", "El parámetro 'limite' debe estar entre 1 y 100."));
}
var (datos, total) = await servicio.ListarAsync(consulta);
return Results.Ok(new ColeccionCafes(datos, total));
})
.RequireAuthorization()
.WithName("obtenerCafes")
.WithTags("Cafés")
.WithOpenApi();
app.Run();
// Tipos del contrato: records inmutables, con decimal para el dinero
record ConsultaCafes(string? Tueste, decimal? PrecioMax, int Limite = 20,
int Desplazamiento = 0, string Ordenar = "nombre");
record CafeDto(string Id, string Nombre, string Origen, string Tueste,
decimal PrecioEuros, int Stock);
record ColeccionCafes(IReadOnlyList<CafeDto> Datos, long Total);
record ErrorApi(string Codigo, string Mensaje, object[]? Detalles = null);Puntos destacables: [AsParameters] agrupa los parámetros de consulta en un record tipado, decimal da aritmética exacta para el dinero, y el rendimiento en el mismo hardware suele estar entre los mejores del panorama. Su punto flojo relativo es la validación declarativa, que no viene de serie con la potencia de Pydantic o Zod.
Cuándo elegirlo: organizaciones ya en el ecosistema Microsoft, o cuando el rendimiento por servidor es un factor de coste real. Y conviene desmontar el prejuicio: ASP.NET Core es multiplataforma, de código abierto y corre en Linux y en contenedores con normalidad.
- Menciones: Laravel, Rails API y Go
Laravel (PHP). Sigue siendo dominante en la web PHP y su modo API con Eloquent, API Resources y Sanctum es productivo y bien documentado. Su hosting es el más barato y disponible del mundo. Estigmatizado sin motivo: el PHP moderno con tipos poco tiene que ver con el de hace quince años.
Ruby on Rails API (rails new --api). Padre del "convención sobre configuración" que inspiró a medio sector. Enorme productividad inicial, ideal para prototipos y startups. Su rendimiento por proceso es modesto y el ecosistema ha perdido impulso frente a otros.
Go con net/http (que desde Go 1.22 enruta con patrones de método y ruta), o con Gin o Echo:
// manejadores/cafes.go — Go con la biblioteca estándar
func ObtenerCafes(servicio *ServicioCafes) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
consulta, err := parsearConsultaCafes(r.URL.Query())
if err != nil {
// El manejo explícito de errores es la marca de la casa en Go:
// verboso, pero ningún error pasa desapercibido.
responderError(w, http.StatusBadRequest, "parametro_invalido", err.Error())
return
}
datos, total, err := servicio.Listar(r.Context(), consulta)
if err != nil {
responderError(w, http.StatusInternalServerError, "error_interno", "")
return
}
responderJSON(w, http.StatusOK, ColeccionCafes{Datos: datos, Total: total})
}
}Go destaca en tres cosas: binario único sin runtime (imágenes Docker de pocos megas, arranque instantáneo), concurrencia con goroutines y muy poca memoria por petición, y una biblioteca estándar tan completa que muchos equipos no usan framework. A cambio, escribes más código: no hay validación declarativa ni generación automática de OpenAPI sin herramientas adicionales.
- La tabla comparativa
| Framework | Lenguaje | Curva | Opinado | Validación | OpenAPI automático | Rendimiento relativo | Tipado | Ecosistema | Cuándo elegirlo |
|---|---|---|---|---|---|---|---|---|---|
| Express | JavaScript | Muy baja | Nada | Manual (Zod) | No | Medio | Opcional (JSDoc/TS) | Enorme | Aprender; APIs pequeñas; máximo control |
| Fastify | JavaScript/TS | Baja | Poco | JSON Schema integrada | Sí (plugin) | Alto | Bueno con TS | Grande | Node con exigencia de rendimiento y contrato |
| NestJS | TypeScript | Alta | Mucho | class-validator |
Sí (@nestjs/swagger) |
Medio | Excelente | Grande | Equipos grandes, dominio complejo, largo plazo |
| Hono | TypeScript | Baja | Poco | Zod (adaptador) | Sí (plugin) | Muy alto | Excelente | Mediano | Edge, serverless, multi-runtime |
| FastAPI | Python | Baja | Medio | Pydantic, de serie | Sí, nativo y completo | Medio-alto | Excelente | Grande | APIs con datos, ML, prototipado rápido y serio |
| Django REST | Python | Media | Mucho | Serializers | Sí (drf-spectacular) |
Medio-bajo | Débil | Enorme | CRUD sobre modelo relacional con admin |
| Spring Boot | Java | Alta | Mucho | Bean Validation | Sí (springdoc) | Alto | Excelente | Enorme | Empresa, integraciones, soporte a largo plazo |
| ASP.NET Core | C# | Media | Medio | Manual/FluentValidation | Sí (Swashbuckle) | Muy alto | Excelente | Grande | Ecosistema Microsoft; coste por servidor |
| Laravel | PHP | Baja | Mucho | Form Requests | Sí (paquetes) | Medio | Medio | Enorme | Web PHP, hosting barato, entrega rápida |
| Rails API | Ruby | Baja | Mucho | Modelo | Sí (paquetes) | Medio-bajo | Débil | Grande | Prototipos, startups, productividad inicial |
| Go + Gin | Go | Media | Poco | Manual/tags | Parcial | Muy alto | Excelente | Mediano | Servicios de alto rendimiento, contenedores mínimos |
Cómo leer la columna de rendimiento. Es una ordenación aproximada de peticiones por segundo en pruebas sintéticas de "devolver un JSON pequeño". Casi nunca describe tu API real, porque en cuanto hay una consulta a base de datos, una llamada de red o una plantilla, el framework deja de ser el cuello de botella. Un GET /v1/cafes que tarda 40 ms de los cuales 35 son la consulta SQL rinde prácticamente igual en Express que en Fastify. Si tu p99 es alto, mide antes de culpar al framework: la respuesta suele estar en el apartado de índices y N+1 de 04-06.
- Criterios de elección honestos
Los criterios reales, ordenados por el peso que deberían tener:
1. El lenguaje que domina tu equipo. Es el criterio número uno, por mucho. Un equipo experto en Python entregará antes y con menos errores en FastAPI que en el framework Node más rápido del mundo. El coste de aprender un lenguaje nuevo —no la sintaxis, sino sus modismos, su depuración, su empaquetado, sus trampas— se mide en meses de productividad reducida y en errores sutiles en producción.
2. Contratación y mercado laboral. Si mañana necesitas dos personas más, ¿las encuentras? ¿Y en tu ciudad, o en tu franja horaria? Elegir un framework de nicho porque es elegante y descubrir que no hay a quién contratar es un error caro y frecuente.
3. Madurez y horizonte de soporte. ¿Quién lo mantiene? ¿Hay una empresa detrás, una fundación, una persona? ¿Cuál es la política de versiones? Spring y Django llevan más de quince años y seguirán; un framework de dos años con un solo mantenedor es una apuesta. Este criterio pesa el doble si la API es de largo recorrido y el triple si estás en un sector regulado.
4. Encaje con el problema. Un CRUD sobre modelo relacional con panel de administración pide DRF a gritos. Una API en el borde con latencia mínima pide Hono. Un dominio complejo con veinte módulos y quince personas pide NestJS o Spring. Forzar la herramienta contra el problema se paga cada semana.
5. Ecosistema para lo que necesitas. No el ecosistema en abstracto: ¿existe un cliente maduro para tu base de datos, tu proveedor de OAuth, tu pasarela de pago, tu sistema de colas? Descubrir en la semana seis que el SDK de tu pasarela no existe en ese lenguaje es un problema serio.
6. Rendimiento. El último, salvo que estés en un caso concreto donde importa: decenas de miles de peticiones por segundo, latencia sub-10 ms, o una factura de servidores lo bastante grande como para que un 30 % de eficiencia sea dinero real. Para el 95 % de las APIs, la diferencia entre frameworks es irrelevante frente a una consulta sin índice.
Y una advertencia sobre los benchmarks públicos: miden un endpoint que devuelve {"hello":"world"}, con configuraciones optimizadas por especialistas y sin base de datos, sin autenticación, sin validación y sin logs. Son útiles para descartar órdenes de magnitud y engañosos para todo lo demás. Mide tu API real con autocannon (04-06) antes de tomar cualquier decisión basada en velocidad.
- Lo que no depende del framework
Esta es la observación central de la lección. Repasa el curso:
| Módulo | ¿Depende del framework? |
|---|---|
| 1 — HTTP, REST, restricciones, HATEOAS | No. Es HTTP y arquitectura. |
| 2 — Recursos, métodos, códigos, errores, paginación, versionado | No. Es diseño de contrato. |
| 3 — Entorno, rutas, validación, persistencia, autenticación, errores, pruebas | Sí. Aquí cambia todo. |
| 4 — Seguridad, OAuth, rate limiting, CORS, caché, observabilidad | Casi nada. Los conceptos y las cabeceras son idénticos; solo cambia la biblioteca. |
| 5 — Postman, OpenAPI, contratos, CI/CD, gateways | No. Todo funciona sobre HTTP. |
| 6 — Casos de estudio y evolución | No. |
De seis módulos, uno cambia. Y ni siquiera entero: la separación por capas de 03-03 —rutas, controladores, servicios, repositorios— se conserva en todos ellos con otros nombres, porque no es una idea de Express.
Ejemplos concretos de lo que se transfiere sin cambios:
- Que
POST /v1/pedidosexijaIdempotency-Keyy devuelva201conLocationes contrato. Igual en los nueve. - Que un
ETagque coincida conIf-None-Matchproduzca304lo dicta HTTP. Cambia la función que lo escribe, no la regla. - Que el token JWT lleve
sub,rolyexp, y que la validación compruebe firma, expiración y emisor, es OAuth y JWT. Cambia la biblioteca. - Que un error se devuelva como
{"error": {"codigo", "mensaje", "detalles"}}es tu catálogo. - Que las etiquetas de las métricas no incluyan
ped_5001porque revientan la cardinalidad es una regla de Prometheus.
Conclusión práctica: si sabes diseñar y operar APIs, aprender un framework nuevo es cuestión de una o dos semanas. Si solo sabes Express, no sabes hacer APIs: sabes usar Express. El conocimiento transferible es el de los módulos 1, 2, 4 y 5.
- Migrar entre frameworks: de Express a Fastify
Supongamos que Tienda Aroma decide migrar a Fastify por rendimiento y por la generación automática de OpenAPI. ¿Qué pasa con el proyecto?
graph TD
A[Proyecto Express] --> B{¿Qué se conserva?}
B --> C[openapi.yaml<br/>El contrato no cambia]
B --> D[servicios/<br/>Lógica de negocio pura]
B --> E[repositorios/<br/>SQL y persistencia]
B --> F[esquemas Zod<br/>convertibles a JSON Schema]
B --> G[pruebas de integración<br/>Supertest sobre HTTP]
B --> H[coleccion Postman<br/>y Newman]
A --> I{¿Qué se reescribe?}
I --> J[rutas/<br/>Router → plugins]
I --> K[middleware/<br/>hooks y decoradores]
I --> L[app.js<br/>orden de la cadena]
I --> M[controladores/<br/>firma req,res → async]
Se conserva todo lo que no toca req y res: los servicios de src/servicios/, los repositorios de src/repositorios/, los mapeadores, src/errores/error-api.js, las migraciones y —lo más valioso— el openapi.yaml y las pruebas de integración de 03-08, porque hablan HTTP y les da igual quién responda. Esas pruebas son la red de seguridad que hace posible la migración: si pasan con Fastify, la migración es correcta por definición.
Se reescribe la capa de entrega: rutas, middlewares y la composición de la aplicación.
Aquí está el mismo endpoint, antes y después:
// ANTES — Express
rutasCafes.get('/', autenticar, validar(esquemaConsultaCafes, 'query'),
asincrono(async (req, res) => {
const { datos, total } = await servicioCafes.listar(req.validado.query);
res.json({ datos: datos.map(aCafePublico), total });
}));
// DESPUÉS — Fastify
// - `autenticar` pasa de middleware a `preHandler`.
// - `validar` desaparece: lo hace el esquema del propio framework.
// - `asincrono` desaparece: Fastify captura las promesas rechazadas de serie.
// - `res.json(...)` se sustituye por devolver el objeto.
fastify.get('/cafes', {
schema: esquemaListarCafes,
preHandler: [fastify.autenticar],
}, async (peticion) => {
const { datos, total } = await servicioCafes.listar(peticion.query);
return { datos: datos.map(aCafePublico), total };
});El controlador es idéntico salvo la firma. Los esquemas Zod se convierten con zod-to-json-schema, la herramienta que ya vimos en 05-02.
La receta de migración, si alguna vez te toca:
- Congela el contrato. Ni un cambio funcional durante la migración. Si mezclas ambas cosas, no sabrás si un fallo viene de la migración o de la función nueva.
- Asegura las pruebas de integración primero. Son el criterio objetivo de éxito. Si la cobertura de los endpoints es baja, súbela antes de tocar nada.
- Migra por recursos, no de golpe. Con un proxy delante puedes servir
/v1/cafesdesde el servicio nuevo y el resto desde el viejo. Es el patrón de la higuera estranguladora, y es lo que hace viable migrar un sistema en producción. - Compara respuestas byte a byte. Ejecuta la colección de Postman de 05-01 contra ambos y diferencia. Las sorpresas suelen estar en las cabeceras y en el orden de los campos.
- Vigila los detalles pequeños, que son los que muerden: el formato exacto del error de validación, el orden de las claves del JSON, si el
ETages débil o fuerte, la codificación de los parámetros repetidos.
¿Compensa? Casi nunca por rendimiento solo. Compensa cuando el framework actual bloquea algo importante: falta de tipado en un equipo que crece, ausencia de un ecosistema que necesitas, o un mantenimiento abandonado. «Es más moderno» no es una razón; es un gasto sin retorno.
- Runtimes alternativos y serverless
Dos ejes más, brevemente, porque afectan a la elección tanto como el framework.
Runtimes de JavaScript:
| Runtime | Propuesta | Estado |
|---|---|---|
| Node.js | El estándar; ecosistema npm completo | Lo que usa Tienda Aroma. Node 20 LTS. |
| Deno | Seguro por defecto (permisos explícitos), TypeScript nativo, biblioteca estándar propia | Maduro; compatibilidad con npm ya buena |
| Bun | Velocidad extrema, gestor de paquetes y ejecutor de pruebas integrados | Joven pero utilizable; compatible con la mayoría de Express |
El detalle más interesante de Deno para lo que hemos visto en 04-02: los permisos son explícitos (--allow-net=api.tiendaaroma.example), de modo que una dependencia comprometida no puede leer tu disco ni abrir conexiones a un servidor desconocido. Es una defensa real contra los ataques de cadena de suministro que mencionamos con npm audit.
Serverless. Tu API no corre como proceso permanente, sino como funciones que se invocan bajo demanda: AWS Lambda con API Gateway, Google Cloud Functions, Azure Functions, Cloudflare Workers.
| Servidor permanente | Serverless | |
|---|---|---|
| Coste sin tráfico | El del servidor | Cero |
| Coste con mucho tráfico | Predecible | Puede dispararse |
| Escalado | Tú lo configuras | Automático |
| Arranque en frío | No existe | De decenas de ms a segundos |
| Conexiones a base de datos | Pool estable | Problema serio: hay que usar un pooler |
| Estado en memoria | Posible (caché local) | No fiable |
| Depuración local | Sencilla | Más incómoda |
Implicaciones directas para lo que hemos construido: el rate limiting con memoria local de 04-04 no funciona en serverless —cada invocación puede ir a otra instancia—, así que Redis pasa de recomendable a obligatorio; la caché en proceso desaparece; y el pool de conexiones de 03-05 se convierte en un problema que exige un pooler externo.
Express corre en Lambda con adaptadores (serverless-http), pero Hono está diseñado para ese entorno y arranca en una fracción del tiempo. Es un buen ejemplo de cómo el entorno de despliegue —tema de 05-05— condiciona la elección del framework tanto como el lenguaje.
Errores Comunes y Consejos
- Elegir por benchmark. Los gráficos miden
{"hello":"world"}sin base de datos ni autenticación. Tu p99 lo domina la consulta SQL, no el enrutador. - Elegir por moda. El framework del que todo el mundo habla este año puede estar sin mantenimiento dentro de tres. Comprueba quién lo sostiene y con qué política de versiones.
- Elegir un lenguaje que el equipo no domina. Es la decisión que más proyectos hunde. La sintaxis se aprende en una semana; la depuración, el empaquetado y las trampas del runtime, en meses.
- Confundir el framework con la arquitectura. DRF o NestJS no te dan un buen diseño de API: te dan una estructura. Un ViewSet mal usado expone tu modelo de datos y viola todo el módulo 2.
- Migrar sin pruebas. Sin las pruebas de integración de 03-08, una migración es una reescritura a ciegas. Asegúralas antes de empezar.
- Mezclar migración y funcionalidad nueva. Cuando algo falle no sabrás de qué mitad viene. Congela el contrato.
- Olvidar que Fastify elimina los campos no declarados en el esquema de respuesta. Es una virtud de seguridad y la causa más frecuente de «mi campo nuevo no sale».
- Creer que "código primero" te libra de pensar el contrato. Los tipos se generan; el diseño de 02-02 no.
- Consejo: aprende un segundo framework de otro lenguaje, no otro de Node. Comparar Express con Fastify enseña poco; comparar Express con FastAPI o Spring enseña qué es esencial y qué es accidental.
- Consejo: mantén la lógica de negocio fuera del framework. Si tus servicios no importan nada de Express, migrar es cambiar la capa de entrega. Es la razón de ser de la separación de 03-03, y su valor solo se aprecia el día que hace falta.
- Consejo: escribe un ADR con la decisión (04-01), con los criterios y las alternativas descartadas. Dentro de dos años alguien preguntará por qué, y sin ADR la respuesta será «porque sí».
Ejercicios
Ejercicio 1: elegir framework para tres escenarios
Para cada escenario, elige un framework, justifícalo con al menos tres criterios del apartado 14 y menciona la alternativa que descartas y por qué:
A) Una startup de tres personas con experiencia en Python lanza el MVP de una API de reservas de restaurantes en ocho semanas. Necesitan un panel de administración interno desde el primer día y prevén cambios de modelo constantes.
B) Un banco moderniza su API de consulta de movimientos. Diez personas, requisitos de auditoría y trazabilidad, integración con un proveedor de identidad corporativo, y soporte garantizado a diez años.
C) Una API de geolocalización que recibe 50.000 peticiones por segundo, devuelve respuestas muy pequeñas desde una caché en memoria, y debe responder en menos de 20 ms en todo el mundo.
Ejercicio 2: portar el endpoint de reseñas
Este es el endpoint GET /v1/cafes/{id}/resenas en Express:
rutasCafes.get('/:id/resenas',
autenticar,
validar(esquemaIdCafe, 'params'),
validar(esquemaConsultaResenas, 'query'),
cacheDe({ maxAge: 300, publico: true }),
asincrono(async (req, res) => {
const { id } = req.validado.params;
const { limite, desplazamiento, puntuacionMin } = req.validado.query;
const { datos, total } = await servicioResenas.listarDeCafe(id, {
limite, desplazamiento, puntuacionMin,
});
res.set('Link', construirLink(req, total, limite, desplazamiento));
res.json({ datos: datos.map(aResenaPublica), total });
}));Pórtalo a Fastify manteniendo el mismo contrato: mismos parámetros y validaciones, mismas cabeceras Cache-Control y Link, y el mismo formato de error parametro_invalido. Indica qué piezas desaparecen, cuáles cambian de nombre y qué hay que añadir explícitamente que en Express estaba en un middleware.
Ejercicio 3: qué se conserva de este curso
Un compañero se incorpora a un proyecto en Spring Boot viniendo de este curso, hecho en Express. Redacta una guía de una página que le diga: qué conocimientos de los módulos 1, 2, 4 y 5 aplica tal cual (con tres ejemplos concretos), qué tiene que reaprender del módulo 3 y cuál es el equivalente en Spring de cinco piezas de nuestro proyecto (middleware/autenticacion.js, middleware/errores.js, repositorios/cafes-sqlite.js, esquemas/cafes.js y observabilidad/metricas.js).
Soluciones
Solución 1
A) Startup de reservas → Django REST Framework.
| Criterio | Análisis |
|---|---|
| Lenguaje del equipo | Ya dominan Python. Es el criterio de mayor peso y descarta de entrada Node y Java. |
| Encaje con el problema | CRUD sobre modelo relacional (restaurantes, mesas, reservas, clientes) con panel de administración: es literalmente el caso para el que DRF existe. |
| Plazo | Ocho semanas. El django-admin gratuito ahorra semanas frente a construir un panel. Los ViewSets dan los siete endpoints por recurso casi sin código. |
| Ecosistema | Migraciones, autenticación y admin resueltos; el equipo solo escribe reglas de negocio. |
Alternativa descartada: FastAPI. Mejor contrato y mejor documentación automática, pero no trae ORM, ni migraciones, ni admin: habría que montar SQLAlchemy, Alembic y un panel, y ahí se van las ocho semanas. Se reconsideraría a los dos años si la API deja de ser un CRUD y el contrato empieza a divergir del modelo.
B) Banco → Spring Boot.
| Criterio | Análisis |
|---|---|
| Madurez y horizonte | Más de quince años, versiones con soporte extendido y respaldo comercial de VMware/Broadcom. En un sector regulado, "quién responde" es un requisito, no una preferencia. |
| Ecosistema | Spring Security con OIDC integrado con el proveedor corporativo (04-03); Actuator da salud y métricas Prometheus (04-07) de fábrica; auditoría y trazabilidad son piezas estándar. |
| Tamaño del equipo | Diez personas: la estructura opinada evita diez formas distintas de organizar el código y acelera las incorporaciones. |
| Contratación | El mercado de Java empresarial es el más profundo, especialmente en banca. |
| Precisión decimal | BigDecimal de serie: en dinero, un requisito duro. |
Alternativa descartada: NestJS. Da una arquitectura parecida con TypeScript y sería una elección defendible, pero pierde en horizonte de soporte, en madurez del ecosistema de seguridad y auditoría empresarial, y en profundidad del mercado laboral bancario. Además, JavaScript carece de tipo decimal nativo, lo que en un sistema financiero es fricción constante.
C) Geolocalización a 50.000 rps → Go, o Hono sobre Cloudflare Workers.
| Criterio | Análisis |
|---|---|
| Rendimiento | Aquí sí es un criterio de primer orden: 50.000 rps con respuestas de caché es justo el escenario donde el framework es el cuello de botella. |
| Encaje | Respuestas pequeñas desde memoria, sin base de datos por petición: el trabajo es enrutar, buscar y serializar. |
| Latencia global (<20 ms) | Imposible desde una sola región: exige presencia en el borde. Cloudflare Workers con Hono lo resuelve por diseño; con Go harían falta despliegues multirregión y enrutado por anycast. |
| Coste | Go da imágenes de pocos megas y consumo mínimo de memoria: menos máquinas para el mismo tráfico. |
Decisión: Hono en el borde si el conjunto de datos cabe en un almacén distribuido tipo KV; Go multirregión si los datos son grandes o el cálculo es intensivo. Descartados Express, NestJS y DRF: a ese volumen la diferencia de eficiencia se traduce directamente en factura, y DRF además arrastra un ORM que este caso no usa.
Solución 2
// rutas/resenas.js — Fastify
// El esquema declara TODO el contrato de entrada y de salida.
const esquemaResenasDeCafe = {
tags: ['Reseñas'],
summary: 'Lista las reseñas de un café',
operationId: 'obtenerResenasDeCafe',
security: [{ bearerJWT: [] }],
params: {
type: 'object',
required: ['id'],
properties: {
id: { type: 'string', pattern: '^caf_[A-Za-z0-9]+$' },
},
},
querystring: {
type: 'object',
additionalProperties: false,
properties: {
limite: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
desplazamiento: { type: 'integer', minimum: 0, maximum: 10000, default: 0 },
puntuacionMin: { type: 'integer', minimum: 1, maximum: 5 },
},
},
response: {
200: {
type: 'object',
properties: {
datos: { type: 'array', items: { $ref: 'Resena#' } },
total: { type: 'integer' },
},
},
400: { $ref: 'Error#' },
404: { $ref: 'Error#' },
},
};
export default async function rutasResenas(fastify) {
fastify.get('/cafes/:id/resenas', {
schema: esquemaResenasDeCafe,
preHandler: [fastify.autenticar],
// La caché HTTP se aplica con un hook onSend, porque en Fastify
// las cabeceras de respuesta se tocan en el ciclo de vida, no en un middleware.
onSend: async (peticion, respuesta, cuerpo) => {
respuesta.header('Cache-Control', 'public, max-age=300');
respuesta.header('Vary', 'Accept-Encoding, Origin');
return cuerpo;
},
}, async (peticion, respuesta) => {
const { id } = peticion.params;
const { limite, desplazamiento, puntuacionMin } = peticion.query;
const { datos, total } = await fastify.servicios.resenas.listarDeCafe(id, {
limite, desplazamiento, puntuacionMin,
});
// La cabecera Link se sigue construyendo a mano: es lógica del contrato,
// no algo que ningún framework resuelva por ti.
respuesta.header('Link', construirLink(peticion, total, limite, desplazamiento));
return { datos: datos.map(aResenaPublica), total };
});
}Y el manejador de errores global, imprescindible para que el 400 automático de Fastify hable nuestro idioma:
// app.js — traduce los errores de validación de Fastify a nuestro catálogo
fastify.setErrorHandler((error, peticion, respuesta) => {
if (error.validation) {
const enQuery = error.validationContext === 'querystring';
return respuesta.status(400).send({
error: {
codigo: enQuery ? 'parametro_invalido' : 'datos_invalidos',
mensaje: enQuery
? 'Parámetros de consulta inválidos.'
: 'El cuerpo contiene campos inválidos.',
detalles: error.validation.map((v) => ({
campo: v.instancePath.replace('/', '') || v.params?.additionalProperty,
problema: v.message,
})),
},
});
}
// ErrorApi y el resto se traducen igual que en src/middleware/errores.js
return manejadorErrores(error, peticion, respuesta);
});Balance de la migración de este endpoint:
| Pieza en Express | En Fastify |
|---|---|
validar(esquemaIdCafe, 'params') |
Desaparece: schema.params |
validar(esquemaConsultaResenas, 'query') |
Desaparece: schema.querystring |
asincrono(...) |
Desaparece: captura las promesas de serie |
autenticar |
Cambia de nombre: preHandler |
cacheDe({...}) |
Cambia de forma: hook onSend |
res.set('Link', ...) |
respuesta.header('Link', ...) |
res.json({...}) |
return {...} |
Formato del error 400 |
Hay que añadirlo: setErrorHandler |
construirLink(...) |
Idéntico: es lógica del contrato |
aResenaPublica(...) |
Idéntico: es un mapeador puro |
Lo que hay que añadir explícitamente es lo más importante de la lista: en Express controlábamos el formato del 400 porque lo escribíamos nosotros; en Fastify, si no se registra setErrorHandler, la validación automática devuelve el formato del framework y rompe el contrato de errores de 02-04 sin que nadie lo note hasta que un consumidor se queja. Es el ejemplo perfecto de que las garantías automáticas de un framework hay que reconducirlas a tu contrato, no al revés.
Solución 3
Guía de incorporación: de Express a Spring Boot
Lo que aplicas tal cual (módulos 1, 2, 4 y 5).
El 80 % de lo que sabes sigue siendo válido, porque describe HTTP y diseño, no Express. Tres ejemplos concretos:
- El diseño del contrato (módulo 2). Que los recursos sean sustantivos en plural, que las transiciones de estado se expresen como subrecursos (
POST /pedidos/{id}/pago) y no como unPATCHsobreestado, que201lleveLocation, que un filtro sin resultados sea200con lista vacía y no404, y que la paginación sea obligatoria conlimiteydesplazamiento. Nada de esto cambia: lo escribirás con@PostMappingen lugar derouter.post, y punto. - Caché y concurrencia (04-06).
ETag,If-None-Match→304,If-Match→412,Cache-Controlconmax-age. Spring lo expone conResponseEntity.ok().eTag(...)yShallowEtagHeaderFilter, pero las reglas son las de HTTP y las decisiones de política son las mismas que tomaste. - Todo el módulo 5. La colección de Postman funciona sin tocar una línea, porque habla HTTP. El
openapi.yamles el mismo documento. La tubería de CI cambianpm cipormvn verifyy poco más. Y un gateway delante no distingue qué hay detrás.
Lo que tienes que reaprender (módulo 3).
Solo la capa de entrega y sus herramientas: anotaciones en lugar de middlewares, Bean Validation en lugar de Zod, JPA o JDBC en lugar de better-sqlite3, JUnit y MockMvc en lugar de node:test y Supertest, y —lo más ajeno viniendo de Node— el contenedor de inyección de dependencias: en Spring no instancias tus servicios, los declaras y el framework los construye e inyecta.
Tabla de equivalencias:
| Pieza de Tienda Aroma | Equivalente en Spring Boot | Nota |
|---|---|---|
src/middleware/autenticacion.js |
SecurityFilterChain de Spring Security con oauth2ResourceServer().jwt() |
Spring valida firma, expiración, emisor y ámbitos; los roles se comprueban con @PreAuthorize("hasRole('ADMINISTRADOR')") sobre el método, más fino que nuestro exigirRol. |
src/middleware/errores.js |
@RestControllerAdvice con métodos @ExceptionHandler |
Mismo concepto exacto: un punto único que traduce excepciones a HTTP. ErrorApi pasa a ser una excepción propia; conviene usar ProblemDetail (RFC 9457) o mantener tu formato con un DTO. |
src/repositorios/cafes-sqlite.js |
Interfaz CafeRepository extends JpaRepository<Cafe, String> |
El patrón repositorio no es idea nuestra: Spring Data lo implementa solo a partir de la interfaz. Los métodos de consulta se deducen del nombre (findByTuesteAndPrecioCentimosLessThan). Si prefieres SQL explícito, @Query o JdbcTemplate. |
src/esquemas/cafes.js (Zod) |
Anotaciones de Bean Validation (@NotNull, @Size, @Min, @Pattern) sobre el DTO, activadas con @Valid |
Menos expresivo que Zod para transformaciones y unificado con el contrato; los mensajes se personalizan en messages.properties. |
src/observabilidad/metricas.js (prom-client) |
Micrometer + spring-boot-starter-actuator |
Métricas HTTP, JVM y del pool de conexiones prácticamente gratis en /actuator/prometheus; /actuator/health da liveness y readiness separados, exactamente lo que escribimos a mano en 04-07. Las reglas de cardinalidad son idénticas: nunca etiquetes con ped_5001. |
Consejo final para la incorporación: dedica el primer día a leer el openapi.yaml del proyecto, no el código. El contrato es lo que ya sabes leer, y te dará el mapa completo del sistema antes de enfrentarte a una sola anotación de Spring.
Conclusión
Has visto el mismo endpoint —GET /v1/cafes, con sus filtros, su paginación obligatoria, su 400 del catálogo y su conversión de céntimos a euros— resuelto en Express, Fastify, NestJS, Hono, FastAPI, Django REST Framework, Spring Boot, ASP.NET Core y Go. Y con ello has visto los tres modelos que se reparten el panorama: el minimalista, donde tú escribes todas las garantías y por eso las entiendes; el de esquema como centro, donde un mismo documento sirve de validación, serialización y documentación —Fastify y FastAPI son sus mejores exponentes, y resuelven de raíz la deriva de contrato que en 05-02 tuvimos que combatir con herramientas—; y el opinado, NestJS, Spring y DRF, que a cambio de más ceremonia dan estructura idéntica, inyección de dependencias y una productividad enorme cuando el problema encaja con lo que el framework espera.
Los criterios de elección, en su orden real: el lenguaje que domina tu equipo, la contratación, la madurez y el horizonte de soporte, el encaje con el problema, el ecosistema concreto que necesitas y, en último lugar salvo casos muy específicos, el rendimiento —porque los benchmarks miden {"hello":"world"} sin base de datos y tu p99 lo domina la consulta SQL de 04-06—. Y sabes que una migración se conserva o se pierde según una única cosa: si tu lógica de negocio importa el framework o no. En Tienda Aroma no lo hace, y por eso src/servicios/, src/repositorios/, los mapeadores, openapi.yaml, la colección de Postman y —sobre todo— las pruebas de integración de 03-08 sobrevivirían intactos a un cambio a Fastify; solo se reescribirían src/rutas/, src/middleware/ y src/app.js.
Lo más importante de esta lección es lo que no cambia. De los seis módulos del curso, solo el tercero depende del framework, y ni siquiera entero. Que un pedido exija Idempotency-Key, que un ETag coincidente produzca 304, que los errores lleven un código estable del catálogo, que la SPA necesite CORS y el panel un token con ámbitos, que las etiquetas de las métricas no incluyan identificadores: nada de eso es Express. Si sabes diseñar y operar APIs, aprender un framework nuevo son dos semanas; si solo sabes Express, sabes usar Express.
Volvemos ahora al proyecto y a un cabo que 05-02 dejó suelto. Tenemos un contrato completo y validado como documento, pero nada garantiza todavía que el servidor lo cumpla, ni que un cambio en el YAML no rompa a la SPA sin avisar. En 05-04, Contratos, mocks y pruebas automatizadas de API, cerramos ese círculo: levantaremos un mock con Prism desde openapi.yaml para que la SPA avance en paralelo, usaremos msw en el front y nock para las llamadas salientes a RápidoEnvíos, validaremos las respuestas reales contra los esquemas dentro de las pruebas Supertest que ya tenemos, detectaremos cambios rompedores con oasdiff aplicando las reglas de 02-07, veremos cuándo el contract testing con Pact compensa y cuándo es sobreingeniería, y organizaremos el recorrido de compra completo como prueba de extremo a extremo, con la tabla de qué se ejecuta al guardar, en el pull request, en el despliegue y en producción.
Curso de REST API: Principios de Diseño y Desarrollo de APIs RESTful
Módulo 1: Introducción a las APIs RESTful
- ¿Qué es una API?
- Historia y evolución de las APIs
- Fundamentos de HTTP para APIs
- Principios básicos de REST
- Modelo de madurez de Richardson y HATEOAS
- REST vs. SOAP
- REST frente a GraphQL, gRPC y webhooks
Módulo 2: Diseño de APIs RESTful
- Principios de diseño de APIs RESTful
- Recursos y URIs
- Métodos HTTP
- Códigos de estado HTTP
- Representaciones, cabeceras y negociación de contenido
- Filtrado, ordenación, paginación y búsqueda
- Versionado de APIs
- Documentación de APIs
Módulo 3: Desarrollo de APIs RESTful
- Configuración del entorno de desarrollo
- Creación de un servidor básico
- Manejo de peticiones y respuestas
- Validación de datos de entrada
- Persistencia y capa de acceso a datos
- Autenticación y autorización
- Manejo de errores
- Pruebas y validación
Módulo 4: Buenas Prácticas y Seguridad
- Buenas prácticas en el diseño de APIs
- Seguridad en APIs RESTful
- OAuth 2.0 y OpenID Connect en la práctica
- Rate limiting y throttling
- CORS y políticas de seguridad
- Caché HTTP y rendimiento
- Observabilidad: logs, métricas y trazas
Módulo 5: Herramientas y Frameworks
- Postman para pruebas de APIs
- Swagger y OpenAPI para documentación
- Frameworks populares para APIs RESTful
- Contratos, mocks y pruebas automatizadas de API
- Integración continua y despliegue
- API gateways y portales de desarrollador
