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

  1. Por qué esta lección existe
  2. Qué se espera hoy de un framework de API
  3. El endpoint de referencia
  4. Express: el mínimo, todo a tu cargo
  5. Fastify: esquemas, plugins y velocidad
  6. NestJS: arquitectura opinada para equipos grandes
  7. Hono: ligero y multi-runtime
  8. FastAPI (Python): el tipado como contrato
  9. Django REST Framework (Python): serializers y viewsets
  10. Spring Boot (Java): el estándar empresarial
  11. ASP.NET Core (C#): minimal APIs y rendimiento
  12. Menciones: Laravel, Rails API y Go
  13. La tabla comparativa
  14. Criterios de elección honestos
  15. Lo que no depende del framework
  16. Migrar entre frameworks: de Express a Fastify
  17. Runtimes alternativos y serverless

  1. 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.

  1. 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.

  1. 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:

  1. limite por defecto 20, máximo 100; desplazamiento máximo 10.000.
  2. tueste solo admite claro, medio u oscuro.
  3. Un parámetro inválido produce 400 con {"error": {"codigo": "parametro_invalido", ...}}.
  4. El precio se almacena en céntimos enteros y se serializa en euros con dos decimales.
  5. 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.

  1. 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.

  1. 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 esquema querystring lo hace el framework, y produce el 400 solo. Ganas garantía —no puedes olvidarte— y pierdes control sobre el formato del error, que hay que personalizar con setErrorHandler para que encaje con nuestro catálogo.
  • El esquema de respuesta compila un serializador. Fastify convierte ese response.200 en una función de serialización especializada, más rápida que JSON.stringify gené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.use en Express, que es global. Eso permite, por ejemplo, aplicar un rate limiting distinto a /v1/sesiones sin 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.

  1. 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.

  1. 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.

  1. 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 422 automático (personalizable a nuestro 400).
  • Conversión de tipos: limite=20 llega como texto y se entrega como int.
  • Documentación OpenAPI 3.1 completa, servida en /docs con Swagger UI y en /redoc con Redoc, sin una línea extra.
  • Serialización con los alias precio_eurosprecioEuros, que resuelve el eterno choque entre el snake_case de Python y el camelCase del JSON.
  • Inyección de dependencias con Depends, que además hace trivial sustituir usuario_actual en 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.

  1. 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.urls

Cuarenta 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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/pedidos exija Idempotency-Key y devuelva 201 con Location es contrato. Igual en los nueve.
  • Que un ETag que coincida con If-None-Match produzca 304 lo dicta HTTP. Cambia la función que lo escribe, no la regla.
  • Que el token JWT lleve sub, rol y exp, 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_5001 porque 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.

  1. 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:

  1. 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.
  2. 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.
  3. Migra por recursos, no de golpe. Con un proxy delante puedes servir /v1/cafes desde 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.
  4. 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.
  5. 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 ETag es 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.

  1. 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:

  1. 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 un PATCH sobre estado, que 201 lleve Location, que un filtro sin resultados sea 200 con lista vacía y no 404, y que la paginación sea obligatoria con limite y desplazamiento. Nada de esto cambia: lo escribirás con @PostMapping en lugar de router.post, y punto.
  2. Caché y concurrencia (04-06). ETag, If-None-Match304, If-Match412, Cache-Control con max-age. Spring lo expone con ResponseEntity.ok().eTag(...) y ShallowEtagHeaderFilter, pero las reglas son las de HTTP y las decisiones de política son las mismas que tomaste.
  3. Todo el módulo 5. La colección de Postman funciona sin tocar una línea, porque habla HTTP. El openapi.yaml es el mismo documento. La tubería de CI cambia npm ci por mvn verify y 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

Módulo 2: Diseño de APIs RESTful

Módulo 3: Desarrollo de APIs RESTful

Módulo 4: Buenas Prácticas y Seguridad

Módulo 5: Herramientas y Frameworks

Módulo 6: Casos de Estudio y Proyectos

© Copyright 2026. Todos los derechos reservados