05
Diseño y arquitectura

Complejidad, módulos profundos y conceptos

Diseño y arquitectura

Semana 5 — Complejidad, módulos profundos y conceptos

Objetivos

  • Definir la complejidad de un sistema como lo que lo hace difícil de entender y de cambiar, y reconocer sus síntomas (amplificación de cambios, carga cognitiva, incógnitas desconocidas) y sus causas (dependencias y oscuridad).
  • Distinguir módulos profundos de módulos superficiales, y diseñar una interfaz que esconda una decisión de diseño.
  • Detectar abstracciones que se filtran, métodos que solo reenvían la llamada y conocimiento repetido en varios lugares.
  • Describir una aplicación por sus conceptos (propósito, estado, acciones y principio operacional) y razonar sobre el acoplamiento y la cohesión entre módulos.
  • Refactorizar sin cambiar el comportamiento, con las pruebas como red de seguridad.

Sesión A — Diseño: complejidad, módulos profundos y ocultamiento de información

Lecturas:

  • John Ousterhout, A Philosophy of Software Design, 2.ª ed., caps. 2–5 (The Nature of Complexity, Working Code Isn’t Enough, Modules Should Be Deep, Information Hiding (and Leakage)). El libro no es gratuito: biblioteca o copia propia (página del libro).
  • Si no tienen el libro: la charla de Ousterhout A Philosophy of Software Design (Talks at Google), que cubre las mismas ideas.

Lecturas complementarias:

Temas:

  • Complejidad, según Ousterhout: todo lo que en la estructura de un sistema lo hace difícil de entender y de modificar. No es el tamaño: un sistema grande puede ser simple de cambiar y uno pequeño, imposible. Lo que cuenta es el costo de los cambios que de verdad se hacen.
  • Tres síntomas. Amplificación de cambios: un cambio simple obliga a tocar muchos lugares (si el horario de cierre estuviera escrito como 22 en cinco archivos, cerrar a las 23:00 tocaría los cinco). Carga cognitiva: cuánto hay que saber para hacer un cambio sin romper nada. Incógnitas desconocidas: no se sabe qué hay que tocar, ni siquiera que hay algo que saber. La tercera es la peor. Dos causas: las dependencias (no se puede entender un trozo de código solo) y la oscuridad (información importante que no es evidente).
  • Programación táctica frente a estratégica. La táctica busca que la tarea de hoy funcione; la estratégica invierte un poco en cada cambio (del 10 al 20 % del tiempo, propone el libro) para que el diseño mejore. “El código funciona” no basta. Error común: llamar “deuda técnica que pagaremos luego” a lo que en realidad es diseño que nadie va a revisar.
  • Módulos profundos. Un módulo (una función, una clase, un archivo, un servicio) tiene una interfaz, que es lo que hay que saber para usarlo, y una implementación. El beneficio es la funcionalidad que da; el costo, la interfaz que hay que aprender. Un módulo profundo da mucho con una interfaz pequeña: open, read, write, lseek y close de Unix esconden discos, caches, permisos y sistemas de archivos. Un módulo superficial tiene una interfaz casi tan grande como lo que hace. Su señal más clara: métodos que solo reenvían la llamada a otro objeto con los mismos parámetros (pass-through methods, que Ousterhout trata en el cap. 7).
  • Ocultamiento de información (Parnas, 1972): cada módulo esconde una decisión de diseño que puede cambiar. Parnas compara dos formas de dividir el mismo programa: por los pasos del diagrama de flujo, o por las decisiones que cada módulo esconde. La segunda resiste mejor los cambios. En Kallpa, src/domain/clock.ts esconde cómo se pasa de un instante a la hora de Lima: si mañana se cambia Intl.DateTimeFormat por otra cosa, nadie más se entera.
  • Abstracción que se filtra (information leakage): la misma decisión aparece en varios módulos, y cambiarla obliga a tocarlos todos. Ejemplos típicos: los nombres de las columnas de una tabla que llegan hasta la respuesta HTTP; un formato de archivo que conocen el lector y el escritor por separado. Una forma frecuente es la descomposición temporal: dividir el código según el orden en que pasan las cosas (leer, validar, guardar) en lugar de según lo que cada parte sabe.
  • Error común: confundir “muchas clases pequeñas” con buen diseño. Separar todo en clases de tres líneas (classitis) multiplica las interfaces sin esconder nada. Un módulo más grande y profundo suele ser más simple de usar.
  • Con IA. Un agente de programación trabaja de forma táctica por defecto: resuelve la tarea pedida y agrega código donde le resulta más cómodo, a veces con capas que solo reenvían llamadas. Con módulos profundos, al agente le basta leer una interfaz pequeña para usar bien un módulo, y su contexto alcanza para más. Revisen el diseño de lo que genera, no solo si las pruebas pasan.

Sesión B — Conceptos, acoplamiento y cohesión

Lecturas:

Lecturas complementarias:

Temas:

  • Un concepto (Jackson) es una unidad de funcionalidad que el usuario reconoce y que tiene sentido por sí sola. Tiene un propósito (para qué existe), un estado, unas acciones y un principio operacional: el escenario típico que muestra cómo cumple su propósito. Concepto reserva: propósito, asegurar el uso exclusivo de un recurso por un tiempo; principio, “si reservo la cancha de 19:00 a 21:00, a esa hora la cancha es mía y nadie más la puede reservar”.
  • Kallpa como conjunto de conceptos: reserva, membresía (socio o regular, con descuento), tarifa con hora punta, cancelación con plazo, notificación. Cada uno se puede entender sin los demás, y se combinan con sincronizaciones: cuando se confirma una reserva, se envía una notificación. Un concepto con dos propósitos, o un propósito repartido en dos conceptos, son señales de mal diseño.
  • Conceptos y requisitos: los turnos fijos de la semana 3, ¿son un concepto nuevo o una forma de usar reserva? La respuesta cambia el modelo de datos, la interfaz y las reglas de cancelación. Mejor decidirlo antes de escribir código.
  • Acoplamiento: cuánto obliga un cambio en un módulo a cambiar otro. Cohesión: cuánto pertenecen juntas las partes de un módulo (una sola razón para cambiar). Se busca bajo acoplamiento y alta cohesión, pero la medida es la misma de la Sesión A: ¿cuánto cuesta el cambio que de verdad va a pasar?
  • Formas de acoplamiento, de más a menos dañina: depender de los detalles internos de otro módulo; compartir estado global; pasar banderas que deciden qué hace la función que se llama; pasar estructuras enteras cuando se usa un campo; pasar solo los datos necesarios. El acoplamiento también puede ser oculto: dos módulos que tienen que cambiar juntos porque comparten un supuesto que no está escrito en ninguna parte.
  • DRY (don’t repeat yourself) es sobre conocimiento, no sobre texto: cada regla del negocio debe tener una sola representación. Dos funciones con código parecido que representan reglas distintas no se juntan; una regla (“qué reservas ocupan la cancha”) escrita en tres lugares sí es un problema, aunque cada copia se vea distinta. Error común: juntar código solo porque se parece. Esa abstracción falsa acopla dos cosas que iban a cambiar por separado.
  • Juntar o separar (cap. 9 de Ousterhout): se junta lo que comparte información, lo que siempre se usa junto o lo que se entiende mejor visto junto. Se separa lo que es de propósito general de lo que es de un caso particular.
  • Dirección de las dependencias: en Kallpa, http usa services, services usa store y domain no depende de nada. Las reglas del negocio se prueban sin base de datos ni HTTP gracias a eso. Una dependencia que va al revés (el dominio que importa algo de http) rompe esa propiedad.

Laboratorio — Refactorizar un módulo superficial de Kallpa

Guía: este documento. Referencias: Ousterhout, caps. 4 y 5 (o la charla); Martin Fowler, Refactoring.

Refactorizar es cambiar la estructura del código sin cambiar su comportamiento. La regla del laboratorio: make check pasa al final sin tocar nada en test/. Las pruebas HTTP de Kallpa levantan el servidor completo, así que son la red de seguridad. En parejas, en una rama nueva de su copia de Kallpa.

  1. Punto de partida: make check en verde. Medir el código de la capa HTTP y de servicios con wc -l src/http/handlers.ts src/services/*.ts, y ver qué importa handlers.ts leyendo sus primeras líneas (head -25 src/http/handlers.ts): de qué carpetas trae cosas y qué nombres. Anotar los números.
  2. Diagnóstico, en docs/diseno-reservas.md, con evidencia (archivo y función). ¿Qué módulo de src/services/ tiene métodos que solo reenvían la llamada? ¿Qué sabe la capa HTTP que no es de HTTP (columnas de la base de datos, el reloj, el precio, qué estados ocupan una cancha)? ¿Qué conocimiento está repetido en más de un lugar? (grep -rn "confirmed" src es un buen comienzo.) ¿Qué conversiones de tipo no revisa nadie?
  3. Diseñar la interfaz antes de implementar: escribir en TypeScript el tipo del módulo nuevo (por ejemplo, un Bookings con book(request) y cancel(id)) y un comentario por operación con lo que promete y lo que esconde. Los errores del negocio salen como un resultado tipado o un error del dominio, no como un HttpError. Otra pareja revisa la interfaz con una pregunta: ¿qué tiene que saber un handler para usarla?
  4. Implementar el módulo en src/services/, usando store/ y domain/, y conectarlo en src/http/server.ts sin cambiar la firma de createServer, que es lo que usan las pruebas. Los handlers quedan en traducir: leer la solicitud, llamar al módulo y convertir el resultado en una respuesta con su código HTTP.
  5. Hacer el cambio en pasos pequeños, con un commit por paso y make check después de cada uno. Si encuentran un comportamiento que parece un error, no lo corrijan aquí: abran un issue en el tablero. Un refactoring que cambia el comportamiento ya no es un refactoring, y mezclar los dos cambios hace imposible revisarlos.
  6. Medir de nuevo, como en el paso 1. ¿Cuántos lugares conocen ahora los nombres de las columnas y la regla de qué reservas ocupan una cancha? ¿La interfaz nueva es más pequeña que lo que esconde?
  7. Abrir un pull request con el antes y el después (números del paso 1 y del paso 6), lo que el módulo esconde ahora y lo que todavía se filtra. Revisar el de otra pareja.
  8. Opcional: pedirle el mismo refactoring a un agente, con la misma regla escrita en el pedido. ¿Respetó test/? ¿Su módulo es profundo o agregó una capa que reenvía? Anótenlo en docs/registro-ia.md.

Entregas

  • P1 entrega: cliente, entrevistas, historias de usuario con criterios de aceptación, backlog priorizado y prototipo (projects/p1-requisitos/).
  • P2 publicado: Diseño y primer sprint: design doc, ADRs, especificación de la API y un esqueleto que funciona de punta a punta (projects/p2-diseno/). Entrega en semana 8, con la demo del sprint 1.

Preguntas de repaso

  1. Den un ejemplo de amplificación de cambios, uno de carga cognitiva y uno de incógnita desconocida en un programa que hayan escrito.
  2. ¿Por qué un módulo con diez métodos de una línea que llaman a otro objeto es superficial? ¿Qué tendría que esconder para volverse profundo?
  3. Expliquen el argumento de Parnas con un ejemplo de Kallpa: ¿qué decisión de diseño escondería cada módulo?
  4. Describan el concepto cancelación con plazo de Kallpa con propósito, estado, acciones y principio operacional.
  5. Dos funciones de Kallpa tienen el mismo código de tres líneas. ¿Cuándo conviene juntarlas en una y cuándo no?

Código de la semana

Compilar y probar dentro del contenedor del curso con make y make test.

Ejemplos (13)Pruebas (6)Compilación (3)

Ejemplos

Los programas que usan los apuntes y la clase.

eslint.config.jsDescargar eslint.config.js
// Las mismas reglas que Kallpa: las recomendadas de JavaScript y de
// typescript-eslint.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';

export default defineConfig(
  { ignores: ['build/', 'solution/'] },
  js.configs.recommended,
  tseslint.configs.recommended,
);
layers.tsDescargar layers.ts
// Las dependencias entre las capas de Kallpa, leídas de los imports.
//
//   node layers.ts [carpeta src]     (por omisión, ../../kallpa/src)
//
// Una capa puede usar las de abajo, nunca las de arriba. Las de abajo no
// saben que existen las de arriba: por eso domain se prueba sin base de datos
// ni HTTP.
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';
import ts from 'typescript';

export const LEVEL: Record<string, number> = {
  domain: 0,
  store: 1,
  notifications: 1,
  services: 2,
  http: 3,
  '(raíz)': 4, // main.ts, config.ts y seed.ts arman todo
};

export type Edge = { from: string; to: string; file: string; target: string; typeOnly: boolean };

function layerOf(srcDir: string, file: string): string {
  const parts = relative(srcDir, file).split(sep);
  return parts.length === 1 ? '(raíz)' : parts[0];
}

function listTs(dir: string): string[] {
  return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
    const path = join(dir, entry.name);
    if (entry.isDirectory()) return listTs(path);
    return entry.name.endsWith('.ts') ? [path] : [];
  });
}

export function edges(srcDir: string): Edge[] {
  const root = resolve(srcDir);
  const result: Edge[] = [];
  for (const file of listTs(root).sort()) {
    const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true);
    for (const node of source.statements) {
      if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) continue;
      const spec = node.moduleSpecifier.text;
      if (!spec.startsWith('.')) continue; // node:sqlite, node:http...
      const target = resolve(dirname(file), spec);
      const from = layerOf(root, file);
      const to = layerOf(root, target);
      if (from === to) continue;
      const clause = node.importClause;
      const typeOnly =
        clause !== undefined &&
        (clause.isTypeOnly ||
          (clause.name === undefined &&
            clause.namedBindings !== undefined &&
            ts.isNamedImports(clause.namedBindings) &&
            clause.namedBindings.elements.every((e) => e.isTypeOnly)));
      result.push({ from, to, file: relative(root, file), target: relative(root, target), typeOnly });
    }
  }
  return result;
}

// Una dependencia va "hacia arriba" si la capa importada no está más abajo.
export function violations(all: Edge[]): Edge[] {
  return all.filter((e) => !(LEVEL[e.to] < LEVEL[e.from]));
}

export function summary(all: Edge[]): string {
  const groups = new Map<string, Edge[]>();
  for (const e of all) {
    const key = `${e.from} → ${e.to}`;
    groups.set(key, [...(groups.get(key) ?? []), e]);
  }
  const lines = [...groups.entries()]
    .sort(([, a], [, b]) => LEVEL[b[0].from] - LEVEL[a[0].from] || LEVEL[b[0].to] - LEVEL[a[0].to])
    .map(([key, list]) => {
      const types = list.filter((e) => e.typeOnly).length;
      const note = types === list.length ? ' (solo tipos)' : types > 0 ? ` (${types} solo tipos)` : '';
      return `${key.padEnd(24)} ${String(list.length).padStart(2)} ${list.length === 1 ? 'import' : 'imports'}${note}`;
    });
  const bad = violations(all);
  lines.push(
    bad.length === 0
      ? 'Ninguna dependencia va hacia arriba.'
      : bad.map((e) => `HACIA ARRIBA: ${e.file} importa ${e.target}`).join('\n'),
  );
  return lines.join('\n');
}

if (import.meta.main) {
  const src = process.argv[2] ?? new URL('../../kallpa/src', import.meta.url).pathname;
  const all = edges(src);
  console.log(summary(all));
  if (violations(all).length > 0) process.exit(1);
}
pass-through.tsDescargar pass-through.ts
// Busca métodos que solo reenvían la llamada (pass-through methods): su cuerpo
// es una sola llamada a un método de un campo del objeto. Usa el compilador de
// TypeScript para leer el código, no expresiones regulares.
//
//   node pass-through.ts archivo.ts [otro.ts ...]
import { readFileSync } from 'node:fs';
import { basename } from 'node:path';
import ts from 'typescript';

export type MethodReport = {
  name: string;
  params: string[];
  // A dónde reenvía ('this.#repo.findItem'), o null si hace algo más.
  forwardsTo: string | null;
  // true si pasa exactamente los mismos parámetros, en el mismo orden.
  sameArguments: boolean;
};

export type ClassReport = { name: string; lines: number; methods: MethodReport[] };

export function analyze(source: string, fileName = 'input.ts'): ClassReport[] {
  const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true);
  const classes: ClassReport[] = [];
  file.forEachChild((node) => {
    if (!ts.isClassDeclaration(node) || !node.name) return;
    const start = file.getLineAndCharacterOfPosition(node.getStart()).line;
    const end = file.getLineAndCharacterOfPosition(node.getEnd()).line;
    const methods = node.members
      .filter(ts.isMethodDeclaration)
      .filter((m) => !ts.isPrivateIdentifier(m.name) && !isPrivate(m))
      .map((m) => report(m, file));
    classes.push({ name: node.name.text, lines: end - start + 1, methods });
  });
  return classes;
}

function isPrivate(m: ts.MethodDeclaration): boolean {
  return (ts.getCombinedModifierFlags(m) & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)) !== 0;
}

function report(method: ts.MethodDeclaration, file: ts.SourceFile): MethodReport {
  const name = method.name.getText(file);
  const params = method.parameters.map((p) => p.name.getText(file));
  const call = singleCall(method.body);
  if (!call || !isFieldMethod(call.expression)) return { name, params, forwardsTo: null, sameArguments: false };
  const args = call.arguments.map((a) => a.getText(file));
  return {
    name,
    params,
    forwardsTo: call.expression.getText(file),
    sameArguments: args.length === params.length && args.every((a, i) => a === params[i]),
  };
}

// El cuerpo es `return x.y(...);` o `x.y(...);` y nada más.
function singleCall(body: ts.Block | undefined): ts.CallExpression | null {
  if (!body || body.statements.length !== 1) return null;
  const [statement] = body.statements;
  const expression = ts.isReturnStatement(statement)
    ? statement.expression
    : ts.isExpressionStatement(statement)
      ? statement.expression
      : undefined;
  return expression && ts.isCallExpression(expression) ? expression : null;
}

// this.campo.metodo o this.#campo.metodo
function isFieldMethod(callee: ts.Expression): boolean {
  return (
    ts.isPropertyAccessExpression(callee) &&
    ts.isPropertyAccessExpression(callee.expression) &&
    callee.expression.expression.kind === ts.SyntaxKind.ThisKeyword
  );
}

export function format(file: string, classes: ClassReport[]): string {
  const out: string[] = [];
  for (const c of classes) {
    const forwarding = c.methods.filter((m) => m.forwardsTo !== null);
    out.push(`${basename(file)}: clase ${c.name}, ${c.lines} líneas, ${c.methods.length} métodos públicos`);
    for (const m of c.methods) {
      const signature = `${m.name}(${m.params.join(', ')})`;
      const verdict = m.forwardsTo === null ? 'hace algo más' : `reenvía a ${m.forwardsTo}${m.sameArguments ? '' : ' (cambia los argumentos)'}`;
      out.push(`  ${signature.padEnd(36)} ${verdict}`);
    }
    out.push(`  ${forwarding.length} de ${c.methods.length} métodos solo reenvían la llamada`);
  }
  return out.join('\n');
}

if (import.meta.main) {
  const files = process.argv.slice(2);
  if (files.length === 0) {
    console.error('Uso: node pass-through.ts archivo.ts [otro.ts ...]');
    process.exit(2);
  }
  for (const file of files) console.log(format(file, analyze(readFileSync(file, 'utf8'), file)));
}
refactor-fecha.diffDescargar refactor-fecha.diff
--- a/src/http/handlers.ts
+++ b/src/http/handlers.ts
@@ -59,10 +59,7 @@
 export function courtAvailability(deps: AppDependencies): Handler {
   return ({ res, url, params }) => {
     const courtId = Number(params.id);
-    const date = url.searchParams.get('date');
-    if (!isValidDate(date)) {
-      throw new HttpError(400, 'INVALID_DATE', 'Indiquen la fecha con el formato AAAA-MM-DD (?date=2026-03-14).');
-    }
+    const date = requireDate(url);
     const taken = deps.service.bookingsForCourt(courtId, date).filter((b) => b.status === 'confirmed');
     const slots = [];
     for (let hour = OPENING_HOUR; hour < CLOSING_HOUR; hour++) {
@@ -155,15 +152,22 @@
 // GET /api/reports/daily?date=YYYY-MM-DD: la caja del día, para la administración.
 export function dailyReport(deps: AppDependencies): Handler {
   return ({ res, url }) => {
-    const date = url.searchParams.get('date');
-    if (!isValidDate(date)) {
-      throw new HttpError(400, 'INVALID_DATE', 'Indiquen la fecha con el formato AAAA-MM-DD (?date=2026-03-14).');
-    }
+    const date = requireDate(url);
     const bookings = deps.service.bookingsOn(date).map(toBookingJson);
     sendJson(res, 200, dailySummary(date, deps.service.listCourts(), bookings));
   };
 }
 
+// La fecha de la consulta (?date=AAAA-MM-DD): la piden la disponibilidad y la
+// caja del día.
+function requireDate(url: URL): string {
+  const date = url.searchParams.get('date');
+  if (!isValidDate(date)) {
+    throw new HttpError(400, 'INVALID_DATE', 'Indiquen la fecha con el formato AAAA-MM-DD (?date=2026-03-14).');
+  }
+  return date;
+}
+
 // Revisa la forma del cuerpo; las reglas del negocio se revisan después.
 function parseBookingRequest(body: unknown): BookingRequest {
   if (typeof body !== 'object' || body === null || Array.isArray(body)) {
concepts/app.tsDescargar concepts/app.ts
// Una aplicación pequeña armada con tres conceptos independientes. Ningún
// concepto importa a otro: solo este archivo los conoce y los sincroniza.
//
//   cuando Reserva.reserve(u, cancha, bloque) devuelve id
//     entonces Plazo.register(id, inicio del bloque)
//          y   Notificación.notify(u, "reserva confirmada")
//   cuando alguien pide cancelar id
//     si Plazo.stillOpen(id, ahora) entonces Reserva.cancel(id)
import { Deadline } from './deadline.ts';
import { Notification } from './notification.ts';
import { Reservation, type Slot } from './reservation.ts';

export type WallTime = { day: string; hour: number; minute: number };

// Minutos desde un origen fijo, para comparar dos horas de pared.
function minutes({ day, hour, minute }: WallTime): number {
  const [y, m, d] = day.split('-').map(Number);
  return Date.UTC(y, m - 1, d, hour, minute) / 60_000;
}

export class CourtApp {
  readonly reservations = new Reservation<number, string>(); // cancha, celular
  readonly deadlines = new Deadline<number>(3);
  readonly notifications = new Notification<string>();

  book(phone: string, courtId: number, slot: Slot): number | 'taken' {
    const result = this.reservations.reserve(phone, courtId, slot);
    if (!result.ok) return 'taken';
    this.deadlines.register(result.id, minutes({ day: slot.day, hour: slot.from, minute: 0 }));
    this.notifications.notify(phone, `Reserva confirmada: cancha ${courtId}, ${slot.day} de ${slot.from}:00 a ${slot.to}:00.`);
    return result.id;
  }

  cancel(id: number, now: WallTime): 'cancelled' | 'too-late' | 'unknown' {
    if (this.reservations.slotOf(id) === undefined) return 'unknown';
    if (!this.deadlines.stillOpen(id, minutes(now))) return 'too-late';
    return this.reservations.cancel(id) ? 'cancelled' : 'unknown';
  }
}
concepts/deadline.tsDescargar concepts/deadline.ts
// concepto Plazo [Cosa]
//
// propósito  dejar que alguien se arrepienta sin que el otro lado se entere
//            a último momento
// principio  si una cosa empieza en el instante t y el plazo es de h horas,
//            antes de t - h todavía se puede deshacer; desde t - h, ya no
// estado     inicio: Cosa -> instante; horas de aviso (fijas)
// acciones   register(cosa, inicio)
//            stillOpen(cosa, ahora) -> sí | no
//
// Tampoco sabe de reservas: el mismo concepto sirve para devolver una compra
// o para retirarse de un curso. Los instantes son minutos contados desde un
// origen cualquiera; quien lo usa decide cuál.

export class Deadline<T> {
  #noticeMinutes: number;
  #startsAt = new Map<T, number>();

  constructor(noticeHours: number) {
    this.#noticeMinutes = noticeHours * 60;
  }

  register(thing: T, startsAt: number): void {
    this.#startsAt.set(thing, startsAt);
  }

  stillOpen(thing: T, now: number): boolean {
    const start = this.#startsAt.get(thing);
    return start !== undefined && start - now >= this.#noticeMinutes;
  }
}
concepts/notification.tsDescargar concepts/notification.ts
// concepto Notificación [Destinatario]
//
// propósito  avisar a alguien de algo que le importa sin que tenga que
//            revisar la aplicación
// principio  si se notifica a un destinatario, el mensaje le llega por su
//            canal
// estado     enviados: lista de (destinatario, texto)
// acciones   notify(destinatario, texto)
//
// El canal (WhatsApp, correo, la consola) es un detalle de la implementación:
// aquí los mensajes se guardan en una lista para poder revisarlos.

export class Notification<To> {
  readonly sent: { to: To; text: string }[] = [];

  notify(to: To, text: string): void {
    this.sent.push({ to, text });
  }
}
concepts/reservation.tsDescargar concepts/reservation.ts
// concepto Reserva [Recurso, Usuario]
//
// propósito  asegurar a alguien el uso exclusivo de un recurso durante un
//            tiempo
// principio  si reservo un recurso para un bloque de horas, en esas horas el
//            recurso es mío: nadie más lo puede reservar; si cancelo, el
//            bloque vuelve a estar libre
// estado     reservas: Id -> (recurso, usuario, día, desde, hasta, activa)
// acciones   reserve(usuario, recurso, día, desde, hasta) -> Id | ocupado
//            cancel(id)
//            holder(recurso, día, hora) -> Usuario | nadie
//
// Es genérico: no sabe qué es una cancha ni qué es un vecino. Funcionaría
// igual para salas de reuniones o para la parrilla del club.

export type Slot = { day: string; from: number; to: number };

type Entry<R, U> = { resource: R; user: U; slot: Slot; active: boolean };

export class Reservation<R, U> {
  #entries = new Map<number, Entry<R, U>>();
  #nextId = 1;

  reserve(user: U, resource: R, slot: Slot): { ok: true; id: number } | { ok: false } {
    for (const e of this.#entries.values()) {
      if (e.active && e.resource === resource && e.slot.day === slot.day && e.slot.from < slot.to && slot.from < e.slot.to) {
        return { ok: false };
      }
    }
    const id = this.#nextId++;
    this.#entries.set(id, { resource, user, slot, active: true });
    return { ok: true, id };
  }

  cancel(id: number): boolean {
    const entry = this.#entries.get(id);
    if (!entry || !entry.active) return false;
    entry.active = false;
    return true;
  }

  holder(resource: R, day: string, hour: number): U | undefined {
    for (const e of this.#entries.values()) {
      if (e.active && e.resource === resource && e.slot.day === day && e.slot.from <= hour && hour < e.slot.to) return e.user;
    }
    return undefined;
  }

  slotOf(id: number): Slot | undefined {
    return this.#entries.get(id)?.slot;
  }
}
kwic/flowchart.tsDescargar kwic/flowchart.ts
// El índice KWIC de Parnas (1972), dividido como un diagrama de flujo: un
// módulo por paso (leer, rotar, ordenar, imprimir). Los módulos se pasan
// arreglos y todos conocen su formato.

// El formato compartido: todas las palabras en un solo texto, cada una
// terminada en END; lineStart[i] es dónde empieza la línea i en ese texto.
export const END = '\u0000';

export type Store = { chars: string; lineStart: number[] };

// Un corrimiento circular: [línea, posición en chars donde empieza].
export type Shift = [line: number, start: number];

// Módulo 1, entrada: lee las líneas y las guarda en el formato de arriba.
export function input(text: string): Store {
  let chars = '';
  const lineStart: number[] = [];
  for (const line of text.split('\n').filter((l) => l.trim() !== '')) {
    lineStart.push(chars.length);
    for (const word of line.trim().split(/\s+/)) chars += word + END;
  }
  return { chars, lineStart };
}

// Módulo 2, corrimientos circulares: un par por cada palabra de cada línea.
export function circularShift(store: Store): Shift[] {
  const shifts: Shift[] = [];
  store.lineStart.forEach((start, line) => {
    const end = store.lineStart[line + 1] ?? store.chars.length;
    shifts.push([line, start]);
    for (let i = start; i < end - 1; i++) if (store.chars[i] === END) shifts.push([line, i + 1]);
  });
  return shifts;
}

// Para comparar, rearma el texto de un corrimiento: tiene que saber cómo
// están guardadas las palabras y dónde termina cada línea.
function shiftText(store: Store, [line, start]: Shift): string {
  const lineBegin = store.lineStart[line];
  const lineEnd = store.lineStart[line + 1] ?? store.chars.length;
  const rotated = store.chars.slice(start, lineEnd) + store.chars.slice(lineBegin, start);
  return rotated.split(END).filter(Boolean).join(' ');
}

// Módulo 3, orden alfabético: recibe los arreglos de los módulos 1 y 2.
export function alphabetize(store: Store, shifts: Shift[]): Shift[] {
  return [...shifts].sort((a, b) => compare(shiftText(store, a), shiftText(store, b)));
}

export function compare(a: string, b: string): number {
  const x = a.toLowerCase();
  const y = b.toLowerCase();
  return x < y ? -1 : x > y ? 1 : 0;
}

// Módulo 4, salida: vuelve a leer chars y lineStart para imprimir.
export function output(store: Store, sorted: Shift[]): string {
  return sorted.map((shift) => shiftText(store, shift)).join('\n');
}

// Módulo 5, control: llama a los demás en orden.
export function kwic(text: string): string {
  const store = input(text);
  return output(store, alphabetize(store, circularShift(store)));
}
kwic/hiding.tsDescargar kwic/hiding.ts
// El mismo índice KWIC dividido según lo que cada módulo esconde (la segunda
// propuesta de Parnas). Nadie fuera de LineStorage sabe cómo se guardan las
// palabras; nadie fuera de CircularShifter sabe si los corrimientos se
// guardan o se calculan; nadie fuera de Alphabetizer sabe cuándo se ordena.
import { compare } from './flowchart.ts';

// Lo que los demás ven del almacenamiento de líneas.
export type Lines = {
  addLine(words: string[]): void;
  lineCount(): number;
  wordCount(line: number): number;
  word(line: number, w: number): string;
};

// Esconde el formato: aquí, el mismo texto empaquetado de flowchart.ts.
export class PackedLines implements Lines {
  #chars = '';
  #wordStarts: number[][] = [];

  addLine(words: string[]): void {
    const starts: number[] = [];
    for (const word of words) {
      starts.push(this.#chars.length);
      this.#chars += word + '\u0000';
    }
    this.#wordStarts.push(starts);
  }
  lineCount(): number {
    return this.#wordStarts.length;
  }
  wordCount(line: number): number {
    return this.#wordStarts[line].length;
  }
  word(line: number, w: number): string {
    const start = this.#wordStarts[line][w];
    return this.#chars.slice(start, this.#chars.indexOf('\u0000', start));
  }
}

// Otra decisión, la misma interfaz: un arreglo de palabras por línea.
export class ArrayLines implements Lines {
  #lines: string[][] = [];

  addLine(words: string[]): void {
    this.#lines.push([...words]);
  }
  lineCount(): number {
    return this.#lines.length;
  }
  wordCount(line: number): number {
    return this.#lines[line].length;
  }
  word(line: number, w: number): string {
    return this.#lines[line][w];
  }
}

export function input(text: string, lines: Lines): void {
  for (const line of text.split('\n').filter((l) => l.trim() !== '')) lines.addLine(line.trim().split(/\s+/));
}

// Lo que los demás ven de los corrimientos: "como si" estuvieran guardados.
export type Shifts = { count(): number; words(i: number): string[] };

// Esconde que guarda un índice [línea, primera palabra] en vez de copias.
export class IndexedShifter implements Shifts {
  #lines: Lines;
  #index: [line: number, first: number][] = [];

  constructor(lines: Lines) {
    this.#lines = lines;
    for (let line = 0; line < lines.lineCount(); line++) {
      for (let first = 0; first < lines.wordCount(line); first++) this.#index.push([line, first]);
    }
  }
  count(): number {
    return this.#index.length;
  }
  words(i: number): string[] {
    const [line, first] = this.#index[i];
    const n = this.#lines.wordCount(line);
    return Array.from({ length: n }, (_, k) => this.#lines.word(line, (first + k) % n));
  }
}

// Esconde el orden: se ordena una vez, al construirlo; ith(i) es el i-ésimo.
export class Alphabetizer {
  #order: number[];

  constructor(shifts: Shifts) {
    const text = (i: number) => shifts.words(i).join(' ');
    this.#order = Array.from({ length: shifts.count() }, (_, i) => i).sort((a, b) => compare(text(a), text(b)));
  }
  ith(i: number): number {
    return this.#order[i];
  }
}

export function output(shifts: Shifts, alphabetizer: Alphabetizer): string {
  return Array.from({ length: shifts.count() }, (_, i) => shifts.words(alphabetizer.ith(i)).join(' ')).join('\n');
}

export function kwic(text: string, lines: Lines = new PackedLines()): string {
  input(text, lines);
  const shifts = new IndexedShifter(lines);
  return output(shifts, new Alphabetizer(shifts));
}
loans/deep.tsDescargar loans/deep.ts
import type { DatabaseSync } from 'node:sqlite';
import type { Now, Request, Response } from './schema.ts';

// Versión profunda. Mismo comportamiento que shallow.ts, pero un solo módulo
// sabe cómo se guardan los préstamos y cuándo un préstamo está abierto.

export type Loan = {
  id: number;
  itemId: number;
  memberId: number;
  lentAt: string;
  returnedAt: string | null;
};

export type ItemStatus = { id: number; name: string; available: boolean };

export type LoanError = {
  code: 'ITEM_NOT_FOUND' | 'ITEM_LENT' | 'MEMBER_HAS_LOAN' | 'LOAN_NOT_FOUND' | 'ALREADY_RETURNED';
  message: string;
};

export type LoanResult = { ok: true; loan: Loan } | { ok: false; error: LoanError };

type LoanRow = { id: number; item_id: number; member_id: number; lent_at: string; returned_at: string | null };

// Interfaz: tres operaciones. Esconde la tabla y sus columnas, la regla de
// qué préstamo está abierto (está escrita una sola vez, en el SQL de abajo),
// el reloj y el orden en que se revisan las reglas.
export class Loans {
  #db: DatabaseSync;
  #now: Now;

  constructor(db: DatabaseSync, now: Now) {
    this.#db = db;
    this.#now = now;
  }

  // Los implementos con su estado: disponible si no tiene un préstamo abierto.
  items(): ItemStatus[] {
    const rows = this.#db
      .prepare(
        `SELECT id, name, NOT EXISTS (SELECT 1 FROM loans WHERE ${OPEN} AND loans.item_id = items.id) AS available
         FROM items ORDER BY id`,
      )
      .all() as { id: number; name: string; available: number }[];
    return rows.map((r) => ({ id: r.id, name: r.name, available: r.available === 1 }));
  }

  // Presta el implemento si existe, está libre y el vecino no tiene otro.
  lend(itemId: number, memberId: number): LoanResult {
    if (!this.#db.prepare('SELECT 1 FROM items WHERE id = ?').get(itemId)) {
      return fail('ITEM_NOT_FOUND', `No existe el implemento ${itemId}.`);
    }
    if (this.#openLoan('item_id', itemId)) return fail('ITEM_LENT', 'El implemento está prestado.');
    if (this.#openLoan('member_id', memberId)) {
      return fail('MEMBER_HAS_LOAN', 'Primero hay que devolver el implemento anterior.');
    }
    const { lastInsertRowid } = this.#db
      .prepare('INSERT INTO loans (item_id, member_id, lent_at) VALUES (?, ?, ?)')
      .run(itemId, memberId, this.#now());
    return { ok: true, loan: this.#find(Number(lastInsertRowid))! };
  }

  // Registra la devolución; un préstamo se devuelve una sola vez.
  giveBack(loanId: number): LoanResult {
    const loan = this.#find(loanId);
    if (!loan) return fail('LOAN_NOT_FOUND', `No existe el préstamo ${loanId}.`);
    if (loan.returnedAt !== null) return fail('ALREADY_RETURNED', 'El implemento ya fue devuelto.');
    this.#db.prepare('UPDATE loans SET returned_at = ? WHERE id = ?').run(this.#now(), loanId);
    return { ok: true, loan: this.#find(loanId)! };
  }

  #openLoan(column: 'item_id' | 'member_id', id: number): boolean {
    return this.#db.prepare(`SELECT 1 FROM loans WHERE ${OPEN} AND ${column} = ?`).get(id) !== undefined;
  }

  #find(id: number): Loan | undefined {
    const row = this.#db.prepare('SELECT * FROM loans WHERE id = ?').get(id) as LoanRow | undefined;
    return row && { id: row.id, itemId: row.item_id, memberId: row.member_id, lentAt: row.lent_at, returnedAt: row.returned_at };
  }
}

// La regla "un préstamo está abierto", en un solo lugar.
const OPEN = 'returned_at IS NULL';

function fail(code: LoanError['code'], message: string): LoanResult {
  return { ok: false, error: { code, message } };
}

// La capa HTTP solo traduce: lee la solicitud, llama al módulo y elige el
// código HTTP.
const STATUS: Record<LoanError['code'], number> = {
  ITEM_NOT_FOUND: 404,
  LOAN_NOT_FOUND: 404,
  ITEM_LENT: 409,
  MEMBER_HAS_LOAN: 409,
  ALREADY_RETURNED: 409,
};

function respond(result: LoanResult): Response {
  return result.ok
    ? { status: 200, body: { loan: result.loan } }
    : { status: STATUS[result.error.code], body: { error: result.error } };
}

export function handleDeep(loans: Loans, req: Request): Response {
  if (req.method === 'GET' && req.path === '/items') return { status: 200, body: { items: loans.items() } };
  if (req.method === 'POST' && req.path === '/loans') {
    return respond(loans.lend(Number(req.body?.itemId), Number(req.body?.memberId)));
  }
  const match = /^\/loans\/(\d+)\/return$/.exec(req.path);
  if (req.method === 'POST' && match) return respond(loans.giveBack(Number(match[1])));
  return { status: 404, body: { error: { code: 'NOT_FOUND', message: `No existe la ruta ${req.path}.` } } };
}
loans/schema.tsDescargar loans/schema.ts
import { DatabaseSync } from 'node:sqlite';

// Préstamo de implementos: en la administración del complejo se prestan
// pelotas y redes a quien tiene una reserva. Las dos versiones del ejemplo
// (shallow.ts y deep.ts) usan este mismo esquema y los mismos datos.
export function openLoansDatabase(): DatabaseSync {
  const db = new DatabaseSync(':memory:');
  db.exec(`
    CREATE TABLE items (
      id   INTEGER PRIMARY KEY,
      name TEXT NOT NULL
    );
    CREATE TABLE loans (
      id          INTEGER PRIMARY KEY,
      item_id     INTEGER NOT NULL REFERENCES items (id),
      member_id   INTEGER NOT NULL,
      lent_at     TEXT NOT NULL,
      returned_at TEXT
    );
  `);
  const insert = db.prepare('INSERT INTO items (name) VALUES (?)');
  for (const name of ['Pelota de fulbito', 'Pelota de vóley', 'Red de vóley']) insert.run(name);
  return db;
}

// Lo que ve quien llama: una solicitud y una respuesta, sin servidor HTTP de
// por medio. Alcanza para comparar las dos versiones con el mismo guion.
export type Request = { method: 'GET' | 'POST'; path: string; body?: Record<string, unknown> };
export type Response = { status: number; body: unknown };

// "Ahora", inyectable como en Kallpa: las pruebas fijan la hora.
export type Now = () => string;
loans/shallow.tsDescargar loans/shallow.ts
import type { DatabaseSync } from 'node:sqlite';
import type { Now, Request, Response } from './schema.ts';

// Versión superficial. Funciona y pasa sus pruebas, pero el diseño reparte el
// conocimiento: las columnas de la tabla, la regla "un préstamo está abierto
// si no tiene returned_at" y los códigos HTTP aparecen en varios lugares.

export type ItemRow = { id: number; name: string };

export type LoanRow = {
  id: number;
  item_id: number;
  member_id: number;
  lent_at: string;
  returned_at: string | null;
};

export class LoanRepository {
  #db: DatabaseSync;

  constructor(db: DatabaseSync) {
    this.#db = db;
  }

  listItems(): ItemRow[] {
    return this.#db.prepare('SELECT id, name FROM items ORDER BY id').all() as ItemRow[];
  }

  findItem(id: number): ItemRow | undefined {
    return this.#db.prepare('SELECT id, name FROM items WHERE id = ?').get(id) as ItemRow | undefined;
  }

  findLoan(id: number): LoanRow | undefined {
    return this.#db.prepare('SELECT * FROM loans WHERE id = ?').get(id) as LoanRow | undefined;
  }

  // Todos los préstamos del implemento, también los ya devueltos.
  loansForItem(itemId: number): LoanRow[] {
    return this.#db.prepare('SELECT * FROM loans WHERE item_id = ?').all(itemId) as LoanRow[];
  }

  loansForMember(memberId: number): LoanRow[] {
    return this.#db.prepare('SELECT * FROM loans WHERE member_id = ?').all(memberId) as LoanRow[];
  }

  insert(itemId: number, memberId: number, lentAt: string): number {
    const result = this.#db
      .prepare('INSERT INTO loans (item_id, member_id, lent_at) VALUES (?, ?, ?)')
      .run(itemId, memberId, lentAt);
    return Number(result.lastInsertRowid);
  }

  setReturnedAt(id: number, returnedAt: string): void {
    this.#db.prepare('UPDATE loans SET returned_at = ? WHERE id = ?').run(returnedAt, id);
  }
}

// La "capa de servicios": cada método reenvía la llamada al repositorio.
export class LoanService {
  #repo: LoanRepository;

  constructor(db: DatabaseSync) {
    this.#repo = new LoanRepository(db);
  }

  listItems(): ItemRow[] {
    return this.#repo.listItems();
  }

  findItem(id: number): ItemRow | undefined {
    return this.#repo.findItem(id);
  }

  findLoan(id: number): LoanRow | undefined {
    return this.#repo.findLoan(id);
  }

  loansForItem(itemId: number): LoanRow[] {
    return this.#repo.loansForItem(itemId);
  }

  loansForMember(memberId: number): LoanRow[] {
    return this.#repo.loansForMember(memberId);
  }

  saveLoan(itemId: number, memberId: number, lentAt: string): number {
    return this.#repo.insert(itemId, memberId, lentAt);
  }

  markReturned(id: number, returnedAt: string): void {
    this.#repo.setReturnedAt(id, returnedAt);
  }
}

function toLoanJson(row: LoanRow) {
  return { id: row.id, itemId: row.item_id, memberId: row.member_id, lentAt: row.lent_at, returnedAt: row.returned_at };
}

function error(status: number, code: string, message: string): Response {
  return { status, body: { error: { code, message } } };
}

// Los "handlers" deciden todo: qué préstamo está abierto, qué se puede prestar
// y qué se guarda.
export function handleShallow(service: LoanService, now: Now, req: Request): Response {
  if (req.method === 'GET' && req.path === '/items') {
    const items = service.listItems().map((item) => ({
      ...item,
      available: !service.loansForItem(item.id).some((l) => l.returned_at === null),
    }));
    return { status: 200, body: { items } };
  }
  if (req.method === 'POST' && req.path === '/loans') {
    const itemId = Number(req.body?.itemId);
    const memberId = Number(req.body?.memberId);
    const item = service.findItem(itemId);
    if (!item) return error(404, 'ITEM_NOT_FOUND', `No existe el implemento ${itemId}.`);
    if (service.loansForItem(itemId).some((l) => l.returned_at === null)) {
      return error(409, 'ITEM_LENT', 'El implemento está prestado.');
    }
    if (service.loansForMember(memberId).some((l) => !l.returned_at)) {
      return error(409, 'MEMBER_HAS_LOAN', 'Primero hay que devolver el implemento anterior.');
    }
    const id = service.saveLoan(itemId, memberId, now());
    return { status: 200, body: { loan: toLoanJson(service.findLoan(id)!) } };
  }
  const match = /^\/loans\/(\d+)\/return$/.exec(req.path);
  if (req.method === 'POST' && match) {
    const loan = service.findLoan(Number(match[1]));
    if (!loan) return error(404, 'LOAN_NOT_FOUND', `No existe el préstamo ${match[1]}.`);
    if (loan.returned_at !== null) return error(409, 'ALREADY_RETURNED', 'El implemento ya fue devuelto.');
    service.markReturned(loan.id, now());
    return { status: 200, body: { loan: toLoanJson(service.findLoan(loan.id)!) } };
  }
  return error(404, 'NOT_FOUND', `No existe la ruta ${req.path}.`);
}

Pruebas

Comprueban lo que afirman los apuntes; se ejecutan con make test.

closing-hour.test.tsDescargar closing-hour.test.ts
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';

// Amplificación de cambios, medida: en una copia de Kallpa se cambia el cierre
// de las 22:00 a las 23:00 en la única línea que lo define, y se corren las
// pruebas. El código entero se entera del cambio; fallan las dos pruebas que
// tienen el horario escrito a mano (15 horas libres, y 21:00 + 2 horas como
// "después del cierre").
test('cerrar a las 23:00 cambia una constante y rompe dos pruebas', () => {
  const copy = mkdtempSync(join(tmpdir(), 'kallpa-23-'));
  try {
    cpSync(new URL('../../kallpa', import.meta.url).pathname, copy, { recursive: true });
    const file = join(copy, 'src/domain/booking.ts');
    const source = readFileSync(file, 'utf8');
    assert.match(source, /export const CLOSING_HOUR = 22;/);
    writeFileSync(file, source.replace('export const CLOSING_HOUR = 22;', 'export const CLOSING_HOUR = 23;'));

    // Sin NODE_TEST_CONTEXT: si no, el node --test de adentro le habla al de
    // afuera por otro canal y no escribe nada en stdout.
    const env = { ...process.env };
    delete env.NODE_TEST_CONTEXT;
    const run = spawnSync('node', ['--test', '--test-reporter=tap', 'test/**/*.test.ts'], { cwd: copy, env, encoding: 'utf8' });
    assert.match(run.stdout, /# fail 2\n/);
    assert.match(run.stdout, /not ok \d+ - rechaza una reserva que termina después del cierre/);
    assert.match(run.stdout, /not ok \d+ - una cancha sin reservas está libre todo el día/);
    assert.match(run.stdout, /16 !== 15/);
  } finally {
    rmSync(copy, { recursive: true });
  }
});
concepts.test.tsDescargar concepts.test.ts
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from 'node:test';
import { CourtApp } from './concepts/app.ts';
import { Deadline } from './concepts/deadline.ts';
import { Reservation } from './concepts/reservation.ts';

// Cada principio operacional, escrito como una prueba.
describe('principios operacionales', () => {
  test('Reserva: si reservo de 19 a 21, a esa hora nadie más puede reservar; si cancelo, queda libre', () => {
    const r = new Reservation<string, string>();
    const mine = r.reserve('Rosa', 'Fulbito 1', { day: '2026-03-14', from: 19, to: 21 });
    assert.ok(mine.ok);
    assert.equal(r.holder('Fulbito 1', '2026-03-14', 20), 'Rosa');
    assert.deepEqual(r.reserve('Luis', 'Fulbito 1', { day: '2026-03-14', from: 20, to: 22 }), { ok: false });
    r.cancel(mine.id);
    assert.equal(r.reserve('Luis', 'Fulbito 1', { day: '2026-03-14', from: 20, to: 22 }).ok, true);
  });

  test('Reserva es genérica: sirve igual para la parrilla del club', () => {
    const grill = new Reservation<'parrilla', number>();
    assert.equal(grill.reserve(7, 'parrilla', { day: '2026-03-15', from: 12, to: 16 }).ok, true);
    assert.equal(grill.holder('parrilla', '2026-03-15', 13), 7);
  });

  test('Plazo: con 3 horas de aviso, a las 15:00 se deshace algo de las 18:00; a las 15:01, ya no', () => {
    const d = new Deadline<string>(3);
    d.register('reserva', 18 * 60);
    assert.equal(d.stillOpen('reserva', 15 * 60), true);
    assert.equal(d.stillOpen('reserva', 15 * 60 + 1), false);
  });
});

describe('la aplicación: tres conceptos y sus sincronizaciones', () => {
  test('al reservar se avisa; al cancelar a tiempo se libera la cancha', () => {
    const app = new CourtApp();
    const id = app.book('987654321', 1, { day: '2026-03-14', from: 19, to: 21 });
    assert.equal(typeof id, 'number');
    assert.deepEqual(app.notifications.sent, [
      { to: '987654321', text: 'Reserva confirmada: cancha 1, 2026-03-14 de 19:00 a 21:00.' },
    ]);
    assert.equal(app.book('912345678', 1, { day: '2026-03-14', from: 20, to: 21 }), 'taken');
    assert.equal(app.cancel(id as number, { day: '2026-03-14', hour: 16, minute: 30 }), 'too-late');
    assert.equal(app.cancel(id as number, { day: '2026-03-14', hour: 16, minute: 0 }), 'cancelled');
    assert.equal(app.reservations.holder(1, '2026-03-14', 20), undefined);
  });

  test('ningún concepto importa a otro', () => {
    for (const name of ['reservation', 'deadline', 'notification']) {
      const source = readFileSync(new URL(`./concepts/${name}.ts`, import.meta.url), 'utf8');
      assert.doesNotMatch(source, /^import /m, `${name}.ts no debería importar nada`);
    }
  });
});
kwic.test.tsDescargar kwic.test.ts
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { kwic as kwicFlowchart } from './kwic/flowchart.ts';
import { ArrayLines, kwic as kwicHiding, PackedLines } from './kwic/hiding.ts';

const TEXT = `Kallpa reserva canchas
canchas de vóley
reserva confirmada por WhatsApp`;

describe('índice KWIC', () => {
  test('lista cada corrimiento circular en orden alfabético', () => {
    assert.equal(
      kwicFlowchart(TEXT),
      [
        'canchas de vóley',
        'canchas Kallpa reserva',
        'confirmada por WhatsApp reserva',
        'de vóley canchas',
        'Kallpa reserva canchas',
        'por WhatsApp reserva confirmada',
        'reserva canchas Kallpa',
        'reserva confirmada por WhatsApp',
        'vóley canchas de',
        'WhatsApp reserva confirmada por',
      ].join('\n'),
    );
  });

  test('las dos divisiones en módulos dan exactamente la misma salida', () => {
    assert.equal(kwicHiding(TEXT), kwicFlowchart(TEXT));
  });

  test('cambiar cómo se guardan las líneas no cambia la salida', () => {
    assert.equal(kwicHiding(TEXT, new ArrayLines()), kwicHiding(TEXT, new PackedLines()));
  });
});
layers.test.tsDescargar layers.test.ts
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, test } from 'node:test';
import { edges, violations } from './layers.ts';

const KALLPA_SRC = new URL('../../kallpa/src', import.meta.url).pathname;

describe('dirección de las dependencias en Kallpa', () => {
  test('domain no importa nada de otra capa', () => {
    assert.deepEqual(edges(KALLPA_SRC).filter((e) => e.from === 'domain'), []);
  });

  test('ni store ni domain saben de HTTP', () => {
    const toHttp = edges(KALLPA_SRC).filter((e) => e.to === 'http' && (e.from === 'store' || e.from === 'domain'));
    assert.deepEqual(toHttp, []);
  });

  test('ninguna dependencia va hacia arriba', () => {
    assert.deepEqual(violations(edges(KALLPA_SRC)), []);
  });

  test('detecta un dominio que importa algo de http', () => {
    const src = mkdtempSync(join(tmpdir(), 'capas-'));
    try {
      mkdirSync(join(src, 'domain'));
      mkdirSync(join(src, 'http'));
      writeFileSync(join(src, 'http', 'json.ts'), 'export class HttpError extends Error {}\n');
      writeFileSync(
        join(src, 'domain', 'booking.ts'),
        "import { HttpError } from '../http/json.ts';\nexport const fail = () => new HttpError();\n",
      );
      const bad = violations(edges(src));
      assert.equal(bad.length, 1);
      assert.equal(bad[0].file, join('domain', 'booking.ts'));
      assert.equal(bad[0].typeOnly, false);
    } finally {
      rmSync(src, { recursive: true });
    }
  });
});
loans.test.tsDescargar loans.test.ts
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { handleDeep, Loans } from './loans/deep.ts';
import { openLoansDatabase, type Request, type Response } from './loans/schema.ts';
import { handleShallow, LoanService } from './loans/shallow.ts';

// Un guion de solicitudes que recorre los casos felices y todos los errores.
const SCRIPT: Request[] = [
  { method: 'GET', path: '/items' },
  { method: 'POST', path: '/loans', body: { itemId: 1, memberId: 7 } },
  { method: 'POST', path: '/loans', body: { itemId: 1, memberId: 8 } }, // ITEM_LENT
  { method: 'POST', path: '/loans', body: { itemId: 2, memberId: 7 } }, // MEMBER_HAS_LOAN
  { method: 'POST', path: '/loans', body: { itemId: 9, memberId: 8 } }, // ITEM_NOT_FOUND
  { method: 'GET', path: '/items' },
  { method: 'POST', path: '/loans/1/return' },
  { method: 'POST', path: '/loans/1/return' }, // ALREADY_RETURNED
  { method: 'POST', path: '/loans/5/return' }, // LOAN_NOT_FOUND
  { method: 'POST', path: '/loans', body: { itemId: 1, memberId: 8 } },
  { method: 'GET', path: '/items' },
  { method: 'GET', path: '/prestamos' }, // NOT_FOUND
];

// Un reloj que avanza un minuto en cada llamada, igual para las dos versiones.
function minuteClock(): () => string {
  let minute = 0;
  return () => new Date(Date.UTC(2026, 2, 14, 15, minute++)).toISOString();
}

function runShallow(): Response[] {
  const service = new LoanService(openLoansDatabase());
  const now = minuteClock();
  return SCRIPT.map((req) => handleShallow(service, now, req));
}

function runDeep(): Response[] {
  const loans = new Loans(openLoansDatabase(), minuteClock());
  return SCRIPT.map((req) => handleDeep(loans, req));
}

describe('préstamo de implementos', () => {
  test('las dos versiones responden lo mismo a todo el guion', () => {
    assert.deepEqual(runDeep(), runShallow());
  });

  test('el guion pasa por cada código de error', () => {
    const codes = runShallow().map((r) => (r.body as { error?: { code: string } }).error?.code ?? r.status);
    assert.deepEqual(codes, [
      200, 200, 'ITEM_LENT', 'MEMBER_HAS_LOAN', 'ITEM_NOT_FOUND', 200,
      200, 'ALREADY_RETURNED', 'LOAN_NOT_FOUND', 200, 200, 'NOT_FOUND',
    ]);
  });

  test('un implemento prestado deja de estar disponible y vuelve al devolverlo', () => {
    const loans = new Loans(openLoansDatabase(), minuteClock());
    const lent = loans.lend(2, 7);
    assert.ok(lent.ok);
    assert.equal(loans.items().find((i) => i.id === 2)?.available, false);
    loans.giveBack(lent.loan.id);
    assert.equal(loans.items().find((i) => i.id === 2)?.available, true);
  });

  test('los errores del módulo no saben de HTTP', () => {
    const loans = new Loans(openLoansDatabase(), minuteClock());
    assert.deepEqual(loans.giveBack(42), {
      ok: false,
      error: { code: 'LOAN_NOT_FOUND', message: 'No existe el préstamo 42.' },
    });
  });
});
pass-through.test.tsDescargar pass-through.test.ts
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from 'node:test';
import { analyze } from './pass-through.ts';

const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8');

describe('métodos que solo reenvían la llamada', () => {
  test('todos los métodos de LoanService reenvían al repositorio', () => {
    const service = analyze(read('./loans/shallow.ts')).find((c) => c.name === 'LoanService')!;
    assert.equal(service.methods.length, 7);
    assert.ok(service.methods.every((m) => m.forwardsTo?.startsWith('this.#repo.')));
  });

  test('Loans, la versión profunda, no tiene ninguno', () => {
    const [loans] = analyze(read('./loans/deep.ts'));
    assert.equal(loans.name, 'Loans');
    assert.deepEqual(
      loans.methods.map((m) => [m.name, m.forwardsTo]),
      [['items', null], ['lend', null], ['giveBack', null]],
    );
  });

  test('distingue si cambia los argumentos', () => {
    const [c] = analyze(`class A {
      #b = { f: (x: number, y: number) => x + y };
      same(x: number, y: number) { return this.#b.f(x, y); }
      swapped(x: number, y: number) { return this.#b.f(y, x); }
      more(x: number) { const y = 2; return this.#b.f(x, y); }
      #hidden(x: number) { return this.#b.f(x, x); }
    }`);
    assert.deepEqual(
      c.methods.map((m) => [m.name, m.forwardsTo !== null, m.sameArguments]),
      [['same', true, true], ['swapped', true, false], ['more', false, false]],
    );
  });
});

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 5: complejidad, módulos profundos y conceptos. Se usa dentro del
# contenedor del curso.
#
#   make           copia Kallpa en build/kallpa, para medirla sin tocar la
#                  original
#   make test      revisa los tipos (tsc), corre ESLint, las pruebas
#                  (*.test.ts), que comprueban lo que dice el apunte, y el
#                  refactoring de ejemplo (make refactor)
#   make medir     los números del paso 1 del laboratorio, sobre build/kallpa
#   make capas     las dependencias entre las capas de Kallpa (layers.ts)
#   make refactor  aplica refactor-fecha.diff a una copia de Kallpa en
#                  build/refactor y corre su make check
#   make clean     borra build/
#
# Los ejemplos leen la Kallpa del curso (../../kallpa) sin modificarla.

KALLPA ?= ../../kallpa
GIT = git -c user.name=Kallpa -c user.email=kallpa@example.com -c init.defaultBranch=main

.PHONY: all test medir capas refactor clean

all: build/kallpa

build/kallpa:
	mkdir -p build
	cp -r $(KALLPA) $@
	rm -f $@/data/*.db $@/data/*.db-*

test:
	tsc -p .
	eslint .
	node --test "*.test.ts"
	$(MAKE) --no-print-directory refactor

medir: build/kallpa
	cd build/kallpa && wc -l src/http/handlers.ts src/services/*.ts
	cd build/kallpa && grep -n "from " src/http/handlers.ts

capas:
	node layers.ts $(KALLPA)/src

# Un refactoring pequeño: la validación de ?date= estaba copiada en dos
# handlers. Las pruebas de Kallpa no cambian y tienen que seguir pasando.
refactor:
	rm -rf build/refactor
	mkdir -p build
	cp -r $(KALLPA) build/refactor
	rm -f build/refactor/data/*.db build/refactor/data/*.db-*
	cd build/refactor && $(GIT) init -q && $(GIT) add -A && $(GIT) commit -qm "Kallpa publicada"
	cd build/refactor && $(GIT) apply ../../refactor-fecha.diff
	cd build/refactor && $(GIT) diff --stat
	$(MAKE) --no-print-directory -C build/refactor check
	cd build/refactor && test -z "$$($(GIT) status --porcelain -- test)" && echo "test/ sin cambios"

clean:
	rm -rf build
package.jsonDescargar package.json
{
  "name": "week-05",
  "private": true,
  "type": "module"
}
tsconfig.jsonDescargar tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "lib": [
      "es2024"
    ],
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "types": [
      "node"
    ],
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  },
  "include": [
    "*.ts",
    "loans",
    "kwic",
    "concepts"
  ],
  "exclude": [
    "build",
    "solution"
  ]
}