Apuntes · Capítulo 6Diseño y arquitectura

APIs y patrones de diseño

APIs y patrones de diseño

La semana pasada repartieron el código de Kallpa en módulos y vieron que lo que importa de un módulo es su interfaz: lo que los demás necesitan saber para usarlo. Esta semana esa idea sale del proceso. Una API HTTP también es una interfaz, pero tiene una diferencia que lo cambia todo: si en un refactoring renombran un método, tsc les marca cada llamada y las corrigen en el mismo pull request. Si renombran un campo de una API, quien la llama no está en su repositorio, no se entera y su aplicación se rompe en el celular de alguien.

El capítulo tiene dos partes, como la semana. La primera es sobre APIs: cómo se diseñan alrededor de recursos, qué quieren decir seguro e idempotente, por qué un reintento puede cobrar dos veces y cómo lo evita una clave de idempotencia, cómo se usan los códigos de estado y los errores, qué cambios rompen a quien llama y cómo se hace convivir una versión vieja con una nueva. La segunda es sobre patrones de diseño: cuáles siguen vigentes, cómo se ven en TypeScript (a veces son solo una función) y cómo se decide no usarlos. En el laboratorio escriben la especificación OpenAPI de Kallpa tal como es hoy, con sus defectos, y proponen las mejoras aparte.

Lectura. Para la primera parte: Google, API Design Guide, en su versión actual, las AIP: AIP-121 (recursos), AIP-136 (métodos propios), AIP-158 (paginación) y AIP-180 (compatibilidad hacia atrás); los enlaces viejos, Standard methods y Errors, hoy redirigen a las AIP correspondientes. Microsoft, Azure REST API Guidelines: Exactly Once Behavior, Handling Errors, API Versioning y Repeatability of requests. RFC 9110, HTTP Semantics, sec. 9.2 (métodos seguros e idempotentes). Complementarias: RFC 9457 (Problem Details); el borrador de la IETF The Idempotency-Key HTTP Header Field; RFC 9745 (Deprecation) y RFC 8594 (Sunset). Para la segunda parte: Refactoring.Guru, Patrones de diseño (Strategy, Adapter, Decorator, Observer y Factory Method), y Martin Fowler, Inversion of Control Containers and the Dependency Injection pattern (2004). Complementarias: Peter Norvig, Design Patterns in Dynamic Languages (1996), y Martin Fowler, Repository. El código del capítulo está en code/week-06 y se prueba con make test dentro del contenedor del curso.

Ruta de lectura.

  • Antes de clase (obligatorio, unos 20 minutos): Una API es un contrato; de Diseño orientado a recursos, Sustantivos primero y Los métodos estándar; de Seguro e idempotente, Las definiciones; de Por qué importa: la respuesta perdida, Lo único que sabe quien llama; de La clave de idempotencia, El protocolo; de Códigos de estado con significado, Los que van a usar; y de los patrones, Una solución con nombre y Strategy.
  • En clase: Sesión A: Diseño orientado a recursos, Seguro e idempotente, Por qué importa: la respuesta perdida, La clave de idempotencia, Códigos de estado con significado, Errores que un programa pueda leer, Evolución y versiones y OpenAPI: el contrato por escrito. Sesión B: Patrones de diseño: qué siguen siendo, de Strategy a Factory Method, y Cuándo un patrón estorba. Laboratorio: El laboratorio: documentar la API de Kallpa.
  • Después de clase: el Resumen y los Ejercicios. Para profundizar: La frontera entre 400 y 422, Las decisiones que el código no toma por ustedes, Deprecar sin sorprender, Lo que dijo Norvig en 1996 y las lecturas del recuadro Lectura.

Una API es un contrato

Quien llama no está en la sala

Una API (application programming interface) es la parte de un sistema que otros programas usan para hablar con él. Aquí nos interesan las API HTTP: quien llama manda una solicitud (método, ruta, cabeceras y quizá un cuerpo) y recibe una respuesta (código de estado, cabeceras y quizá un cuerpo). La página de Kallpa es un cliente HTTP de su propia API; mañana podría serlo una aplicación de celular escrita por otra persona, o la hoja de cálculo con la que alguien de la administración lee la caja del día.

Esa API es un contrato en el sentido de la semana 4: dice qué pide cada operación y qué promete a cambio. Tiene dos rasgos que un contrato entre módulos no tiene:

  • No se puede revisar a quien llama. Dentro de un repositorio, tsc y las pruebas encuentran a todos los que usan una función. Fuera, no hay manera de saber quién usa qué.
  • No se puede actualizar a todos a la vez. Un cambio en un módulo y en todos sus usuarios entra en un solo commit. Una aplicación instalada en mil celulares se actualiza cuando sus dueños quieren.

Por eso una API se diseña con más cuidado que una interfaz interna y se cambia con reglas. Lo que cuesta poco al principio (un nombre, una forma de respuesta, un código de estado) puede ser carísimo un año después.

Todo lo observable termina en el contrato

En la semana 1 vimos la ley de Hyrum: con suficientes usuarios, todo comportamiento observable de un sistema termina siendo algo de lo que alguien depende, lo prometa o no el contrato. En una API eso incluye cosas que nadie pensó prometer:

  • El orden de una lista: GET /api/courts devuelve las canchas por nombre. Un cliente HTTP puede tomar la primera como “la cancha principal”.
  • El texto de un mensaje de error: el README de Kallpa dice que se compare error.code y no el mensaje, pero alguien puede buscar la palabra “reservada” dentro de message.
  • Un campo que sobra, o que falta cuando no aplica: teamName aparece en una hora ocupada y no aparece en una libre. Hay código que usa su presencia para saber si la hora está ocupada, sin leer available.

Una especificación no elimina el problema, pero le pone un borde: dice qué se promete. Lo que quede fuera todavía puede romper a alguien, pero ya no es una promesa rota.

Idea clave. Una interfaz interna se cambia y se corrige a quien la usa. Una API pública se cambia sin poder corregir a quien la usa. Las dos cosas se llaman “interfaz”, pero la segunda se parece más a una ley publicada que a una función.

Diseño orientado a recursos

Sustantivos primero

Hay muchas maneras de organizar una API. La que siguen la mayoría de las API HTTP públicas, y la que recomiendan las dos guías de esta semana, es el diseño orientado a recursos. AIP-121 lo plantea en este orden: primero se deciden los recursos (los sustantivos) que la API ofrece, después cómo se relacionan y al final qué operaciones tiene cada uno, usando en lo posible los verbos estándar.

Un recurso es una cosa con identidad a la que se le puede dar un nombre: una cancha, una reserva, un vecino. Una colección es un conjunto de recursos del mismo tipo. Las rutas nombran recursos y colecciones, y el método HTTP dice qué se hace con ellos:

GET  /api/courts                  la colección de canchas
GET  /api/courts/1/availability   algo que le pertenece a la cancha 1
GET  /api/members/2/bookings      las reservas del vecino 2
POST /api/bookings                agrega una reserva a la colección

La idea de fondo es la misma de los módulos profundos de la semana pasada: pocas operaciones que se combinan. Quien ya sabe usar una colección sabe usar todas. Si cada operación tuviera su propio verbo (getCourts, listCourtsForMember, findAvailableCourtHours…), quien llama tendría que aprender cada una.

Error común. Verbos en las rutas para operaciones estándar: /api/getCourts, /api/createBooking, /api/deleteBooking?id=12. El método HTTP ya es el verbo. Además, la ruta con verbo esconde información a todo lo que está en el medio (proxies, caches, monitoreo), que sí entiende GET y DELETE, pero no sabe qué quiere decir deleteBooking.

Los métodos estándar

Las AIP llaman métodos estándar a cinco operaciones que se repiten en casi todos los recursos, y fijan su traducción a HTTP:

Método estándar HTTP Sobre qué AIP
Get GET un recurso 131
List GET una colección 132
Create POST una colección 133
Update PATCH un recurso 134
Delete DELETE un recurso 135

AIP-134 explica por qué actualizar es PATCH y no PUT, aunque PUT sea idempotente: PATCH cambia solo los campos que se mandan, y PUT reemplaza el recurso entero, así que un cliente HTTP viejo que no conoce un campo nuevo lo borraría al mandar el recurso sin él.

Kallpa encaja con este vocabulario en cuatro de sus siete operaciones (lo leen en su README):

Kallpa Método estándar
GET /api/courts List de canchas
GET /api/members List de vecinos
GET /api/members/:id/bookings List de las reservas de un vecino
POST /api/bookings Create de una reserva
GET /api/reports/daily?date=... ni colección ni recurso guardado: un cálculo
GET /api/courts/:id/availability?date=... un cálculo sobre una cancha
POST /api/bookings/:id/cancel no encaja: es un método propio

Los dos cálculos son lecturas: no cambian nada, así que GET les queda bien aunque no devuelvan un recurso guardado.

Métodos propios

¿Y cancelar una reserva? En Kallpa no se borra: queda con estado cancelled y se guarda cuándo se canceló, y hay reglas (hasta 3 horas antes). Un DELETE sugeriría que la reserva deja de existir. Un PATCH con { "status": "cancelled" } funcionaría, pero daría a entender que status se puede poner en cualquier valor.

AIP-136 llama método propio (custom method) a una operación que no se expresa bien con los estándar, y pide usarlo solo en ese caso: “Custom methods should only be used for functionality that can not be easily expressed via standard methods”. Según esa AIP, la ruta lleva dos puntos y el verbo, y el método HTTP es POST si hay efectos y GET si solo se lee:

POST /api/bookings/12:cancel      el estilo de Google
POST /api/bookings/12/cancel      el estilo de Kallpa, también muy común

Los dos dicen lo mismo: una acción sobre un recurso concreto. Azure recomienda también la forma con dos puntos (<resource-id>:<action>), siempre con POST y respondiendo 200. Lo que importa es escoger una forma y usarla en toda la API.

Lo que un GET no puede hacer

Un GET no cambia datos. Esta no es una regla de estilo, sino una que protege al sistema de sus intermediarios. Un navegador puede pedir por adelantado los enlaces de una página, un proxy puede repetir un GET sin preguntar cuando se corta la conexión, un cache puede responder sin que la solicitud llegue al servidor y un robot de búsqueda sigue todos los enlaces que encuentra. Si GET /api/bookings/12/cancel cancelara, cualquiera de ellos podría cancelar una reserva sin que nadie lo haya pedido.

Seguro e idempotente

Estas dos palabras tienen definiciones precisas, en RFC 9110, el documento que define HTTP. Vale la pena leerlas tal cual, porque se suelen repetir mal.

Las definiciones

Seguro (safe), sección 9.2.1: un método es seguro si su semántica definida es esencialmente de solo lectura, es decir, quien llama “does not request, and does not expect, any state change on the origin server”. No pide ni espera un cambio de estado. Son seguros GET, HEAD, OPTIONS y TRACE.

Idempotente (idempotent), sección 9.2.2: “A request method is considered ‘idempotent’ if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request.” El efecto buscado en el servidor de N solicitudes iguales es el mismo que el de una. Son idempotentes PUT, DELETE y los métodos seguros.

Método ¿Seguro? ¿Definido como idempotente?
GET, HEAD, OPTIONS, TRACE sí sí
PUT no sí
DELETE no sí
POST no no
PATCH no no

La tabla se lee en una dirección: todo método seguro es idempotente (si no cambia nada, N veces tampoco), pero no al revés. DELETE cambia el estado y es idempotente: borrar algo dos veces deja lo mismo que borrarlo una.

Tres precisiones que se olvidan

Es sobre el efecto, no sobre la respuesta. RFC 9110 lo dice en esa misma sección: repetir tiene el mismo efecto “though the response might differ”. El primer DELETE /x responde 204 y el segundo puede responder 404, y DELETE sigue siendo idempotente, porque el servidor quedó igual después de los dos.

Es sobre lo que se pidió, no sobre todo lo que pasa. “The idempotent property only applies to what has been requested by the user”. Un GET que escribe una línea de log sigue siendo seguro: quien llama no pidió el log. Por eso cada solicitud de Kallpa agrega una línea al log y GET /api/courts sigue siendo seguro.

POST y PATCH no están definidos como idempotentes, lo que no es lo mismo que “nunca lo son”. La definición habla de lo que se puede suponer de un método sin saber nada más. Un programa genérico (un navegador, un proxy, una biblioteca HTTP) no conoce su API y solo ve el método, por eso no reintenta un POST por su cuenta. Una operación concreta sobre POST sí puede tener un efecto idempotente, si está diseñada así y lo documenta. El RFC lo contempla: un cliente no debería reintentar automáticamente un método no idempotente, “unless it has some means to know that the request semantics are actually idempotent, regardless of the method”.

Dos operaciones de Kallpa, con la definición en la mano

Corramos lo mismo que el paso 2 del laboratorio: una reserva y una repetición exacta, como si fuera un reintento después de un timeout, y después una cancelación doble (make recorrido; las fechas dependen del día, y las líneas largas están cortadas por el guion):

--- reservar y reintentar
$ curl -X POST localhost:3100/api/bookings -H content-type: application/json -d {"courtId":2,"memberId":2,"date":"2026-11-10","startHour":10,"teamName":"Los Halcones"}
HTTP/1.1 200 OK
{"booking":{"id":16,"courtId":2,"memberId":2,"date":"2026-11-10","startHour":10,"hours":1,"teamName":"Los Halcones","priceCents":8100,"status":"confirmed","createdAt":"2026-11-02T05:40:39.195Z","cance

$ curl -X POST localhost:3100/api/bookings -H content-type: application/json -d {"courtId":2,"memberId":2,"date":"2026-11-10","startHour":10,"teamName":"Los Halcones"}
HTTP/1.1 409 Conflict
{"error":{"code":"SLOT_TAKEN","message":"La cancha ya está reservada en ese horario."}}

--- cancelar dos veces
$ curl -X POST localhost:3100/api/bookings/16/cancel
HTTP/1.1 200 OK
{"booking":{"id":16,"courtId":2,"memberId":2,"date":"2026-11-10","startHour":10,"hours":1,"teamName":"Los Halcones","priceCents":8100,"status":"cancelled","createdAt":"2026-11-02T05:40:39.195Z","cance

$ curl -X POST localhost:3100/api/bookings/16/cancel
HTTP/1.1 409 Conflict
{"error":{"code":"ALREADY_CANCELLED","message":"La reserva ya estaba cancelada."}}

¿Es idempotente cancelar? Hay que separar dos preguntas. Como método, POST no está definido como idempotente. Como operación, miren el efecto: después de la primera cancelación, la reserva queda cancelled con su cancelledAt; después de la segunda, queda exactamente igual (api.test.ts compara la reserva antes y después). La respuesta cambia de 200 a 409, lo que la definición permite. El efecto de esta operación sí es idempotente. Lo que falta es decirlo: nada en la API le permite a un programa saberlo, y mientras tanto no tiene ese “means to know” del RFC.

¿Es idempotente reservar? Parece que sí, porque el segundo intento no creó nada. Pero esa no es la prueba: la definición pide el mismo efecto para N solicitudes iguales en cualquier caso. Si entre la primera solicitud y el reintento alguien cancela esa reserva, el reintento encuentra la hora libre y crea una segunda.

Lo comprueba api.test.ts, en la prueba reservar no es idempotente: reserva, cancela y repite exactamente la misma solicitud; el reintento responde 200 con otro id, y el vecino queda con dos filas para esa hora, una cancelada y otra confirmada.

El 409 que frena casi todos los reintentos viene de la regla del negocio (la cancha no puede estar ocupada dos veces), no de que la operación sea idempotente.

Idea clave. Seguro: quien llama no pide cambios. Idempotente: pedir lo mismo N veces deja al servidor como pedirlo una. La definición mira el efecto buscado, no la respuesta y no lo que el servidor haga por su cuenta (como escribir un log).

Error común. Decir “DELETE es idempotente, así que siempre debe responder 204”, o “un 404 en el segundo DELETE rompe la idempotencia”. La respuesta puede cambiar: lo que no puede cambiar es el efecto.

Por qué importa: la respuesta perdida

Lo único que sabe quien llama

Un vecino aprieta “Reservar” con mala señal. El celular manda el POST, la barra de carga gira y la conexión se corta. ¿Se hizo la reserva? Hay tres posibilidades y, desde el celular, las tres se ven igual:

  1. La solicitud nunca llegó. No hay reserva.
  2. La solicitud llegó y el servidor todavía la está procesando. Puede que haya reserva dentro de un momento.
  3. El servidor la procesó y la respuesta se perdió de regreso. Hay reserva.

Por eso importa la idempotencia. Si la operación es idempotente, la app puede reintentar sin pensar: en los tres casos el resultado final es el correcto. Si no, reintentar es una apuesta. RFC 9110 lo dice directamente: los métodos idempotentes se distinguen “because the request can be repeated automatically if a communication failure occurs before the client is able to read the server’s response”.

El caso 3, de verdad

El caso 3 es fácil de reproducir sin esperar a que la red falle. lost-response.ts levanta un proxy que reenvía la solicitud al servidor real, espera su respuesta y, la primera vez, cierra la conexión sin entregarla:

const text = await upstream.text();
if (remaining > 0) {
  remaining--;
  req.socket.destroy(); // el servidor ya hizo su trabajo; la respuesta se pierde
  return;
}

make retry pasa por ese proxy a Kallpa y a un servidor de pagos mínimo (charges-server.ts), que no tiene ninguna regla que frene un duplicado. Quien llama hace lo que haría cualquier app: si se corta, repite lo mismo.

Kallpa, POST /api/bookings
  intento 1: se cortó la conexión (fetch failed)
  intento 2: 409 {"error":{"code":"SLOT_TAKEN","message":"La cancha ya está reservada en ese horario."}}
  reservas del vecino 2 en la base de datos: 1

Pagos sin clave, POST /charges
  intento 1: se cortó la conexión (fetch failed)
  intento 2: 201 {"charge":{"id":2,"memberId":2,"cents":8100}}
  cobros en la base de datos: 2

Pagos con clave, POST /charges
  intento 1: se cortó la conexión (fetch failed)
  intento 2: 201 {"charge":{"id":1,"memberId":2,"cents":8100}}
  cobros en la base de datos: 1

Hay dos fallas distintas:

  • Kallpa falla sin duplicar, pero informa mal. La reserva se hizo y es del vecino 2, pero lo último que él ve es “la cancha ya está reservada en ese horario”. El mensaje es correcto y engañoso: dice que la cancha está ocupada, no que la ocupa él. La app le mostrará un error, y el vecino buscará otra hora o llamará a la administración, mientras tiene una reserva que no sabe que tiene.
  • Pagos duplica. Sin una regla que lo frene, el reintento cobra dos veces. El vecino vio un solo botón y un solo error.

La última parte de la salida muestra la solución.

Animación en tres pasos, con un diagrama de secuencia entre un celular y un servidor en cada panel. En los tres, el primer intento llega al servidor, que hace su trabajo, y la respuesta se corta a mitad de camino: se cortó la conexión. Paso 1, Kallpa: POST /api/bookings guarda la reserva; el segundo intento, igual al primero, encuentra la hora ocupada y responde 409 SLOT_TAKEN; hay una reserva del vecino 2, que no duplica, pero el vecino cree que la cancha es de otro. Paso 2, pagos sin clave: POST /charges hace el cobro 1; el segundo intento hace el cobro 2 y responde 201 con el charge de id 2; hay dos cobros. Paso 3, pagos con clave: POST /charges hace el cobro 1 y guarda la clave; el segundo intento, con la misma clave, la encuentra y responde 201 con el charge de id 1; hay un cobroAnimación en tres pasos, con un diagrama de secuencia entre un celular y un servidor en cada panel. En los tres, el primer intento llega al servidor, que hace su trabajo, y la respuesta se corta a mitad de camino: se cortó la conexión. Paso 1, Kallpa: POST /api/bookings guarda la reserva; el segundo intento, igual al primero, encuentra la hora ocupada y responde 409 SLOT_TAKEN; hay una reserva del vecino 2, que no duplica, pero el vecino cree que la cancha es de otro. Paso 2, pagos sin clave: POST /charges hace el cobro 1; el segundo intento hace el cobro 2 y responde 201 con el charge de id 2; hay dos cobros. Paso 3, pagos con clave: POST /charges hace el cobro 1 y guarda la clave; el segundo intento, con la misma clave, la encuentra y responde 201 con el charge de id 1; hay un cobro
La misma respuesta perdida, tres resultados. Lo que frena el duplicado en Kallpa es una regla del negocio; lo que lo frena en pagos es la clave.

La clave de idempotencia

El protocolo

Como un POST no es idempotente por sí mismo, se le agrega algo que permita reconocer un reintento. Quien llama genera un valor único por intención (por cada vez que el vecino aprieta el botón, no por cada envío) y lo manda en una cabecera. El servidor guarda la clave con la respuesta y, si la vuelve a ver, no repite la operación: devuelve lo que guardó.

POST /api/bookings
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"

(Las comillas son parte del valor en el borrador: la cabecera es un Structured Field cuyo valor debe ser una cadena.)

La cabecera está descrita en un borrador de la IETF, The Idempotency-Key HTTP Header Field. La versión que revisamos es la -08, de octubre de 2026: un borrador activo, todavía no un RFC, y que puede cambiar. Casi todo lo que pide lo pide con SHOULD, es decir, como recomendación fuerte. Lo que recomienda:

Situación Respuesta del servidor
La clave es nueva Procesa normalmente
Reintento, la primera ya terminó Una respuesta “consistent with the request being completed”; lo usual es repetir la guardada
Reintento, la primera todavía se procesa 409 Conflict
La misma clave con otro cuerpo 422 Unprocessable Content
Falta la clave en una operación que la exige 400 Bad Request

Pide también dos cosas con MUST: que el servidor publique cómo funciona su idempotencia, incluido cuánto dura una clave, y que una clave no se reutilice con un cuerpo distinto. Recomienda usar un UUID: su sección de seguridad advierte que una clave fácil de adivinar podría servirle a un atacante para leer la respuesta guardada de otra persona.

Como decorator de una operación

idempotency.ts lo implementa sin tocar la operación, envolviéndola con otra función (en la segunda parte del capítulo le daremos nombre a esta forma: decorator). Una clave está en proceso o terminada, con su respuesta guardada, y la lógica sigue la tabla de arriba abajo:

return async (key: string | undefined, request: Request): Promise<Replayable> => {
  if (key === undefined) {
    return errorResponse(400, 'IDEMPOTENCY_KEY_MISSING', 'Esta operación pide la cabecera Idempotency-Key.');
  }
  const print = fingerprint(request);
  const now = options.now();
  const existing = store.get(key);
  if (existing && now - existing.storedAt < options.ttlMs) {
    if (existing.fingerprint !== print) {
      return errorResponse(422, 'IDEMPOTENCY_KEY_REUSED', 'Esa clave ya se usó con otro cuerpo.');
    }
    if (existing.state === 'in-progress') {
      return errorResponse(409, 'REQUEST_IN_PROGRESS', 'La primera solicitud con esa clave todavía se procesa.');
    }
    return { ...existing.response, replayed: true };
  }
  // Se marca antes del primer await: un reintento concurrente ya ve la clave.
  store.set(key, { state: 'in-progress', fingerprint: print, storedAt: now });
  try {
    const response = await operation(request);
    store.set(key, { state: 'done', fingerprint: print, storedAt: now, response });
    return { ...response, replayed: false };
  } catch (error) {
    // No se sabe qué pasó: se libera la clave para que un reintento pueda
    // volver a correr la operación.
    store.delete(key);
    throw error;
  }
};

La huella (fingerprint) es un hash del cuerpo. Sirve para detectar que se reutilizó una clave con otro contenido, y se calcula sobre una forma canónica del JSON, con los campos ordenados, porque {"a":1,"b":2} y {"b":2,"a":1} son el mismo pedido aunque tengan otros bytes. El reloj se recibe como dependencia, igual que en Kallpa, y por eso se puede probar el vencimiento sin esperar 24 horas. idempotency.test.ts comprueba cada fila de la tabla:

✔ sin clave no se ejecuta nada: 400
✔ un reintento con la misma clave y el mismo cuerpo devuelve la misma respuesta sin repetir el efecto
✔ el orden de los campos no cambia la huella
✔ la misma clave con otro cuerpo: 422 y sin efecto
✔ un reintento mientras la primera todavía corre: 409
✔ cuando vence la clave, el mismo pedido vuelve a ejecutarse
✔ las respuestas de error de la operación también se guardan y se repiten
✔ el límite de este diseño: si el proceso falla después del efecto, el reintento lo repite

(Salida de node --test, sin los tiempos de cada prueba.)

Las decisiones que el código no toma por ustedes

El módulo anterior es para entender la lógica. Una clave de idempotencia real obliga a decidir varias cosas, y es lo que harán en el paso 7 del laboratorio para POST /api/bookings:

  • ¿Dónde se guarda y cuándo? Esta es la más importante. La última prueba de la lista muestra el problema: si el proceso se cae después de hacer el efecto y antes de guardar la respuesta, el reintento repite el efecto, con clave y todo.

    const operation = async (request: Charge) => {
      charges.push(request); // el efecto ya ocurrió...
      if (fail) throw new Error('se cayó antes de guardar la respuesta'); // ...y la respuesta se pierde
      return { status: 201, body: { id: charges.length } };
    };

    La garantía real solo existe si la clave y el efecto se guardan en la misma transacción de la base de datos: o quedan los dos, o ninguno. Un Map en memoria, como el del ejemplo, además se pierde al reiniciar el servidor.

  • ¿Por cuánto tiempo? Lo bastante para cubrir los reintentos reales y no tanto como para llenar la base de datos. Lo que escojan, se publica.

  • ¿Qué pasa con los efectos que no están en la base de datos? Un aviso por WhatsApp no entra en una transacción. Si se manda antes del commit y el commit falla, se avisó de una reserva que no existe; si se manda después, un fallo puede dejar una reserva sin aviso.

  • ¿Qué se guarda? Guardar también los rechazos da una respuesta estable, pero un 409 guardado seguiría diciendo “ocupada” aunque la cancha se libere. No guardarlos deja volver a intentar. Las dos opciones son defendibles; lo que no se defiende es no haberlo decidido.

  • ¿Quién es owner de la clave? Si la clave es global, cualquiera que la adivine recibe la respuesta de otro. Con autenticación, la clave se guarda por usuario. Kallpa no tiene autenticación (semana 15).

  • ¿Es obligatoria? En una API que ya existe, volverla obligatoria rompe a todos. Se empieza con una cabecera opcional, que es un cambio compatible.

En la industria. El borrador de la IETF incluye una sección de Implementation Status con empresas y productos que usan esa cabecera, entre ellos Stripe y Adyen. Azure llega al mismo lugar por otro camino: sus guías exigen que todas las operaciones sean idempotentes (“all service operations (including POST) must be idempotent”), recomiendan crear recursos con PUT o PATCH y, para los POST, usar otras dos cabeceras, Repeatability-Request-ID y Repeatability-First-Sent, definidas por OASIS, con una ventana de al menos 5 minutos. Otros nombres, la misma idea: quien llama identifica su intención y el servidor reconoce la repetición. Fuentes: el borrador -08, sec. 4, y Azure REST API Guidelines, Exactly Once Behavior y Repeatability of requests.

Con IA. Al pedirle a un agente “agrega reintentos a este cliente HTTP”, lo típico es un bucle con backoff alrededor de la llamada, y funciona en todas las pruebas: un reintento solo se nota cuando se pierde una respuesta, que en una prueba normal no pasa. Revisen dos cosas: si la operación que se reintenta es idempotente y si el cliente genera la clave una vez por intención, fuera del bucle. Una clave generada dentro del bucle, una por intento, no protege de nada. Se comprueba con una prueba como lost-response.test.ts: una red que corta y un contador de efectos.

Error común. Generar la clave en cada envío. Si el reintento lleva otra clave, el servidor lo trata como un pedido nuevo.

Códigos de estado con significado

Los que van a usar

El código de estado es lo primero que lee un programa, y tiene significado para todo lo que está en el medio: un cache, un balanceador de carga, una biblioteca que decide si reintentar. Estos cubren casi todo:

Código Nombre (RFC 9110) Cuándo
200 OK Salió bien y hay algo que devolver
201 Created Se creó un recurso; la cabecera Location dice dónde quedó
204 No Content Salió bien y no hay cuerpo
400 Bad Request La solicitud está mal formada
401 Unauthorized No se sabe quién llama; la respuesta debe traer WWW-Authenticate
403 Forbidden Se sabe quién llama y no puede hacer esto
404 Not Found Ese recurso o esa ruta no existe
405 Method Not Allowed La ruta existe con otro método; la respuesta debe traer Allow
409 Conflict Choca con el estado actual del recurso
413 Content Too Large El cuerpo es demasiado grande
422 Unprocessable Content Bien formada, pero no se puede procesar
429 Too Many Requests Demasiadas solicitudes (este lo define RFC 6585)
500 Internal Server Error Falló el servidor, no quien llama
503 Service Unavailable No puede atender ahora, pero es temporal

Tres frases para no confundirlos. 401 frente a 403: el primero dice “no sé quién eres” y el segundo “sé quién eres y no”. 4xx frente a 5xx: el primero le dice a quien llama “cambia tu solicitud”; el segundo, “no es tu culpa”. 409 frente a 422: 409 choca con el estado actual de un recurso (la misma reserva podría funcionar si se cancela la otra); 422 no cumple una regla, sea cual sea el estado de los demás recursos. La frontera no es perfecta, porque hay reglas que dependen del tiempo: una fecha demasiado lejana hoy (TOO_FAR_AHEAD) será válida en unos días.

La frase de la primera línea de la respuesta la pone Node, y no siempre coincide con el nombre actual:

$ curl -X POST localhost:3100/api/bookings -H content-type: application/json -d {"courtId":2,"memberId":2,"date":"2026-11-10","startHour":10,"hours":4,"teamName":"X"}
HTTP/1.1 422 Unprocessable Entity
{"error":{"code":"TOO_MANY_HOURS","message":"Se pueden reservar como máximo 3 horas seguidas."}}

$ curl -X POST localhost:3100/api/bookings -H content-type: application/json --data @/tmp/kallpa-grande.json
HTTP/1.1 413 Payload Too Large
{"error":{"code":"BODY_TOO_LARGE","message":"El cuerpo de la solicitud es demasiado grande."}}

Unprocessable Entity y Payload Too Large son los nombres antiguos; RFC 9110 los cambió a Unprocessable Content y Content Too Large. No es un error de Kallpa: los programas comparan el número, y RFC 9112, sobre HTTP/1.1, dice que un cliente debería ignorar esa frase (“A client SHOULD ignore the reason-phrase content”).

La frontera entre 400 y 422

¿Un hours: 4 es un 400 o un 422? RFC 9110 define el 422 para una solicitud cuya sintaxis es correcta y que aun así no se puede procesar. Pero 400 también abarca lo que el servidor percibe como un error de quien llama. La norma deja lugar a más de una respuesta razonable, y las guías importantes no coinciden:

  • Azure usa 400 para todo: “fail the operation with 400-Bad Request if any value fails validation”.
  • Google tampoco usa 422: en la tabla de google.rpc.Code, a la que remite AIP-193, ningún código se traduce a ese estado. Un argumento inválido (INVALID_ARGUMENT) o una condición que no se cumple (FAILED_PRECONDITION) van a 400.
  • El borrador de la clave de idempotencia sí usa 422, y muchas API también.
  • Kallpa escoge en su README: 400 si “falta un campo o tiene el tipo equivocado”, y 422 para las reglas del negocio.

Cualquiera de estas decisiones puede defenderse. Lo que no: decidirlo campo por campo, sin una regla, porque quien llama tendría que aprender cada caso. Por eso conviene una frase como esta en la documentación de la API, y revisar que todos los endpoints la respeten:

400: el cuerpo no es JSON, falta un campo o tiene el tipo equivocado. 422: tiene la forma correcta, pero no cumple una regla.

Si Kallpa la cumple en todos sus campos es parte de lo que revisarán en el laboratorio.

Una cadena de preguntas de arriba abajo; cada no sale a la derecha hacia un código, cada sí baja a la siguiente. Existen esa ruta y ese recurso: si no, 404 Not Found. La ruta acepta ese método: si no, 405 Method Not Allowed. Se sabe quién llama: si no, 401 Unauthorized. Puede hacer esto: si no, 403 Forbidden. El cuerpo es JSON, con los campos y tipos correctos: si no, 400 Bad Request. Cumple las reglas: si no, 422 Unprocessable Content. Es compatible con el estado actual del recurso: si no, 409 Conflict. Todos esos están rotulados como cambia tu solicitud, 4xx. El servidor pudo atenderla: si no, 500 si falló o 503 si es temporal, rotulados como no es tu culpa, 5xx. Si sí, salió bien, 2xx: 200 OK si hay algo que devolver, 201 Created si se creó, con Location, y 204 No Content si no hay cuerpoUna cadena de preguntas de arriba abajo; cada no sale a la derecha hacia un código, cada sí baja a la siguiente. Existen esa ruta y ese recurso: si no, 404 Not Found. La ruta acepta ese método: si no, 405 Method Not Allowed. Se sabe quién llama: si no, 401 Unauthorized. Puede hacer esto: si no, 403 Forbidden. El cuerpo es JSON, con los campos y tipos correctos: si no, 400 Bad Request. Cumple las reglas: si no, 422 Unprocessable Content. Es compatible con el estado actual del recurso: si no, 409 Conflict. Todos esos están rotulados como cambia tu solicitud, 4xx. El servidor pudo atenderla: si no, 500 si falló o 503 si es temporal, rotulados como no es tu culpa, 5xx. Si sí, salió bien, 2xx: 200 OK si hay algo que devolver, 201 Created si se creó, con Location, y 204 No Content si no hay cuerpo
Una forma de escoger entre los códigos de esta sección, no una norma: RFC 9110 no fija este orden, y Azure y Google responden 400 donde aquí sale 422. Lo que importa es que la API aplique la misma regla en todas partes.

Errores que un programa pueda leer

Lo que necesita un error

Un error de API tiene dos lectores: un programa, que decide qué hacer, y una persona, que trata de entender qué pasó. Necesita:

  • un código estable para el programa, que no cambie aunque se reescriba el mensaje;
  • un mensaje para la persona, que se pueda mejorar en cualquier momento;
  • si sirve, detalles (por ejemplo, qué campo falló y por qué).

Kallpa usa una forma, en todas partes:

{ "error": { "code": "SLOT_TAKEN", "message": "La cancha ya está reservada en ese horario." } }

api.test.ts recorre errores de seis orígenes distintos (ruta que no existe, método equivocado, JSON roto, reserva que no existe, vecino que no existe, fecha que falta) y comprueba que todos tienen exactamente esas dos claves.

Las formas que van a encontrar

No hay una sola forma estándar. Estas son las tres que más se ven:

Quién Forma El identificador para programas
RFC 9457, Problem Details application/problem+json con type, title, status, detail, instance type: una URI
Azure { "error": { "code", "message", "target", "details", "innererror" } } error.code, un texto, repetido en la cabecera x-ms-error-code
Google (AIP-193) { "error": { "code", "message", "status", "details" } } details lleva un ErrorInfo con reason y domain

Un detalle que confunde: en Google, error.code es el número HTTP (429), y el identificador simbólico viene en status (RESOURCE_EXHAUSTED) y en ErrorInfo.reason. En Azure y en Kallpa, error.code es un texto. Se escriben igual y no significan lo mismo. En los tres casos el mensaje se escribe para personas; AIP-193 además advierte que, en una API antigua que no tiene identificadores para programas, cambiar el mensaje es peligroso, porque es lo único que quien llama puede comparar. Es la ley de Hyrum otra vez.

Azure es explícito sobre el contrato: “x-ms-error-code values are part of your API contract (because customer code is likely to do comparisons against them) and cannot change in the future”.

Importa menos cuál elijan que usar la misma en toda la API. Una API con dos formas de error obliga a escribir dos ramas en cada cliente HTTP.

Error común. Devolver 200 con { "success": false, ... }. Todo lo que está en el medio (caches, monitoreo, una biblioteca de reintentos) ve un éxito. El panel de errores del equipo estará en cero mientras los usuarios no pueden reservar.

Error común. Devolver en el cuerpo de un 500 el mensaje de la excepción o el stack trace. Es información interna que le sirve a un atacante, y además alguien terminará dependiendo de ella. Kallpa la manda al log y responde un mensaje fijo: Ocurrió un error inesperado.

Evolución y versiones

Compatible e incompatible

Un cambio es compatible (backward compatible) si el código que ya funciona contra la API sigue funcionando sin cambios. Es incompatible (breaking) si puede dejar de funcionar. La lista que sigue combina AIP-180 y la práctica común:

Cambio ¿Compatible? Por qué
Agregar un endpoint Sí Nadie lo usa todavía
Agregar un campo opcional en la solicitud Sí, si omitirlo hace lo mismo que antes AIP-180: el valor por omisión debe reproducir el comportamiento anterior
Agregar un campo en la respuesta Sí, si quien llama ignora lo que no conoce Ver abajo
Quitar o renombrar un campo No Quien lo leía recibe undefined
Cambiar el tipo de un campo No También si “se ve igual” en JSON
Cambiar el significado de un campo No Y ninguna herramienta lo detecta
Volver obligatorio algo opcional No Las solicitudes de ayer fallan hoy
Cambiar un código de estado No Quien lo comparaba toma otro camino
Cambiar un valor por omisión No AIP-180 lo prohíbe expresamente
Aumentar el largo máximo de un texto Con cuidado AIP-180 pide tratarlo como incompatible: alguien guardó ese campo en una columna con el tamaño anterior

evolution.ts tiene tres clientes HTTP de la respuesta de disponibilidad, y evolution.test.ts les aplica estos cambios a una respuesta real de Kallpa:

▶ qué rompe cada cambio
  ✔ agregar un campo en la respuesta: el cliente tolerante sigue igual
  ✔ agregar un campo en la respuesta: el cliente que exige campos exactos se cae
  ✔ renombrar hour a startHour: el cliente tolerante no se cae, pero devuelve basura
  ✔ envolver el arreglo suelto en un objeto: el cliente se cae
  ✔ pasar un error de 400 a 422: el cliente que comparaba el código ya no lo reconoce

(Salida de node --test, sin los tiempos.)

Hay dos lecciones en esa salida. La segunda línea muestra que “agregar un campo es compatible” es una regla con una condición: vale si quien llama ignora los campos que no conoce. Un cliente que valida campos exactos se rompe, y por la ley de Hyrum alguien lo escribirá así. Conviene poner esa condición por escrito: “pueden aparecer campos nuevos; ignoren los que no conozcan”. La tercera línea es la peor falla: un error que no lanza nada. La app sigue corriendo y muestra una lista de undefined, y nadie se entera hasta que llama un usuario.

Error común. Renombrar un campo “porque se ve mejor” en una API que ya tiene clientes HTTP. El nombre viejo era feo, pero funcionaba.

Un arreglo suelto no deja crecer

GET /api/courts devuelve { "courts": [...] }, pero GET /api/members/:id/bookings devuelve [...]. Un objeto puede crecer: mañana se le agrega otro campo y nadie se entera. A un arreglo suelto no se le puede agregar nada; para hacerlo hay que cambiarle la forma, y eso rompe a todos. Por eso se recomienda envolver las listas desde el primer día.

Sin embargo, envolver no resuelve todo. AIP-158 pide paginar desde el inicio, y da el motivo: “Adding pagination to an existing RPC is a backwards-incompatible change”. Aunque el campo nextPageToken sea un agregado inofensivo para la forma de la respuesta, cambia el comportamiento: un cliente HTTP que hoy lee una sola respuesta recibe todas las reservas; con paginación, recibirá la primera página y no se dará cuenta de que faltan las demás. El objeto hace posible agregar la paginación sin cambiar la forma de la respuesta. No la vuelve gratis.

La paginación con token funciona así: la respuesta incluye nextPageToken si quedan más resultados, y quien llama lo devuelve para pedir la página siguiente. Se prefiere un token opaco a un número de página para no prometer cómo está hecha la consulta por dentro.

Convivir: versiones

Cuando un cambio incompatible vale la pena, no se aplica sobre lo que ya existe. Se publica al lado. Hay varias formas de nombrar la versión, y las guías tampoco coinciden aquí:

Forma Ejemplo Quién
En la ruta /api/v2/members/2/bookings Google pone la versión mayor en la ruta (/v1/...)
En un parámetro ?api-version=2021-06-04 Azure lo exige en toda operación, con fechas, y prohíbe el número en la ruta: “DO NOT include a version number segment in any operation path”
En una cabecera X-Api-Version: 2 Es común en algunas API; es menos visible para quien lee una URL

Lo que comparten es la promesa: una versión ya publicada no cambia de forma incompatible. Azure lo dice sin matices: “DO NOT introduce any breaking changes into the service”. Sus excepciones pasan por una junta de revisión.

Deprecar sin sorprender

Deprecar es anunciar que algo va a dejar de existir, mientras sigue funcionando. Son tres pasos, en este orden:

  1. Publicar lo nuevo y dejar lo viejo igual. Las dos versiones conviven.
  2. Anunciar, con una fecha, y medir quién sigue usando lo viejo.
  3. Apagar recién cuando el uso cae, o cuando se cumple el plazo y se avisó a quienes quedan.

Dos RFC dan cabeceras para el segundo paso:

  • RFC 9745, Deprecation: desde cuándo está deprecado el recurso, como @ y un tiempo Unix (Deprecation: @1798761600). La fecha puede ser futura. El RFC es claro: “The act of deprecation does not change any behavior of the resource”.
  • RFC 8594, Sunset: cuándo dejará de responder, como fecha HTTP (Sunset: Sat, 01 Jan 2028 00:00:00 GMT). RFC 9745 agrega que esa fecha no puede ser anterior a la de Deprecation.

No son las únicas opciones: Azure, por ejemplo, tiene su propia cabecera azure-deprecating, pensada para que la lea una persona y no un programa.

v2-bookings.diff aplica esto al arreglo suelto de Kallpa, en una copia. La ruta vieja sigue respondiendo lo mismo, pero agrega las cabeceras; la nueva devuelve un objeto. Las dos comparten la consulta:

// GET /api/members/:id/bookings: arreglo suelto, deprecado.
export function memberBookings(deps: AppDependencies): Handler {
  return ({ res, params }) => {
    res.setHeader('Deprecation', `@${MEMBER_BOOKINGS_V1_DEPRECATED_AT}`);
    res.setHeader('Sunset', MEMBER_BOOKINGS_V1_SUNSET);
    res.setHeader('Link', `</api/v2/members/${params.id}/bookings>; rel="successor-version"`);
    sendJson(res, 200, bookingsOfMember(deps, params.id));
  };
}

// GET /api/v2/members/:id/bookings: un objeto, que puede crecer.
export function memberBookingsV2(deps: AppDependencies): Handler {
  return ({ res, params }) => {
    sendJson(res, 200, { bookings: bookingsOfMember(deps, params.id) });
  };
}

make recorrido-v2 las llama (las líneas largas están cortadas por el guion):

$ curl -s -i localhost:3101/api/members/5/bookings
HTTP/1.1 200 OK
Deprecation: @1798761600
Sunset: Sat, 01 Jan 2028 00:00:00 GMT
Link: </api/v2/members/5/bookings>; rel="successor-version"
[{"id":9,"courtId":3,"memberId":5,"date":"2026-11-03","startHour":9,"hours":2,"teamName":"

$ curl -s -i localhost:3101/api/v2/members/5/bookings
HTTP/1.1 200 OK
{"bookings":[{"id":9,"courtId":3,"memberId":5,"date":"2026-11-03","startHour":9,"hours":2,

GET /api/members/5/bookings 200
GET /api/v2/members/5/bookings 200

Las dos últimas líneas son el log del servidor. Es la medición del paso 2, y también su límite: contando líneas se sabe cuánto se usa la ruta vieja, pero no quién la usa, porque Kallpa no tiene autenticación. Sin saber quién, no se le puede avisar a nadie. Las pruebas viejas de Kallpa no cambiaron, y el diff agrega cuatro en test/v2.test.ts:

ℹ tests 44
ℹ suites 7
ℹ pass 44
ℹ fail 0

La semana 16 retoma la deprecación a mayor escala, cuando el código mismo se vuelve heredado.

Con IA. Un agente al que se le pide “ordena esta API” tiende a hacer el cambio más limpio, que suele ser incompatible: renombra, unifica, pasa de 200 a 201, y actualiza en el mismo commit los clientes HTTP y las pruebas que encuentra en el repositorio. Las pruebas pasan. Los clientes HTTP que están fuera del repositorio, no. En la revisión, una pregunta lo destapa: ¿este diff cambió alguna prueba de la API que ya existía? Si una prueba de contrato cambió, el contrato cambió.

OpenAPI: el contrato por escrito

Qué es

OpenAPI es un formato estándar para describir una API HTTP en un archivo YAML o JSON: las rutas, los parámetros, los cuerpos, todas las respuestas y sus esquemas. Como lo puede leer un programa, de ese archivo se puede obtener documentación, comprobar respuestas y generar código. La versión 3.1 usa JSON Schema para describir los datos.

Un documento tiene pocas partes. Este es el esqueleto del laboratorio, code/week-06/openapi.yaml, completo:

openapi: 3.1.0
info:
  title: Kallpa API
  version: 1.0.0
  description: Reservas de canchas.
security: []
servers:
  - url: http://localhost:3000
paths:
  /api/courts:
    get:
      operationId: listCourts
      summary: Lista las canchas
      responses:
        '200':
          description: Las canchas
          content:
            application/json:
              schema:
                type: object
                required: [courts]
                properties:
                  courts:
                    type: array
                    items:
                      $ref: '#/components/schemas/Court'
components:
  schemas:
    Court:
      type: object
      required: [id, name, sport]
      properties:
        id: { type: integer }
        name: { type: string }
        sport: { type: string, enum: [fulbito, voley, basquet] }
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
  • paths tiene las operaciones: una por cada combinación de ruta y método. Los parámetros de ruta se escriben /api/courts/{id}/availability, con llaves.
  • responses tiene una entrada por código de estado, cada una con su esquema.
  • components/schemas guarda los tipos que se repiten, y $ref los usa. Un $ref evita copiar Court en cada respuesta y es el DRY de la semana pasada: un solo lugar.
  • security: [] dice que ninguna operación pide autenticación. Kallpa no la tiene y la especificación lo dice en voz alta, en lugar de callarlo. Se retoma en la semana 15.

redocly lint: lo que revisa

Redocly CLI, que viene en el contenedor, revisa el documento contra las reglas de OpenAPI y un conjunto de buenas prácticas (recommended). Sobre el esqueleto:

$ redocly lint openapi.yaml
No configurations were provided -- using built in recommended configuration by default.

validating openapi.yaml...
[1] openapi.yaml:2:1 at #/info

Info object should contain `license` field.

1 | openapi: 3.1.0
2 | info:
  | ^^^^
3 |   title: Kallpa API
4 |   version: 1.0.0

Warning was generated by the info-license rule.

Reference: https://redocly.com/docs/cli/rules/oas/info-license

(Hay tres avisos más de la misma forma, para no-server-example.com en la línea 8, operation-4xx-response en la línea 14 y no-unused-components en la línea 36. La salida termina así.)

openapi.yaml: validated in 18ms

Woohoo! Your API description is valid. 🎉
You have 4 warnings.

Cero errores y cuatro advertencias, con código de salida 0. Una advertencia no es una orden: es una pregunta que hay que responder. La cuarta se va sola cuando usen Error con un $ref. La segunda no se puede corregir, porque Kallpa solo corre en localhost: se justifica con un comentario en el YAML. La tercera merece pensarla: GET /api/courts no responde ningún 4xx. Si para callar la regla agregan un 400 que nunca ocurre, la especificación deja de decir la verdad.

Un error es otra cosa. En lint-examples/broken-ref.yaml el $ref apunta a un esquema que no existe:

[1] lint-examples/broken-ref.yaml:20:17 at #/paths/~1api~1courts/get/responses/200/content/application~1json/schema

Can't resolve $ref

Y al final, con código de salida 1:

❌ Validation failed with 1 error and 4 warnings.

Lo que redocly lint no revisa

Lint compara el documento con las reglas de OpenAPI, no con el servidor. lint-examples/wrong-but-valid.yaml dice que POST /api/bookings responde 201 con { id }. Kallpa responde otra cosa. Aun así:

$ redocly lint lint-examples/wrong-but-valid.yaml --format=summary
No configurations were provided -- using built in recommended configuration by default.

validating lint-examples/wrong-but-valid.yaml...
warning info-license: 1
warning no-server-example.com: 1

lint-examples/wrong-but-valid.yaml: validated in 13ms

Woohoo! Your API description is valid. 🎉
You have 2 warnings.

Valid quiere decir “es un documento OpenAPI bien escrito”. No quiere decir “es verdad”. Para saber si es verdad hay que llamar al servidor y comparar. contract.ts es un validador pequeño que hace eso: toma el esquema de una respuesta (del documento empaquetado como JSON con redocly bundle) y lo compara con lo que llegó.

test('GET /api/courts responde lo que dice la especificación', async () => {
  const body = await (await fetch(`${server.url}/api/courts`)).json();
  assert.deepEqual(validate(spec, body, courts), []);
});

Si faltara name en una cancha, o sport valiera tenis, validate devolvería esas discrepancias, y otra prueba lo comprueba. validate solo entiende lo que usa la especificación de Kallpa, y no revisa formatos como date-time. En un proyecto real se usa una biblioteca completa de JSON Schema. La idea es la misma: una prueba que compara una respuesta real con el contrato evita que la especificación se vuelva ficción. La revisión de P2 hace algo parecido: llama a las operaciones GET sin parámetros de ruta de su especificación y falla si la aplicación responde 404 o 5xx.

Error común. Escribir la especificación de la API que les gustaría tener. Una especificación que promete un 201 que el servidor no da es peor que no tener ninguna: alguien va a programar contra ella. En el laboratorio se describe la API como es hoy, con sus defectos a la vista, y las mejoras se proponen aparte.

Con IA. Una especificación OpenAPI sirve de contrato contra el que se comprueba un cliente HTTP generado. client-example.ts es una ilustración escrita para la clase, un cliente como los que suele proponer un agente cuando no tiene la especificación a la vista: compila, corre y usa court.courtName y court.sportType, que suenan razonables y no existen. Como la respuesta se trata como any, tsc no dice nada, y la app muestra undefined (undefined) en las cuatro canchas. En contract.test.ts, fieldExists compara los campos que usa con la especificación y los encuentra sin correr el cliente. Denle la especificación al agente como contexto, y después comprueben: los dos pasos juntos valen más que cualquiera de los dos.

Patrones de diseño: qué siguen siendo

Una solución con nombre

Un patrón de diseño (design pattern) es una solución con nombre a un problema que se repite al diseñar software, con sus ventajas y sus costos. No es código para copiar, ni una biblioteca: es una forma de organizar el código, que en cada lenguaje se escribe distinto.

El catálogo clásico es el libro Design Patterns de 1994, de Erich Gamma, Richard Helm, Ralph Johnson y John Vlissides, con 23 patrones y ejemplos en C++ y Smalltalk. Tres décadas después sigue vigente, pero no todo por igual. Lo que se mantiene es:

  • El problema. Cambiar un algoritmo sin tocar a quien lo usa, o hacer encajar dos interfaces, son problemas que siguen apareciendo en cualquier lenguaje.
  • El vocabulario. Decir “aquí va un adapter” en una revisión de código ahorra un párrafo. Es su uso más barato y quizá el más útil.

Lo que cambia es la forma. Muchos de esos patrones existían para resolver limitaciones de los lenguajes de 1994, y hoy pueden ser una línea.

Lo que dijo Norvig en 1996

Peter Norvig estudió el catálogo en una charla de 1996 y concluyó que 16 de los 23 patrones “are either invisible or simpler” en Dylan o Lisp. En otra lámina precisa el alcance: tienen una implementación “qualitatively simpler […] for at least some uses of each pattern”. Vale la pena leer con cuidado qué midió, porque la cifra se suele repetir sin contexto:

  • Es una revisión de un experto, no un experimento: no mide nada en programas reales.
  • Es sobre Dylan y Lisp, no sobre cualquier lenguaje dinámico, y mucho menos sobre TypeScript.
  • Dice “más simple” y “para algunos usos”. No dice que esos patrones sobren.
  • Los 16 no se deben a una sola característica:
Característica del lenguaje Patrones ¿La tiene TypeScript?
Tipos de primera clase (6) Abstract Factory, Flyweight, Factory Method, State, Proxy, Chain of Responsibility En parte: las clases son valores
Funciones de primera clase (4) Command, Strategy, Template Method, Visitor Sí
Macros (2) Interpreter, Iterator No, pero tiene iteradores en el lenguaje
Combinación de métodos (2) Mediator, Observer No
Multimétodos (1) Builder No
Módulos (1) Facade Sí

Dos de los patrones de esta semana, Adapter y Decorator, ni siquiera están entre esos 16. Así que la lección no es “los patrones sobraron”, sino más específica: pregunten qué parte de un patrón resolvía su problema y qué parte solo le daba la vuelta a una limitación del lenguaje.

Veamos ahora los que siguen vigentes, cada uno con un ejemplo de Kallpa y lo que cuesta.

Strategy

Problema. Hay varias formas de hacer algo y se quiere cambiar de una a otra sin tocar a quien lo usa.

Solución clásica. Una interfaz con un método y una clase por cada algoritmo; quien usa el algoritmo recibe un objeto de esa interfaz. patterns/strategy.ts lo escribe así para ordenar las horas libres que se le sugieren a un vecino:

export interface HourRanker {
  compare(a: number, b: number): number;
}

export class EarliestFirst implements HourRanker {
  compare(a: number, b: number): number {
    return a - b;
  }
}

// ... y ClosestTo, una clase con un campo #target y su propio compare

En TypeScript. Las funciones son valores, así que la interfaz con un solo método se vuelve el tipo de una función, y Array.prototype.sort ya recibe justamente eso:

export type Rank = (a: number, b: number) => number;

export const earliestFirst: Rank = (a, b) => a - b;
export const closestTo =
  (target: number): Rank =>
  (a, b) =>
    Math.abs(a - target) - Math.abs(b - target) || a - b;

export function suggest(slots: Slot[], rank: Rank): number[] {
  return slots
    .filter((slot) => slot.available)
    .map((slot) => slot.hour)
    .sort(rank);
}

Las dos versiones ordenan igual, lo comprueba patterns.test.ts. La segunda tiene menos ceremonia y el patrón sigue ahí: un algoritmo que se cambia sin tocar a suggest. El dato que en la clase era un campo privado (#target) ahora lo guarda la clausura.

En Kallpa. Clock es una estrategia para obtener “ahora”, con una forma un poco más grande que una función porque es un objeto con un método:

export type Clock = {
  now(): Date;
};

systemClock en producción y fixedClock en las pruebas. Si cada función llamara a new Date(), una prueba que hoy siempre da el mismo resultado dependería del día en que se corre, y probar “esa hora ya pasó” obligaría a escribir fechas relativas a hoy.

Cuándo no. Si solo hay un algoritmo, y no hay un motivo concreto para esperar otro, llamen a la función y ya. El costo de una estrategia es una indirección: para saber qué código corre, hay que mirar quién la pasó.

Dependency injection

Problema. Un módulo necesita a otro, y si lo crea él mismo queda atado a esa implementación: no se puede cambiar ni probar por separado.

Solución. Un módulo recibe sus dependencias en lugar de crearlas. El término es de Martin Fowler, en el artículo de 2004 de esta semana. Fowler parte de que “inversión de control” era un nombre demasiado general, y propone dependency injection para algo más preciso: un objeto aparte (el ensamblador) conecta las piezas. Distingue tres estilos:

  • Por constructor (constructor injection): las dependencias se pasan al crear el objeto.
  • Por setter (setter injection): se asignan con métodos después de crearlo.
  • Por interfaz (interface injection): el objeto implementa una interfaz que define cómo inyectarlas.

También compara la inyección con otra alternativa: el service locator, un objeto al que se le piden las dependencias. Y hace una observación que dura hasta hoy: lo central es separar la configuración del uso, más que la herramienta con la que se haga.

En Kallpa. createServer recibe lo que necesita: createServer({ db, clock, notifier, log }). Es inyección por constructor en la forma más simple, sin clases. En producción, main.ts abre el archivo real y se queda con el reloj del sistema y el notificador por consola, que son los valores por omisión. En las pruebas, test/helpers.ts pasa otra cosa en cada lugar:

const db = openDatabase(':memory:');
seedCatalog(db);
const sent: BookingConfirmation[] = [];
const notifier: Notifier = { bookingConfirmed: (confirmation) => sent.push(confirmation) };
const server = createServer({ db, clock: fixedClock(options.now ?? MONDAY_10AM), notifier, log: () => {} });

Sin esto, cada prueba escribiría en un archivo real, dependería de la fecha y llenaría la consola. La semana 9 estudia estos reemplazos con su nombre: test doubles.

Error común. Creer que la dependency injection necesita un contenedor, anotaciones o una biblioteca. Pasar parámetros al construir el objeto es dependency injection. Un contenedor automatiza ese cableado cuando hay cientos de objetos; Kallpa tiene unos pocos y un solo lugar donde se arman, y agregarle uno haría el código más difícil de seguir.

Adapter

Problema. Ya existe algo con una interfaz, y el código espera otra. No se puede cambiar ninguna de las dos, o no se debe.

Solución. Un objeto pequeño que implementa la interfaz que el código espera y por dentro llama a la otra.

En Kallpa. Notifier es la interfaz que Kallpa espera, y hoy la implementa consoleNotifier. Cuando el complejo contrate un proveedor de WhatsApp, ese proveedor tendrá su propia interfaz. El proveedor de patterns/adapter.ts es inventado para la clase; su forma no copia la API de ninguna empresa, y el ejemplo real tendría además autenticación y manejo de errores:

export type TemplateMessage = {
  to: string; // con código de país: +51987654321
  template: string;
  params: string[];
};

export type MessagingProvider = {
  sendTemplate(message: TemplateMessage): void;
};

// Lo único que sabe de los dos lados: traduce una llamada en la otra.
export function messagingNotifier(provider: MessagingProvider): Notifier {
  return {
    bookingConfirmed(c: BookingConfirmation) {
      provider.sendTemplate({
        to: `+51${c.phone}`,
        template: 'reserva_confirmada',
        params: [c.memberName, c.courtName, c.date, `${c.startHour}:00`, String(c.hours), String(c.priceCents)],
      });
    },
  };
}

Toda la traducción está en un solo lugar: el +51, el nombre del template, el orden de los parámetros. La prueba arma el servidor real con este adapter y hace una reserva por HTTP. El mensaje llega al proveedor con su formato, y handlers.ts no cambió una línea.

Fíjense en cómo se conectan las ideas: el adapter resuelve el formato, y puede enchufarse sin tocar nada porque Kallpa ya recibía su Notifier por dependency injection. También es ocultamiento de información en el sentido de Parnas (semana 5): la decisión “qué proveedor usamos” queda dentro de un módulo.

Dos paneles con la misma forma. Strategy, para cambiar un algoritmo: suggest(slots, rank), que no cambia, usa el tipo Rank, una función de (a, b) a number; lo implementan earliestFirst, la más temprana, y closestTo(target), la más cercana. Una nota dice que en Kallpa el caso es Clock, con systemClock en producción y fixedClock en las pruebas. Adapter, para hacer encajar dos interfaces: handlers.ts, que no cambia una línea, usa Notifier, con bookingConfirmed(c); lo implementan consoleNotifier, que escribe en la consola, y messagingNotifier, el adapter, que traduce y usa MessagingProvider con sendTemplate(mensaje). La traducción es el +51, el template y el orden de los parámetrosDos paneles con la misma forma. Strategy, para cambiar un algoritmo: suggest(slots, rank), que no cambia, usa el tipo Rank, una función de (a, b) a number; lo implementan earliestFirst, la más temprana, y closestTo(target), la más cercana. Una nota dice que en Kallpa el caso es Clock, con systemClock en producción y fixedClock en las pruebas. Adapter, para hacer encajar dos interfaces: handlers.ts, que no cambia una línea, usa Notifier, con bookingConfirmed(c); lo implementan consoleNotifier, que escribe en la consola, y messagingNotifier, el adapter, que traduce y usa MessagingProvider con sendTemplate(mensaje). La traducción es el +51, el template y el orden de los parámetros
Se parecen porque los dos ponen una interfaz en el medio. Se diferencian en para qué: strategy ofrece varios algoritmos intercambiables; adapter hace encajar algo que ya existe y tiene otra forma.

Cuándo no. Un adapter para un proveedor que todavía no existe, “por si acaso”, es código sin usuarios. Lo que sí conviene desde el inicio es la interfaz Notifier, que no cuesta casi nada y ya sirve para las pruebas. El adapter se escribe el día en que hay un proveedor.

En la industria. La semana 7 estudia la arquitectura hexagonal, de Alistair Cockburn, también llamada de puertos y adaptadores. Es esta misma idea llevada a todo un sistema: el núcleo define las interfaces que necesita (los puertos), y cada tecnología externa entra por un adaptador.

Repository

Problema. La lógica del negocio no debería saber cómo se guardan las cosas.

Solución. Fowler, en el catálogo de Patterns of Enterprise Application Architecture, define Repository como algo que “mediates between the domain and data mapping layers using a collection-like interface for accessing domain objects”. Quien lo usa ve una colección de objetos del dominio, y no ve SQL.

En Kallpa. src/store/ tiene tres repositorios. El de canchas se ajusta bien a la definición:

list(): Court[] {
  return this.#db.prepare('SELECT id, name, sport FROM courts ORDER BY name').all() as Court[];
}

Devuelve Court, un tipo del dominio. BookingRepository, en cambio, devuelve BookingRow, con los nombres de las columnas (start_hour, court_id). Tiene el nombre del patrón, pero no esconde del todo lo que el patrón promete esconder: es la abstracción que se filtra de la semana pasada. Se parece más a un acceso a una tabla que a una colección de reservas. Poner Repository en el nombre de una clase no la vuelve un repositorio.

Cuándo no. Una aplicación con tres consultas no necesita una capa aparte solo para tener el patrón. Y una interfaz CourtRepository con una sola clase que la implementa, y que no se prueba con otra cosa, es ceremonia: la clase ya es la interfaz.

Decorator y middleware

Problema. Se quiere agregar algo antes o después de una operación (un log, manejo de errores, una medición de tiempo) sin cambiarla, y poder combinar esos agregados.

Solución. Un decorator envuelve a un objeto con otro de la misma interfaz: quien lo usa no nota la diferencia, y el de adentro no se entera. En TypeScript suele ser una función que recibe una función y devuelve otra del mismo tipo. Ya vieron uno en este capítulo: withIdempotency. Los frameworks web llaman middleware a esta misma idea aplicada a los handlers de HTTP.

En Kallpa. Hoy, src/http/server.ts hace todo junto dentro de un solo try/catch/finally: buscar la ruta, convertir errores en JSON y escribir el log. decorator-server.diff lo separa en tres funciones, todas con la misma firma:

type RawHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;

// Convierte cualquier error en una respuesta JSON.
function withErrors(next: RawHandler): RawHandler {
  return async (req, res) => {
    try {
      await next(req, res);
    } catch (error) {
      if (error instanceof HttpError) {
        sendError(res, error);
      } else {
        console.error(error);
        sendError(res, new HttpError(500, 'INTERNAL_ERROR', 'Ocurrió un error inesperado.'));
      }
    }
  };
}

// Escribe una línea de log al terminar, pase lo que pase. Va por fuera de
// withErrors para ver el código de estado que de verdad se respondió.
function withLog(log: (line: string) => void, next: RawHandler): RawHandler {
  return async (req, res) => {
    try {
      await next(req, res);
    } finally {
      log(`${req.method} ${req.url} ${res.statusCode}`);
    }
  };
}

Y al armar el servidor se leen de afuera hacia adentro:

return http.createServer(withLog(log, withErrors(dispatcher(router))));

make decorator aplica el cambio a una copia de Kallpa y corre su make check:

 src/http/server.ts | 65 ++++++++++++++++++++++++++++++++++++------------------
 1 file changed, 44 insertions(+), 21 deletions(-)
ℹ tests 40
ℹ suites 6
ℹ pass 40
ℹ fail 0

Las 40 pruebas de Kallpa pasan, y el Makefile comprueba que test/ no cambió, igual que en el refactoring de la semana pasada. Hay un único detalle fuera de lo que esas pruebas miran: new URL(...) pasó a estar dentro de withErrors, y por lo tanto un error al armar la URL ahora responde 500, en lugar de quedar sin capturar. El tamaño del cambio también es un dato: 44 líneas nuevas y 21 borradas. En un servidor de este tamaño, con un solo try/catch, no está claro que convenga. Convendría cuando haya que agregar un tercer y un cuarto wrapper, por ejemplo una medición de tiempos o una clave de idempotencia.

El orden importa. Componer decorators no es conmutativo, y aquí tenemos una demostración exacta. patterns.test.ts envuelve un handler que lanza un 404 de las dos formas:

✔ decorator: withLog(withErrors(h)) registra el estado que recibió el cliente HTTP
✔ decorator: al revés, withErrors(withLog(h)) registra un 200 que nunca existió

(Salida de node --test, sin los tiempos.)

En el segundo orden, cuando withLog escribe su línea, todavía nadie convirtió la excepción en una respuesta, y res.statusCode tiene su valor por omisión, 200. El cliente HTTP recibe un 404 y el log dice 200. El tipo es el mismo y tsc no se queja: el orden es una decisión de diseño que solo una prueba puede proteger.

Dos paneles con cajas anidadas alrededor de failing, un handler que lanza HttpError 404. A la izquierda, withLog(withErrors(h)): withLog por fuera y withErrors por dentro. Primero withErrors atrapa y responde 404, entonces statusCode ya es 404 y al final withLog escribe la línea. Quien llama recibe 404 y el log dice GET /x 404. A la derecha, withErrors(withLog(h)): withErrors por fuera y withLog por dentro. Primero withLog escribe la línea, con statusCode aún en 200; la excepción sigue subiendo; y al final withErrors atrapa y responde 404. Quien llama recibe 404 y el log dice GET /x 200Dos paneles con cajas anidadas alrededor de failing, un handler que lanza HttpError 404. A la izquierda, withLog(withErrors(h)): withLog por fuera y withErrors por dentro. Primero withErrors atrapa y responde 404, entonces statusCode ya es 404 y al final withLog escribe la línea. Quien llama recibe 404 y el log dice GET /x 404. A la derecha, withErrors(withLog(h)): withErrors por fuera y withLog por dentro. Primero withLog escribe la línea, con statusCode aún en 200; la excepción sigue subiendo; y al final withErrors atrapa y responde 404. Quien llama recibe 404 y el log dice GET /x 200
Las dos composiciones tienen el mismo tipo y quien llama recibe lo mismo. Solo cambia lo que queda en el log, y en el segundo orden registra un 200 que nunca existió.

Cuándo no. Si el agregado solo se usa en un lugar y nunca se va a combinar con otro, una llamada directa se lee mejor que un wrapper.

Observer

Problema. Cuando pasa algo, varias partes del sistema tienen que reaccionar, y quien lo hace pasar no debería conocerlas.

Solución. Quien produce el evento mantiene una lista de interesados y les avisa; se pueden agregar y quitar sin tocar a quien produce. En Node existe EventEmitter.

En Kallpa. Hoy createBooking llama directamente al notificador. Si además hubiera que actualizar la caja y registrar una métrica, ese handler conocería a tres módulos más. Con eventos, sabe solo que debe avisar (patterns/observer.ts):

export function confirmBooking(
  events: EventEmitter<BookingEvents>,
  saved: BookingConfirmedEvent[],
  booking: BookingConfirmedEvent,
): void {
  saved.push(booking);
  events.emit('booking-confirmed', booking);
}

Quienes reaccionan se registran en otro lugar:

events.on('booking-confirmed', (e) => log.push(`whatsapp ${e.bookingId}`));
events.on('booking-confirmed', (e) => log.push(`caja ${e.priceCents}`));

Lo que cuesta. Se paga por tres lados, y conviene conocerlos antes de elegirlo:

  • El flujo ya no se lee de corrido. Para saber qué pasa cuando se confirma una reserva, hay que buscar en todo el proyecto quién escucha ese evento. Esto es la oscuridad de la semana pasada, comprada a cambio de menos acoplamiento.
  • El orden es implícito. Los listeners corren en el orden en que se registraron, y ese orden no se ve en ningún lugar del código de reservas.
  • Un listener que falla afecta a todos. Este es el menos obvio. emit es síncrono: llama a los listeners uno por uno, dentro de la misma llamada. Si uno lanza una excepción, los siguientes no corren y la excepción le llega a quien emitió:
test('observer: un listener que lanza corta a los siguientes y le llega a quien emitió', () => {
  const events = createBookingEvents();
  const saved: BookingConfirmedEvent[] = [];
  const log: string[] = [];
  events.on('booking-confirmed', () => {
    throw new Error('WhatsApp no responde');
  });
  events.on('booking-confirmed', () => log.push('caja'));
  assert.throws(() => confirmBooking(events, saved, event), /WhatsApp no responde/);
  assert.equal(saved.length, 1); // la reserva ya está guardada...
  assert.deepEqual(log, []); // ...y la caja nunca se enteró
});

La reserva quedó guardada, la caja no se enteró y quien llamó a la API va a recibir un 500 por una reserva que sí se hizo. Es el mismo problema de la respuesta perdida, causado esta vez desde adentro. El desacoplamiento era del código, no de las fallas. Para separar también las fallas hace falta capturar errores en cada listener o usar una cola de mensajes, que tiene sus propios costos.

Cuándo no. Con uno o dos reaccionantes que se conocen y no cambian, una llamada directa dice más. Kallpa hoy tiene uno: el notificador. Un EventEmitter allí sería prematuro.

Factory Method

Problema. Quien usa un objeto no debería decidir qué clase concreta crear.

Solución clásica. Refactoring.Guru lo define como un método en una superclase para crear objetos, que las subclases pueden redefinir para cambiar el tipo de lo que se crea. Según esa definición, el patrón requiere herencia, y Norvig lo pone entre los que se simplifican cuando los tipos son valores de primera clase.

En TypeScript. Lo que más se ve es algo más simple, y no es exactamente ese patrón: una función que elige y devuelve una implementación. Se le dice factory function. Es útil cuando la decisión depende de un dato:

export function notifierFor(channel: 'console' | 'silent'): Notifier {
  return channel === 'console' ? consoleNotifier : { bookingConfirmed() {} };
}

También fixedClock('2026-03-09T15:00:00Z') es una factory function: da un objeto listo con un Clock dentro y le esconde a quien llama cómo está hecho.

Cuándo un patrón estorba

Más interfaces sin esconder nada

Aplicar un patrón porque existe es un error frecuente, y se reconoce por la desproporción entre lo que se agrega y lo que se esconde. Este código es de patterns/overkill.ts, escrito para discutirlo:

export interface NotifierFactory {
  create(): Notifier;
}

export class ConsoleNotifierFactory implements NotifierFactory {
  create(): Notifier {
    return consoleNotifier;
  }
}

export class NotifierFactoryProvider {
  getFactory(): NotifierFactory {
    return new ConsoleNotifierFactory();
  }
}

La prueba lo dice en una línea: todo eso devuelve lo mismo que escribir el nombre de la constante.

assert.equal(new NotifierFactoryProvider().getFactory().create(), consoleNotifier);

Son módulos superficiales en el sentido de la semana 5: una interfaz tan grande como la implementación. Quien lee tiene que recorrer tres definiciones para llegar a un nombre que ya estaba exportado. Y no abaratan ningún cambio: si mañana hay otro notificador, hay que escribirlo igual y cambiar una línea, con o sin fábrica.

El Singleton

Un Singleton garantiza que una clase tenga una sola instancia, accesible desde cualquier parte. Es estado global con otro nombre, y sus problemas son los del estado global. Quien depende de él no lo dice en su firma, y lo que una prueba le hace le llega a la siguiente:

// El código que depende del Singleton no lo dice en su firma.
export function isMorningWithSingleton(): boolean {
  return GlobalClock.getInstance().now().getUTCHours() < 17; // 12:00 en Lima
}

// El mismo código con el reloj como parámetro.
export function isMorning(clock: Clock): boolean {
  return clock.now().getUTCHours() < 17;
}

Con la segunda versión, cada llamada dice qué hora usa y nada se comparte. La necesidad de una sola instancia suele resolverse creando una sola, en un solo lugar, y pasándola: en Kallpa hay una sola conexión a la base de datos, y nadie necesita una clase especial para eso. main.ts la abre y createServer la recibe.

Error común. Sumar patrones como si fueran puntos. Un diseño con más patrones no es un diseño mejor. Un diseño mejor esconde más y le pide menos a quien lo lee.

La pregunta

Para cualquier patrón, en su propio código o en una revisión, la pregunta es la misma: ¿qué cambio futuro abarata esto, y qué tan probable es?

Patrón Abarata En Kallpa
Strategy Cambiar un algoritmo Clock: un reloj fijo en cada prueba
Dependency injection Reemplazar una dependencia createServer: la base de datos, el reloj y el notificador
Adapter Cambiar de proveedor Notifier: el día en que haya WhatsApp Business
Repository Cambiar cómo se guarda; probar sin base de datos src/store/, con la filtración de BookingRow
Decorator Agregar y combinar cosas alrededor de una operación El try/catch/finally de server.ts
Observer Agregar reacciones sin tocar a quien produce Todavía no: hay un solo reaccionante
Factory Method Cambiar qué se crea Una función basta

Si no se puede nombrar el cambio, el patrón probablemente sobra. Y si se puede, pero es poco probable, todavía pueden esperar: agregar una abstracción cuando llega el segundo caso suele costar poco, y con dos casos reales se diseña mejor que con uno imaginado.

Idea clave. Un patrón es una respuesta. Sin una pregunta concreta (qué cambio se quiere abaratar), solo es más código.

Con IA. Los modelos de lenguaje reproducen los patrones que más aparecen en el código con el que se entrenaron, y a veces los traen con más ceremonia de la necesaria: interfaces con una sola implementación, fábricas para todo, capas que solo reenvían. Es una observación de la práctica, no un resultado medido, y varía según la herramienta y el pedido. Lo que no varía es cómo se revisa: para cada abstracción nueva de un diff, pregunten qué cambio facilita y cuántas implementaciones tiene hoy. Un NotifierFactoryProvider como el de arriba pasa tsc, ESLint y todas las pruebas. Ninguna herramienta lo va a marcar; tiene que hacerlo quien revisa.

El laboratorio: documentar la API de Kallpa

El laboratorio aplica la primera parte del capítulo, en parejas, en una rama nueva de su copia de Kallpa. Los pasos completos están en la guía de la semana; aquí van los puntos que más cuestan. La regla central: la especificación describe la API como es hoy, no como debería ser. Las mejoras van aparte, en docs/api-revision.md.

Recorrer antes de escribir (pasos 1 y 2). Con dos terminales en el mismo contenedor: make seed y make start en una, curl -s -i en la otra. Anoten en una tabla, por endpoint, el código y la forma del cuerpo de cada respuesta, también de error. No se la crean al README: revísenla contra lo que responde el servidor. Pregúntense por cada operación qué entradas darían otra respuesta. Si su copia ya tiene integrado su issue de P0, puede tener más endpoints.

Escribir por partes (pasos 3 y 4). Copien code/week-06/openapi.yaml a docs/openapi.yaml y agreguen un endpoint a la vez, corriendo redocly lint docs/openapi.yaml cada vez. Los códigos de error van en un enum en el esquema Error.

Las reglas en el esquema (paso 4). minimum, maximum y maxLength documentan las reglas del README, pero no reemplazan a lo que hace el servidor. Si el esquema dice maximum: 3 para hours, el servidor aun así responde con su propio código cuando llega un 4. Documenten los dos.

Las advertencias (paso 5). El paso pide corregir o justificar cada una con un comentario en el YAML. No agreguen una respuesta falsa para que desaparezca una advertencia. Después, redocly build-docs docs/openapi.yaml -o docs/api.html genera una página que pueden abrir en el navegador de su computadora: es el mejor momento para leer su especificación como la leería alguien de afuera.

La revisión (paso 6). Comparen su tabla y su especificación con las guías, y encuentren al menos cuatro inconsistencias. Una ya la vieron en este capítulo: GET /api/members/:id/bookings devuelve un arreglo suelto y las otras listas un objeto. Busquen las demás en los temas que menciona la guía: códigos de estado, forma de las respuestas, nombres de campos, la frontera entre 400 y 422, y qué pasa al reintentar. Para cada una, respondan las dos preguntas de la guía: ¿corregirla rompe a quien ya llama a la API? ¿Cómo la harían convivir con la versión actual? La tabla de compatibilidad de este capítulo y v2-bookings.diff sirven de modelo. Que algo esté mal no quiere decir que valga la pena corregirlo: “se queda como está, y lo documentamos” también es una respuesta válida, si la justifican.

La clave de idempotencia (paso 7). Respondan las preguntas de la sección Las decisiones que el código no toma por ustedes, ahora para Kallpa. Tengan en cuenta que Kallpa manda un aviso por WhatsApp al confirmar. idempotency.ts les da la lógica, pero no la respuesta: guarda en un Map y envuelve una operación que devuelve un resultado, y los handlers de Kallpa escriben directamente en res. Si hacen la parte opcional, escriban primero la prueba del reintento, con un servidor de prueba: la misma clave, dos solicitudes, una sola reserva y un solo aviso en server.sent.

En la industria. P2 pide esta misma especificación para su proyecto, en docs/api/openapi.yaml, y la revisión automática corre redocly lint con el conjunto recommended. Los avisos no hacen fallar esa revisión, pero los errores sí. Ver projects/p2-diseno/.

El código de la semana y make test

La carpeta code/week-06 tiene:

Archivo Qué es
openapi.yaml El esqueleto del laboratorio
api.test.ts Lo que el capítulo dice de la API publicada de Kallpa, contra un servidor de prueba
recorrido.sh make recorrido: reservar, reintentar y cancelar dos veces con curl
lost-response.ts, charges-server.ts, retry-demo.ts, lost-response.test.ts La respuesta perdida: en Kallpa y en un servidor de pagos, con y sin clave (make retry)
idempotency.ts, idempotency.test.ts La clave de idempotencia como decorator
evolution.ts, evolution.test.ts Qué cambios rompen a qué clientes HTTP
v2-bookings.diff, recorrido-v2.sh Una versión nueva con Deprecation y Sunset (make v2, make recorrido-v2)
contract.ts, contract.test.ts, client-example.ts Comparar respuestas y clientes HTTP con la especificación
lint-examples/, lint.test.ts Lo que redocly lint encuentra y lo que no
patterns/, patterns.test.ts Los patrones del capítulo
decorator-server.diff withErrors y withLog en server.ts (make decorator)

make test revisa los tipos, corre ESLint y redocly lint sobre el esqueleto, corre las pruebas de la carpeta y aplica los dos diffs a copias de Kallpa, corriendo su make check en cada una. Los ejemplos leen la Kallpa del curso sin modificarla: se copia a build/kallpa.

Resumen

  • Una API es un contrato con quien no está en la sala. No se puede revisar ni actualizar a quien llama, y por la ley de Hyrum todo lo observable termina siendo parte del contrato.
  • Diseño orientado a recursos: sustantivos en las rutas y verbos estándar (List y Get con GET, Create con POST, Update con PATCH, Delete con DELETE). Lo que no encaja es un método propio, como cancelar.
  • Seguro (RFC 9110): quien llama no pide un cambio de estado. Idempotente: el efecto buscado de N solicitudes iguales es el de una. La respuesta puede cambiar. POST y PATCH no están definidos como idempotentes, pero una operación concreta puede serlo si se diseña y se documenta así.
  • Una respuesta perdida deja a quien llama sin saber qué pasó. Reintentar un POST puede duplicar la operación, o fallar sin decir que la primera vez funcionó.
  • Una clave de idempotencia identifica la intención: el servidor guarda la clave con la respuesta y, ante un reintento, la repite. El borrador de la IETF, que aún no es un RFC, recomienda 422 si se reusa la clave con otro cuerpo y 409 si la primera sigue en proceso. La garantía real exige guardar la clave y el efecto en la misma transacción.
  • Códigos de estado y errores: una frontera entre 400 y 422 escrita y respetada, y una sola forma de error con un código estable.
  • Compatible es lo que no rompe a quien ya llama. Quitar, renombrar o cambiar el significado o el código de estado no lo es. Agregar un campo lo es si quien llama ignora lo que no conoce. Envolver una lista hace posible la paginación, pero agregar paginación sigue siendo un cambio de comportamiento (AIP-158).
  • Deprecar: convivir, anunciar con Deprecation y Sunset, medir y recién entonces apagar. Google versiona en la ruta y Azure en un parámetro; lo común es no romper una versión ya publicada.
  • OpenAPI deja el contrato por escrito. redocly lint revisa el documento, no el servidor: para saber si dice la verdad hay que comparar con respuestas reales.
  • Un patrón de diseño es una solución con nombre y con costos. Siguen vigentes el problema y el vocabulario; la forma cambia. Norvig: 16 de 23 son invisibles o más simples en Dylan o Lisp, para algunos usos. Esa cifra no se traslada tal cual a otros lenguajes.
  • Strategy es a menudo solo una función; dependency injection, pasar parámetros; adapter traduce; repository esconde cómo se guarda; decorator envuelve, y el orden importa; observer desacopla el código, no las fallas.
  • Antes de aplicar un patrón: ¿qué cambio futuro abarata y qué tan probable es? Un NotifierFactoryProvider pasa todas las herramientas, y aun así sobra.

Ejercicios

  1. Clasifiquen estas solicitudes como seguras, idempotentes o ninguna de las dos, justificando con la definición de RFC 9110: GET /api/courts; un GET que registra una visita en un log; PUT /api/courts/1 con la cancha entera; PATCH /api/members/2 con { "plan": "socio" }; PATCH /api/members/2 con { "balanceDelta": 10 }.
  2. Un compañero dice: “POST /api/bookings/12/cancel no es idempotente, porque POST nunca lo es; además, la segunda vez responde otra cosa”. Respóndanle por escrito, separando el método de la operación, y digan qué le falta a Kallpa para que quien llama pueda contar con ese comportamiento.
  3. Clasifiquen como compatible o incompatible, y digan qué tendría que ser cierto de quien llama para que no se rompa: agregar price a la respuesta de la disponibilidad; cambiar hour por startHour; pasar un error de 400 a 422; agregar un parámetro opcional ?sport=; agregar un valor nuevo al enum de status.
  4. Diseñen tres rutas y sus respuestas para una funcionalidad nueva de Kallpa: implementos que se prestan con una reserva (como en la semana 5). Escriban para cada operación el método, la ruta, el código de éxito y al menos dos códigos de error. ¿Alguna es un método propio?
  5. Agreguen a idempotency.test.ts una prueba para esta decisión: una operación que devuelve 500 (sin lanzar una excepción) no debería quedar guardada. Modifiquen withIdempotency para que pase. ¿Qué cambia para quien reintenta? Busquen un caso en el que esta decisión sea peligrosa.
  6. En lost-response.test.ts, agreguen una prueba de un cliente HTTP con el error común: genera la clave de idempotencia dentro del bucle de reintentos. Comprueben que cobra dos veces y corríjanlo.
  7. Escriban en evolution.ts un cuarto cliente HTTP y un cambio que lo rompa sin cambiar ningún nombre ni ningún tipo: solo el significado de un campo. ¿Lo detectaría una especificación OpenAPI? ¿Qué lo detectaría?
  8. Extiendan contract.ts para que revise format: date (YYYY-MM-DD) y agreguen una prueba. ¿Por qué se debe distinguir entre “una respuesta no cumple la especificación” y “la especificación no describe el servidor”? ¿Qué harían en cada caso?
  9. Escriban un decorator withTiming(log, next) con la firma de patterns/decorator.ts, que registre cuántos milisegundos tardó el handler. Reciban el reloj como dependencia para poder probarlo. ¿En qué lugar de la cadena lo ponen, y qué mide en cada posición?
  10. Un compañero propone una interfaz NotifierFactory con una sola implementación que devuelve consoleNotifier. Escriban el comentario que dejarían en la revisión de código: qué pregunta le hacen, qué alternativa proponen y en qué condición futura sí la aceptarían.
  11. Cambien patterns/observer.ts para que un listener que falla no afecte a los demás ni a quien emite. Ajusten la prueba correspondiente. ¿Qué pasa ahora con el error? ¿Dónde queda registrado, y quién se entera de que el aviso de WhatsApp no salió?
  12. En el proyecto de su equipo, escriban la especificación de tres operaciones (o revisen la que tienen para P2) y escojan un patrón de este capítulo que estén usando, o que estén a punto de usar. Respondan por escrito, para el docs/diseno.md de P2: ¿qué cambio futuro abarata y qué tan probable es? Si no pueden nombrarlo, propongan cómo quitarlo.