Parte VII · Práctica

22. Ejercicios, retos y proyecto integrador

Leer sobre señales, inyección de dependencias o Unit of Work produce la ilusión del conocimiento: todo parece evidente mientras lo lees y se evapora en cuanto tienes el editor vacío delante. Este capítulo existe para romper esa ilusión. Son 78 ejercicios con solución comentada, ocho casos de depuración con fallos reales, seis ejercicios de diseño de nivel entrevista y la especificación completa de un proyecto integrador dividido en diez hitos evaluables. No hay teoría nueva: hay trabajo.

COREANGULARNESTJSMIKROORM Tiempo de trabajo: 40–80 h Prerrequisitos: capítulos 1 a 21

22.1 Cómo usar este capítulo

Un ejercicio resuelto leyendo la solución vale aproximadamente cero. Un ejercicio resuelto tras veinte minutos de frustración honesta vale por diez capítulos leídos. La diferencia no es motivacional: es neurológica. Recuperar información con esfuerzo (retrieval practice) consolida la memoria a largo plazo; reconocerla al leerla, no.

22.1.1 El método: la regla de los veinte minutos

Para cada ejercicio, sigue este ciclo sin saltarte ningún paso:

  1. Lee el enunciado y la firma esperada. Antes de escribir nada, reformula con tus palabras qué se te pide y qué casos límite existen. Si no puedes reformularlo, no has entendido el enunciado.
  2. Escribe el código en un editor real, no en tu cabeza y no en un comentario. Un proyecto con TypeScript en modo estricto y un ejecutor de tests. El apartado 22.1.3 te da el esqueleto en dos minutos.
  3. Intenta veinte minutos. Cronómetro de verdad. Está permitido consultar la documentación oficial de Angular, NestJS, MikroORM o MDN; está prohibido buscar la solución del ejercicio o pedírsela a un asistente. La documentación entrena una habilidad que usarás siempre; copiar la solución no entrena nada.
  4. Comprueba que compila (tsc --noEmit sin errores y sin ningún any ni @ts-ignore añadido por desesperación) y que pasa los casos de prueba del enunciado.
  5. Solo entonces abre la solución. Compárala con la tuya. Que la tuya sea distinta no significa que esté mal: busca diferencias en el manejo de errores, en los casos límite y en el tipado, que es donde se concentra la distancia entre un junior y un senior.
  6. Si te has atascado, abre la solución, léela, ciérrala, borra tu intento y vuelve a escribirla de memoria al día siguiente. Este último paso es el que convierte lectura en habilidad.
El error de estudio más común Leer los 78 ejercicios de un tirón con las soluciones abiertas y concluir «los entiendo todos». Es exactamente lo que hace el 90 % de los lectores y explica por qué el 90 % de los candidatos se queda en blanco en una entrevista técnica. Diez ejercicios peleados valen más que setenta y ocho leídos.

22.1.2 Criterios de «hecho»

En un equipo profesional, «funciona en mi máquina» no es un criterio. Estos son los criterios que aplico para dar por cerrado cualquier ejercicio de este capítulo, y son los mismos que aplicarías en una revisión de código:

CriterioQué significa exactamenteCómo se comprueba
Compila en estrictoSin errores con strict: true, noUncheckedIndexedAccess y sin any explícito ni implícito. npx tsc --noEmit
Pasa los testsTodos los casos del enunciado, incluidos los límite (vacío, nulo, error). npx vitest run
Maneja el errorToda ruta de fallo está contemplada: no hay catch vacíos ni promesas sin capturar.Test que fuerza el fallo
Sin recursos colgandoTimers limpiados, suscripciones canceladas, conexiones cerradas, AbortController disparado.El proceso de test termina solo
Nombres honestosEl nombre dice qué hace, no cómo está implementado. Nada de data, temp, handleIt.Lectura en voz alta
ExplicablePuedes justificar cada decisión en dos frases, incluidas las que descartaste. Explícalo a alguien (o a un pato de goma)

22.1.3 El banco de pruebas mínimo

Los ejercicios de TypeScript, y buena parte de los de NestJS y MikroORM, se resuelven en un proyecto mínimo que puedes montar en dos minutos y reutilizar durante todo el capítulo.

terminal
mkdir katas-fullstack && cd katas-fullstack
npm init -y
npm i -D typescript vitest @types/node
mkdir -p src
npx vitest            # modo watch: reejecuta al guardar
package.json
{
  "name": "katas-fullstack",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "typecheck": "tsc --noEmit"
  }
}
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2023", "DOM"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src"]
}
src/ts-01.test.ts · así se escribe cada test
import { describe, expect, it } from 'vitest';
import { esTarea } from './ts-01-type-guards.js';

describe('TS-01 · esTarea', () => {
  it('acepta una tarea válida', () => {
    expect(esTarea({ id: 1, titulo: 'Escribir tests', prioridad: 'alta', etiquetas: [] })).toBe(true);
  });

  it('rechaza un objeto con prioridad desconocida', () => {
    expect(esTarea({ id: 1, titulo: 'x', prioridad: 'urgentísima', etiquetas: [] })).toBe(false);
  });
});
Para los retos de Angular y NestJS Necesitarás dos proyectos reales: ng new taskflow-web --standalone --style=scss y nest new taskflow-api. No hagas los ejercicios de Angular en un playground online: la mitad del aprendizaje está en la configuración de proveedores, en el enrutado y en el build.

22.1.4 Numeración y autoevaluación

Cada ejercicio lleva un código estable que puedes usar para anotar tu progreso en una hoja de cálculo o en los mensajes de commit (feat(katas): resuelve TS-07):

PrefijoBloqueCantidadTiempo estimado
TS-nnKatas de TypeScript (22.2)126–10 h
NG-nnRetos de Angular (22.3)1412–18 h
NEST-nnRetos de NestJS (22.4)1412–18 h
ORM-nnRetos de MikroORM (22.5)1410–16 h
SQL-nnRetos de SQL (22.6)104–6 h
DEBUG-nnCasos de depuración (22.7)84–6 h
DIS-nnEjercicios de diseño (22.8)64–8 h
TF-nnHitos del proyecto integrador (22.9)1060–100 h

Para autoevaluarte, puntúa cada ejercicio en esta escala de cuatro niveles. Si al terminar el capítulo tienes más de un 70 % en nivel 3 o 4, estás en condiciones de defender el stack en una entrevista senior; los ejercicios en nivel 0 o 1 son tu lista de repaso para dentro de una semana.

NivelDescripción
0 · NadaNo supe ni por dónde empezar. Vuelve al capítulo teórico correspondiente.
1 · Con ayudaLo resolví mirando la solución. Repítelo de memoria en 48 horas.
2 · Con esfuerzoLo resolví solo, pero tardé mucho o mi versión tiene fallos de casos límite.
3 · SólidoLo resolví solo y bien. Mi versión es equivalente a la propuesta.
4 · DominadoLo resolví solo, bien, y puedo explicar por qué es correcto, qué alternativas hay y cuándo elegir cada una.

22.2 Katas de TypeScript

Doce katas sobre el sistema de tipos y sobre la asincronía. No son acertijos: cada una es un patrón que aparece literalmente en el código de Angular, NestJS o MikroORM. Si dominas estas doce, el resto del stack deja de parecer magia.

TS-01 · Nivel 1 · type guards

Enunciado. Escribe una función de guarda que valide en tiempo de ejecución que un valor desconocido (el que llega de fetch, de JSON.parse o de una cola) es una Tarea. No puedes usar as en ningún punto ni bibliotecas externas. Debe validar el tipo de cada campo, la pertenencia de prioridad a la unión y el contenido del array.

// firma esperada
export type Prioridad = 'baja' | 'media' | 'alta';
export interface Tarea {
  id: number;                 // entero
  titulo: string;             // no vacío
  prioridad: Prioridad;
  etiquetas: string[];        // todos los elementos, string
  vence?: string;             // opcional, ISO 8601
}
export function esTarea(x: unknown): x is Tarea;

Casos de prueba.

esTarea({ id: 1, titulo: 'a', prioridad: 'alta', etiquetas: [] })            // true
esTarea({ id: 1, titulo: 'a', prioridad: 'alta', etiquetas: ['x'], vence: '2026-01-01' }) // true
esTarea({ id: 1.5, titulo: 'a', prioridad: 'alta', etiquetas: [] })          // false (no entero)
esTarea({ id: 1, titulo: '', prioridad: 'alta', etiquetas: [] })             // false (vacío)
esTarea({ id: 1, titulo: 'a', prioridad: 'urgente', etiquetas: [] })         // false
esTarea({ id: 1, titulo: 'a', prioridad: 'alta', etiquetas: ['x', 2] })      // false
esTarea(null)                                                                // false
esTarea([])                                                                  // false
Solución comentada · TS-01
src/ts-01-type-guards.ts
export const PRIORIDADES = ['baja', 'media', 'alta'] as const;
export type Prioridad = (typeof PRIORIDADES)[number];   // 'baja' | 'media' | 'alta'

export interface Tarea {
  id: number;
  titulo: string;
  prioridad: Prioridad;
  etiquetas: string[];
  vence?: string;
}

// Guarda auxiliar: convierte unknown en un diccionario indexable SIN usar `as`.
function esObjeto(x: unknown): x is Record<string, unknown> {
  return typeof x === 'object' && x !== null && !Array.isArray(x);
}

function esPrioridad(x: unknown): x is Prioridad {
  // El ensanchamiento a readonly string[] es necesario porque includes() de una
  // tupla `as const` solo acepta miembros de la propia unión.
  return typeof x === 'string' && (PRIORIDADES as readonly string[]).includes(x);
}

function esArrayDeStrings(x: unknown): x is string[] {
  return Array.isArray(x) && x.every((e) => typeof e === 'string');
}

export function esTarea(x: unknown): x is Tarea {
  if (!esObjeto(x)) return false;                       // descarta null, arrays y primitivos
  if (typeof x['id'] !== 'number' || !Number.isInteger(x['id'])) return false;
  if (typeof x['titulo'] !== 'string' || x['titulo'].trim() === '') return false;
  if (!esPrioridad(x['prioridad'])) return false;
  if (!esArrayDeStrings(x['etiquetas'])) return false;
  if (x['vence'] !== undefined && typeof x['vence'] !== 'string') return false;
  return true;
}

Por qué así. Tres detalles marcan la diferencia. Primero, typeof null === 'object' es un fallo histórico de JavaScript: sin la comprobación x !== null la guarda acepta null. Segundo, un array también es 'object', y sin !Array.isArray(x) la guarda aceptaría []. Tercero, el campo opcional se valida contra undefined, no con 'vence' in x: un objeto puede traer vence: undefined explícito y sigue siendo válido.

Trampa del predicado. x is Tarea es una promesa que le haces al compilador: si la implementación miente, TypeScript no te avisará nunca. Por eso las guardas escritas a mano son el punto más peligroso de una base de código tipada, y por eso en producción conviene delegar en un validador con esquema (Zod, class-validator, TypeBox) que genere el tipo y la validación de una sola fuente. Ver capítulo 10.

TS-02 · Nivel 1 · genéricos con restricciones

Enunciado. Implementa dos utilidades de colección con tipado exacto: indexarPor, que construye un Map a partir de una clave que debe existir en el elemento, y agruparPor, que agrupa por una clave calculada. El compilador debe rechazar claves inexistentes y el tipo del valor del mapa debe deducirse solo.

// firmas esperadas
export function indexarPor<T, K extends keyof T>(items: readonly T[], clave: K): Map<T[K], T>;
export function agruparPor<T, K extends PropertyKey>(
  items: readonly T[],
  clave: (item: T) => K,
): Record<K, T[]>;

Casos de prueba.

const tareas = [
  { id: 1, titulo: 'a', prioridad: 'alta' as const },
  { id: 2, titulo: 'b', prioridad: 'baja' as const },
  { id: 3, titulo: 'c', prioridad: 'alta' as const },
];

const porId = indexarPor(tareas, 'id');       // Map<number, {...}>
porId.get(2)?.titulo;                         // 'b'
indexarPor(tareas, 'inexistente');            // ERROR de compilación

const porPrioridad = agruparPor(tareas, (t) => t.prioridad);
porPrioridad.alta.length;                     // 2
Solución comentada · TS-02
src/ts-02-genericos.ts
export function indexarPor<T, K extends keyof T>(items: readonly T[], clave: K): Map<T[K], T> {
  const mapa = new Map<T[K], T>();
  for (const item of items) mapa.set(item[clave], item);
  return mapa;
}

export function agruparPor<T, K extends PropertyKey>(
  items: readonly T[],
  clave: (item: T) => K,
): Record<K, T[]> {
  // Partial + aserción controlada: el objeto se llena por completo antes de devolverse,
  // pero el compilador no puede saberlo porque K es un tipo abierto.
  const salida: Partial<Record<K, T[]>> = {};
  for (const item of items) {
    const k = clave(item);
    (salida[k] ??= []).push(item);            // ??= crea el array solo la primera vez
  }
  return salida as Record<K, T[]>;
}

La clave del ejercicio es K extends keyof T. Sin la restricción, clave sería string y item[clave] no compilaría; con ella, TypeScript propaga el tipo concreto del literal 'id' y deduce T[K] = number automáticamente. Es exactamente el mecanismo que usan Pick, Omit y los selects tipados de MikroORM.

Sobre el as final. Es uno de los pocos casos donde una aserción es legítima: el invariante («al terminar el bucle todas las claves producidas están presentes») es cierto pero no expresable en el sistema de tipos. Lo correcto es aislarlo en una línea y documentarlo, no esparcir as por toda la función. Si quieres evitarlo del todo, devuelve un Map<K, T[]>: es más honesto y además admite claves que no son cadenas.

TS-03 · Nivel 1 · utility types derivados

Enunciado. Dada la entidad Usuario, define sin repetir ni un solo campo los cuatro tipos que necesita una API: el DTO de creación, el de actualización parcial, la vista pública y un tipo AlMenosUno que obligue a enviar como mínimo una propiedad. Si mañana se añade un campo a Usuario, los cuatro deben actualizarse solos.

interface Usuario {
  id: string;
  email: string;
  password: string;
  nombre: string;
  rol: 'admin' | 'miembro';
  creadoEn: Date;
}

// A definir: CrearUsuarioDto, ActualizarUsuarioDto, UsuarioPublico, AlMenosUno<T>

Casos de prueba (comprobaciones de tipo, no de runtime).

const a: CrearUsuarioDto = { email: 'a@b.c', password: 'x', nombre: 'Ana', rol: 'miembro' }; // OK
const b: CrearUsuarioDto = { email: 'a@b.c', password: 'x', nombre: 'Ana', rol: 'miembro', id: '1' }; // ERROR
const c: ActualizarUsuarioDto = { nombre: 'Ana' };        // OK
const d: ActualizarUsuarioDto = {};                       // ERROR: al menos una propiedad
const e: UsuarioPublico = { id: '1', email: 'a@b.c', nombre: 'Ana', rol: 'admin', creadoEn: new Date() }; // OK
// @ts-expect-error password no existe en la vista pública
e.password;
Solución comentada · TS-03
src/ts-03-utility-types.ts
export interface Usuario {
  id: string;
  email: string;
  password: string;
  nombre: string;
  rol: 'admin' | 'miembro';
  creadoEn: Date;
}

/** Lo que el cliente puede enviar al crear: ni id ni fecha, que los pone el servidor. */
export type CrearUsuarioDto = Omit<Usuario, 'id' | 'creadoEn'>;

/** Distribuye sobre cada clave: exige exactamente una obligatoria y el resto opcionales. */
export type AlMenosUno<T, K extends keyof T = keyof T> =
  K extends unknown ? Required<Pick<T, K>> & Partial<Omit<T, K>> : never;

/** Actualización parcial: nunca se cambian id, fecha ni contraseña por esta vía. */
export type ActualizarUsuarioDto = AlMenosUno<Omit<Usuario, 'id' | 'creadoEn' | 'password'>>;

/** Vista pública: la contraseña jamás sale del servidor. */
export type UsuarioPublico = Omit<Usuario, 'password'>;

/** Extra: el mismo tipo pero serializable a JSON (Date se convierte en string ISO). */
export type Serializado<T> = {
  [K in keyof T]: T[K] extends Date ? string : T[K] extends object ? Serializado<T[K]> : T[K];
};
export type UsuarioPublicoJson = Serializado<UsuarioPublico>;   // creadoEn: string

Cómo funciona AlMenosUno. Cuando el parámetro de un tipo condicional es un tipo genérico desnudo (K extends unknown ? ... : ...), TypeScript distribuye la condición sobre cada miembro de la unión. Con K = 'email' | 'nombre' | 'rol' el resultado es la unión de tres objetos: uno con email obligatorio, otro con nombre obligatorio y otro con rol obligatorio. El objeto vacío no encaja en ninguno, que es justo lo que queremos.

Por qué importa en producción. El caso d —un PATCH con cuerpo vacío— es un error real que suele llegar hasta la base de datos y ejecutar un UPDATE sin columnas o, peor, sobrescribir campos con undefined. Codificar la regla en el tipo la hace imposible en el cliente TypeScript; recuerda que en el servidor sigue siendo obligatorio validarla en runtime, porque los tipos se borran al compilar.

TS-04 · Nivel 2 · tipos condicionales con infer

Enunciado. Implementa cinco tipos de extracción. Todos deben resolverse sin any y funcionar con tipos anidados.

type Desempaqueta<T>      // Promise<Promise<number>> -> number ; number -> number
type ElementoDe<T>        // Tarea[] -> Tarea ; readonly string[] -> string
type RetornoAsync<F>      // (() => Promise<User>) -> User
type PrimerParametro<F>   // ((a: string, b: number) => void) -> string
type ClavesDeTipo<T, V>   // { a: string; b: number; c: string }, string -> 'a' | 'c'

Casos de prueba.

type T1 = Desempaqueta<Promise<Promise<number>>>;                 // number
type T2 = ElementoDe<readonly { id: number }[]>;                  // { id: number }
type T3 = RetornoAsync<() => Promise<{ ok: boolean }>>;           // { ok: boolean }
type T4 = PrimerParametro<(a: string, b: number) => void>;        // string
type T5 = ClavesDeTipo<{ a: string; b: number; c: string }, string>; // 'a' | 'c'
Solución comentada · TS-04
src/ts-04-infer.ts
/** Recursivo: desenvuelve promesas anidadas hasta llegar al valor real. */
export type Desempaqueta<T> = T extends Promise<infer U> ? Desempaqueta<U> : T;

/** readonly (infer U)[] cubre tanto T[] como ReadonlyArray<T>. */
export type ElementoDe<T> = T extends readonly (infer U)[] ? U : never;

/** Se usa unknown[] en los parámetros para aceptar cualquier aridad. */
export type RetornoAsync<F> =
  F extends (...args: never[]) => Promise<infer R> ? R : never;

/** El resto de parámetros se descarta con ...never[]. */
export type PrimerParametro<F> =
  F extends (primero: infer A, ...resto: never[]) => unknown ? A : never;

/** Tipo mapeado con `as`: las claves que no cumplen se reescriben a never y desaparecen. */
export type ClavesDeTipo<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K];
}[keyof T] extends never ? never : keyof {
  [K in keyof T as T[K] extends V ? K : never]: T[K];
};

/** Versión más legible y equivalente del anterior. */
export type ClavesConTipo<T, V> = {
  [K in keyof T]-?: T[K] extends V ? K : never;
}[keyof T];

Qué hace infer. Declara una variable de tipo dentro de la condición y la vincula por unificación estructural: «si T encaja con la forma Promise<algo>, llama U a ese algo». Es el equivalente en el mundo de los tipos al pattern matching de lenguajes funcionales.

Por qué never[] y no any[] en los parámetros. Los parámetros de función son contravariantes: una firma que acepta never[] es supertipo de cualquier otra, así que el patrón encaja con cualquier función sin necesidad de any. Con any[] también funciona, pero introduce any en tu base de código y desactiva reglas de ESLint razonables.

Dónde lo verás. ReturnType, Parameters, Awaited y InstanceType de la librería estándar están escritos exactamente así. Angular usa el mismo patrón para deducir el tipo de un Signal<T> y MikroORM para deducir el tipo de una relación poblada.

TS-05 · Nivel 2 · template literal types

Enunciado. Construye la API de tipos de un bus de eventos y de un lector de rutas anidadas. Necesitas Manejadores<T>, que a partir de un mapa de eventos genere las claves onXxx con su payload correcto, y RutasDe<T>, que produzca la unión de todas las rutas en notación de punto de un objeto anidado.

interface EventosTarea {
  creada: { id: number };
  completada: { id: number; en: Date };
  borrada: { id: number };
}
type H = Manejadores<EventosTarea>;
// { onCreada: (p: { id: number }) => void;
//   onCompletada: (p: { id: number; en: Date }) => void;
//   onBorrada: (p: { id: number }) => void }

interface Config { api: { url: string; timeout: number }; ui: { tema: 'claro' | 'oscuro' } }
type R = RutasDe<Config>;
// 'api' | 'api.url' | 'api.timeout' | 'ui' | 'ui.tema'

Extra. Añade ValorEn<T, R> que devuelva el tipo del valor situado en una ruta, de modo que ValorEn<Config, 'api.timeout'> sea number.

Solución comentada · TS-05
src/ts-05-template-literals.ts
/** Reasignación de claves con `as` + Capitalize (tipo intrínseco del compilador). */
export type Manejadores<T> = {
  [K in keyof T & string as `on${Capitalize<K>}`]: (payload: T[K]) => void;
};

type Hoja = string | number | boolean | Date | null | undefined | ((...a: never[]) => unknown);

/** Recorre el objeto y concatena la clave con las rutas de su valor. */
export type RutasDe<T> = T extends Hoja
  ? never
  : {
      [K in keyof T & string]: T[K] extends Hoja
        ? K
        : K | `${K}.${Extract<RutasDe<T[K]>, string>}`;
    }[keyof T & string];

/** Camino inverso: dada una ruta, obtener el tipo del valor. */
export type ValorEn<T, R extends string> =
  R extends `${infer Cabeza}.${infer Resto}`
    ? Cabeza extends keyof T
      ? ValorEn<T[Cabeza], Resto>
      : never
    : R extends keyof T
      ? T[R]
      : never;

/** Función real que aprovecha los tipos: acceso seguro por ruta. */
export function leerRuta<T extends object, R extends Extract<RutasDe<T>, string>>(
  objeto: T,
  ruta: R,
): ValorEn<T, R> {
  let actual: unknown = objeto;
  for (const parte of ruta.split('.')) {
    actual = (actual as Record<string, unknown>)[parte];
  }
  return actual as ValorEn<T, R>;
}

Los tres ingredientes. (1) keyof T & string filtra las claves numéricas y de tipo símbolo, que no se pueden interpolar en una plantilla. (2) La cláusula as de un tipo mapeado permite renombrar claves; si la expresión resulta never, la clave desaparece. (3) Capitalize, Uppercase, Lowercase y Uncapitalize son tipos intrínsecos implementados en el compilador, no en lib.d.ts.

Límites que debes conocer. RutasDe sobre un tipo con recursión infinita (interface Nodo { hijos: Nodo[] }) agota el limitador de profundidad del compilador y produce el error «Type instantiation is excessively deep». La solución habitual es añadir un contador de profundidad como parámetro de tipo. Además, este RutasDe trata los arrays como hojas; extenderlo a índices numéricos ('items.0.nombre') es un buen ejercicio adicional.

TS-06 · Nivel 2 · DeepReadonly

Enunciado. Implementa DeepReadonly<T>, que marque como readonly todas las propiedades de forma recursiva, respetando arrays, Map, Set, Date y funciones (que no deben transformarse). Añade la contrapartida en tiempo de ejecución: congelarProfundo.

interface Estado { usuario: { nombre: string; roles: string[] }; abierto: boolean }
const s: DeepReadonly<Estado> = { usuario: { nombre: 'Ana', roles: ['admin'] }, abierto: true };
s.abierto = false;              // ERROR
s.usuario.nombre = 'Luis';      // ERROR
s.usuario.roles.push('x');      // ERROR: push no existe en ReadonlyArray
Solución comentada · TS-06
src/ts-06-deep-readonly.ts
export type DeepReadonly<T> =
  T extends (...args: never[]) => unknown ? T                       // funciones: tal cual
  : T extends Date ? T                                              // valores «atómicos»
  : T extends ReadonlyMap<infer K, infer V> ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
  : T extends ReadonlySet<infer U> ? ReadonlySet<DeepReadonly<U>>
  : T extends readonly (infer U)[] ? ReadonlyArray<DeepReadonly<U>>
  : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;                                                              // primitivos

/** La versión de runtime: Object.freeze es superficial, esta no. */
export function congelarProfundo<T>(valor: T, vistos = new WeakSet<object>()): DeepReadonly<T> {
  if (valor === null || typeof valor !== 'object') return valor as DeepReadonly<T>;
  if (vistos.has(valor)) return valor as DeepReadonly<T>;           // corta ciclos
  vistos.add(valor);
  for (const clave of Reflect.ownKeys(valor)) {
    congelarProfundo((valor as Record<PropertyKey, unknown>)[clave], vistos);
  }
  return Object.freeze(valor) as DeepReadonly<T>;
}

El orden de las ramas importa. Las funciones y Date van primero porque también son object: si la rama genérica los capturase, obtendrías un objeto con las propiedades de Date en solo lectura pero sin sus métodos utilizables. Del mismo modo, ReadonlyMap se comprueba antes que object porque un Map con propiedades en solo lectura seguiría permitiendo set().

El WeakSet no es adorno. Sin él, un objeto con una referencia circular (a.hijo.padre === a, habitual en entidades de MikroORM con relaciones bidireccionales) provoca recursión infinita y desbordamiento de pila.

Aplicación real. Es el tipo que usarías para exponer el estado de un store de señales: el componente puede leer todo el árbol pero no mutarlo, y las mutaciones se canalizan por métodos que crean referencias nuevas, que es lo que la detección de cambios de Angular necesita.

TS-07 · Nivel 2 · Result<T, E>, errores sin excepciones

Enunciado. Implementa el tipo Result y su pequeña álgebra para modelar errores esperables (validación, «no encontrado», conflicto) sin usar excepciones. Debe incluir constructores, mapear, encadenar, desempaquetarO, un adaptador desde código que lanza y un adaptador desde promesas.

// firmas esperadas
type Result<T, E = Error> = { ok: true; valor: T } | { ok: false; error: E };
function ok<T>(valor: T): Result<T, never>;
function fallo<E>(error: E): Result<never, E>;
function mapear<T, U, E>(r: Result<T, E>, f: (t: T) => U): Result<U, E>;
function encadenar<T, U, E, F>(r: Result<T, E>, f: (t: T) => Result<U, F>): Result<U, E | F>;
function desempaquetarO<T, E>(r: Result<T, E>, porDefecto: T): T;
function intentar<T>(fn: () => T): Result<T, Error>;
function intentarAsync<T>(p: Promise<T>): Promise<Result<T, Error>>;

Caso de prueba. Una cadena de tres validaciones que se corta en la primera que falla y que el compilador obliga a comprobar antes de acceder al valor.

Solución comentada · TS-07
src/ts-07-result.ts
export type Result<T, E = Error> =
  | { readonly ok: true; readonly valor: T }
  | { readonly ok: false; readonly error: E };

export const ok = <T>(valor: T): Result<T, never> => ({ ok: true, valor });
export const fallo = <E>(error: E): Result<never, E> => ({ ok: false, error });

export function mapear<T, U, E>(r: Result<T, E>, f: (t: T) => U): Result<U, E> {
  return r.ok ? ok(f(r.valor)) : r;           // el caso de error se propaga sin tocarlo
}

export function encadenar<T, U, E, F>(
  r: Result<T, E>,
  f: (t: T) => Result<U, F>,
): Result<U, E | F> {
  return r.ok ? f(r.valor) : r;               // la unión de errores crece: E | F
}

export function desempaquetarO<T, E>(r: Result<T, E>, porDefecto: T): T {
  return r.ok ? r.valor : porDefecto;
}

export function intentar<T>(fn: () => T): Result<T, Error> {
  try {
    return ok(fn());
  } catch (e) {
    return fallo(e instanceof Error ? e : new Error(String(e)));
  }
}

export async function intentarAsync<T>(p: Promise<T>): Promise<Result<T, Error>> {
  try {
    return ok(await p);
  } catch (e) {
    return fallo(e instanceof Error ? e : new Error(String(e)));
  }
}
src/ts-07-uso.ts · errores de dominio tipados
type ErrorValidacion =
  | { tipo: 'email-invalido'; valor: string }
  | { tipo: 'password-corta'; minimo: number }
  | { tipo: 'email-duplicado'; email: string };

const validarEmail = (e: string): Result<string, ErrorValidacion> =>
  /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e) ? ok(e) : fallo({ tipo: 'email-invalido', valor: e });

const validarPassword = (p: string): Result<string, ErrorValidacion> =>
  p.length >= 12 ? ok(p) : fallo({ tipo: 'password-corta', minimo: 12 });

export function registrar(email: string, password: string): Result<{ email: string }, ErrorValidacion> {
  return encadenar(validarEmail(email), (e) =>
    mapear(validarPassword(password), () => ({ email: e })),
  );
}

const r = registrar('ana@ejemplo.com', '123');
if (r.ok) {
  console.log(r.valor.email);       // aquí `error` no existe
} else {
  // El switch es exhaustivo: si añades un tipo de error, el compilador te obliga a tratarlo.
  switch (r.error.tipo) {
    case 'email-invalido':  console.error('Email inválido:', r.error.valor); break;
    case 'password-corta':  console.error('Mínimo', r.error.minimo); break;
    case 'email-duplicado': console.error('Ya existe', r.error.email); break;
  }
}

Por qué esto y no excepciones. Una excepción es invisible en la firma: nada te obliga a capturarla y nada te dice qué puede lanzarse. Con Result, el fallo forma parte del tipo de retorno y el compilador impide leer valor sin comprobar antes ok. Es el mismo modelo de Rust y de Go, y encaja perfectamente con las uniones discriminadas de TypeScript.

Cuándo NO usarlo. Para errores inesperados (fallo de red, base de datos caída, error de programación) las excepciones siguen siendo lo correcto: quieres que suban hasta el filtro global y se registren. La regla práctica es: Result para errores de dominio que forman parte del contrato («el email ya existe»), excepciones para lo que no debería pasar nunca.

TS-08 · Nivel 2 · funciones de orden superior tipadas

Enunciado. Implementa tuberia (composición de izquierda a derecha) con sobrecargas que preserven los tipos hasta cinco funciones, y rebotar (debounce) que conserve la firma exacta de la función original y permita cancelar.

const procesar = tuberia(
  (s: string) => s.trim(),
  (s: string) => s.toLowerCase(),
  (s: string) => s.split(' '),
  (p: string[]) => p.length,
);
procesar('  Hola Mundo Cruel ');   // 3, y el tipo inferido es number

const buscar = rebotar((texto: string, pagina: number) => console.log(texto, pagina), 300);
buscar('a', 1); buscar('ab', 1); buscar('abc', 1);   // solo se ejecuta la última
buscar.cancelar();
Solución comentada · TS-08
src/ts-08-hof.ts
export function tuberia<A, B>(ab: (a: A) => B): (a: A) => B;
export function tuberia<A, B, C>(ab: (a: A) => B, bc: (b: B) => C): (a: A) => C;
export function tuberia<A, B, C, D>(ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D): (a: A) => D;
export function tuberia<A, B, C, D, E>(
  ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E,
): (a: A) => E;
// Implementación (no visible desde fuera): una sola reducción.
export function tuberia(...fns: Array<(x: unknown) => unknown>) {
  return (entrada: unknown) => fns.reduce((acc, f) => f(acc), entrada);
}

type Rebotada<F extends (...args: never[]) => void> = ((...args: Parameters<F>) => void) & {
  cancelar(): void;
  ejecutarYa(...args: Parameters<F>): void;
};

export function rebotar<F extends (...args: never[]) => void>(fn: F, ms: number): Rebotada<F> {
  let temporizador: ReturnType<typeof setTimeout> | undefined;

  const envoltorio = (...args: Parameters<F>) => {
    if (temporizador !== undefined) clearTimeout(temporizador);
    temporizador = setTimeout(() => {
      temporizador = undefined;
      fn(...args);
    }, ms);
  };

  envoltorio.cancelar = () => {
    if (temporizador !== undefined) clearTimeout(temporizador);
    temporizador = undefined;
  };
  envoltorio.ejecutarYa = (...args: Parameters<F>) => {
    envoltorio.cancelar();
    fn(...args);
  };

  return envoltorio;
}

Por qué sobrecargas y no un tipo variádico. Se puede escribir tuberia con tuplas variádicas y tipos recursivos, pero el mensaje de error que produce cuando dos funciones no encajan es ilegible. Las sobrecargas dan errores claros («no se puede asignar string a number en el argumento 3») y son lo que hacen RxJS y Redux en su código real. Regla: optimiza el sistema de tipos para el mensaje de error, no para la elegancia.

El detalle de ReturnType<typeof setTimeout>. En el navegador setTimeout devuelve number y en Node un objeto Timeout. Escribir let temporizador: number compila en un entorno y falla en el otro; esta forma funciona en los dos y es lo correcto en código isomorfo.

TS-09 · Nivel 3 · decorador de caché

Enunciado. Escribe un decorador de método @Cachear(ttlMs) que memorice el resultado por combinación de argumentos durante un tiempo. Requisitos: debe funcionar con métodos síncronos y asíncronos; no debe memorizar promesas rechazadas; la caché debe ser por instancia, no compartida entre todas; y debe evitar la estampida (dos llamadas simultáneas iguales deben compartir una sola ejecución).

class ServicioTareas {
  llamadas = 0;
  @Cachear(1000)
  async buscar(termino: string): Promise<string[]> {
    this.llamadas++;
    return [termino];
  }
}
const s = new ServicioTareas();
await Promise.all([s.buscar('a'), s.buscar('a')]);   // llamadas === 1
await s.buscar('a');                                  // llamadas === 1 (sigue en caché)
await s.buscar('b');                                  // llamadas === 2
Solución comentada · TS-09
src/ts-09-cachear.ts · decoradores legacy (experimentalDecorators)
interface Entrada { expira: number; valor: unknown }

/**
 * Versión con `experimentalDecorators: true`, que es la que usan NestJS y Angular.
 * La caché vive en un WeakMap indexado por instancia: cuando la instancia se recolecta,
 * su caché desaparece con ella y no hay fuga de memoria.
 */
export function Cachear(ttlMs = 5_000): MethodDecorator {
  const porInstancia = new WeakMap<object, Map<string, Entrada>>();

  return (_objetivo, _clave, descriptor: PropertyDescriptor) => {
    const original = descriptor.value as (...args: unknown[]) => unknown;

    descriptor.value = function (this: object, ...args: unknown[]) {
      let cache = porInstancia.get(this);
      if (!cache) { cache = new Map(); porInstancia.set(this, cache); }

      const clave = JSON.stringify(args);
      const ahora = Date.now();
      const entrada = cache.get(clave);
      if (entrada && entrada.expira > ahora) return entrada.valor;

      const valor = original.apply(this, args);
      cache.set(clave, { expira: ahora + ttlMs, valor });

      // Si es asíncrono, guardamos la promesa (eso resuelve la estampida)
      // pero la retiramos si acaba en error: no se cachean los fallos.
      if (valor instanceof Promise) {
        valor.catch(() => { cache.delete(clave); });
      }
      return valor;
    };

    return descriptor;
  };
}
src/ts-09-cachear-estandar.ts · decoradores estándar (TypeScript 5)
export function Cachear2(ttlMs = 5_000) {
  const porInstancia = new WeakMap<object, Map<string, Entrada>>();

  return function <This extends object, Args extends unknown[], Ret>(
    metodo: (this: This, ...args: Args) => Ret,
    _contexto: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Ret>,
  ) {
    return function (this: This, ...args: Args): Ret {
      let cache = porInstancia.get(this);
      if (!cache) { cache = new Map(); porInstancia.set(this, cache); }
      const clave = JSON.stringify(args);
      const entrada = cache.get(clave);
      if (entrada && entrada.expira > Date.now()) return entrada.valor as Ret;

      const valor = metodo.call(this, ...args);
      cache.set(clave, { expira: Date.now() + ttlMs, valor });
      if (valor instanceof Promise) valor.catch(() => { cache.delete(clave); });
      return valor;
    };
  };
}

Las cuatro trampas del ejercicio. (1) Usar una función flecha para descriptor.value rompe this: hay que usar function y apply. (2) Una caché en el closure del decorador es compartida por todas las instancias de la clase, lo que en un servicio con estado de usuario es una filtración de datos entre peticiones; de ahí el WeakMap por instancia. (3) Guardar la promesa y no el valor resuelto es lo que evita la estampida: las dos llamadas simultáneas reciben el mismo objeto Promise. (4) Sin el catch que limpia, un fallo transitorio de red queda cacheado durante todo el TTL.

Limitación de JSON.stringify como clave. No distingue el orden de las propiedades de un objeto, no serializa undefined, Map, Set ni BigInt, y falla con referencias circulares. En producción se usa una función de clave inyectable (@Cachear({ ttlMs, clave: (id) => \`tarea:\${id}\` })) o una caché externa como Redis, que es lo que verás en el ejercicio NEST-10.

TS-10 · Nivel 3 · AsyncLocalStorage y contexto de petición

Enunciado. Construye un contexto de petición con identificador de correlación que se propague automáticamente a través de await, setTimeout y Promise.all sin pasarlo como parámetro. Añade un logger que lo incluya en cada línea y un middleware de Express que lo inicialice.

Criterio de éxito. Dos peticiones concurrentes con trabajo asíncrono intercalado deben producir registros con identificadores distintos y correctos, nunca mezclados.

Solución comentada · TS-10
src/ts-10-contexto.ts
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';

export interface ContextoPeticion {
  correlacionId: string;
  usuarioId?: string;
  inicio: number;
}

const almacen = new AsyncLocalStorage<ContextoPeticion>();

export function ejecutarEnContexto<T>(ctx: ContextoPeticion, fn: () => T): T {
  return almacen.run(ctx, fn);
}

export function contextoActual(): ContextoPeticion | undefined {
  return almacen.getStore();
}

export function registrar(mensaje: string, extra: Record<string, unknown> = {}): void {
  const ctx = contextoActual();
  const linea = {
    ts: new Date().toISOString(),
    correlacionId: ctx?.correlacionId ?? 'sin-contexto',
    usuarioId: ctx?.usuarioId,
    msDesdeInicio: ctx ? Date.now() - ctx.inicio : undefined,
    mensaje,
    ...extra,
  };
  console.log(JSON.stringify(linea));
}

/** Middleware de Express: crea el contexto y lo mantiene durante toda la petición. */
export function middlewareCorrelacion(
  req: { header(n: string): string | undefined },
  res: { setHeader(n: string, v: string): void },
  next: () => void,
): void {
  const correlacionId = req.header('x-correlation-id') ?? randomUUID();
  res.setHeader('x-correlation-id', correlacionId);
  ejecutarEnContexto({ correlacionId, inicio: Date.now() }, next);
}
src/ts-10.test.ts · la prueba que de verdad importa
import { setTimeout as dormir } from 'node:timers/promises';
import { expect, it } from 'vitest';
import { contextoActual, ejecutarEnContexto } from './ts-10-contexto.js';

const trabajo = async (etiqueta: string) => {
  await dormir(Math.random() * 20);
  await Promise.all([dormir(5), dormir(1)]);
  return `${etiqueta}:${contextoActual()?.correlacionId}`;
};

it('no mezcla contextos entre ejecuciones concurrentes', async () => {
  const [a, b] = await Promise.all([
    ejecutarEnContexto({ correlacionId: 'A', inicio: Date.now() }, () => trabajo('a')),
    ejecutarEnContexto({ correlacionId: 'B', inicio: Date.now() }, () => trabajo('b')),
  ]);
  expect(a).toBe('a:A');
  expect(b).toBe('b:B');
});

Qué está pasando por dentro. AsyncLocalStorage se apoya en async_hooks, el mecanismo por el que Node etiqueta cada recurso asíncrono con el identificador del contexto que lo creó. Cuando una continuación se ejecuta, Node restaura el store asociado. Es el equivalente al ThreadLocal de Java, pero para el modelo de un solo hilo con muchas tareas.

Dónde falla. El contexto se pierde si el trabajo sale del árbol asíncrono actual: colas propias que guardan callbacks en un array y los ejecutan después, pools de conexiones que reutilizan recursos creados en otra petición, o código que usa EventEmitter con listeners registrados fuera del contexto. En esos casos hay que capturar el store explícitamente y volver a entrar con almacen.run().

En NestJS este patrón está detrás de @mikro-orm/nestjs (que crea un EntityManager por petición sin pasarlo por parámetros), de nestjs-cls y de la mayoría de los sistemas de tracing. Es la respuesta correcta a «¿cómo consigo el usuario actual en una capa profunda sin ensuciar todas las firmas?».

TS-11 · Nivel 3 · concurrencia limitada

Enunciado. Implementa mapaConcurrente, que aplique una función asíncrona a una lista ejecutando como máximo n tareas a la vez, conservando el orden de los resultados. Añade una variante que no se detenga en el primer error y devuelva el resultado de todas.

export function mapaConcurrente<T, R>(
  items: readonly T[],
  limite: number,
  fn: (item: T, indice: number) => Promise<R>,
): Promise<R[]>;

Casos de prueba. Con 10 elementos, límite 3 y una función que tarda 50 ms, el tiempo total debe rondar los 200 ms (4 tandas), la concurrencia máxima observada debe ser exactamente 3, y el resultado debe estar en el mismo orden que la entrada. Con límite 0 debe lanzar RangeError.

Solución comentada · TS-11
src/ts-11-concurrencia.ts
export async function mapaConcurrente<T, R>(
  items: readonly T[],
  limite: number,
  fn: (item: T, indice: number) => Promise<R>,
): Promise<R[]> {
  if (!Number.isInteger(limite) || limite < 1) {
    throw new RangeError('El límite de concurrencia debe ser un entero >= 1');
  }
  const resultados = new Array<R>(items.length);
  let siguiente = 0;                                  // índice compartido, sin carrera: un solo hilo

  // Cada «trabajador» toma el siguiente índice libre hasta que se agotan.
  // Esto mantiene el pool SIEMPRE lleno, a diferencia de trocear en tandas.
  const trabajador = async (): Promise<void> => {
    while (siguiente < items.length) {
      const i = siguiente++;
      resultados[i] = await fn(items[i] as T, i);
    }
  };

  const trabajadores = Array.from({ length: Math.min(limite, items.length) }, trabajador);
  await Promise.all(trabajadores);
  return resultados;
}

/** Variante tolerante a fallos: nunca rechaza, informa por elemento. */
export async function mapaConcurrenteSettled<T, R>(
  items: readonly T[],
  limite: number,
  fn: (item: T, indice: number) => Promise<R>,
): Promise<Array<{ ok: true; valor: R } | { ok: false; error: unknown }>> {
  return mapaConcurrente(items, limite, async (item, i) => {
    try {
      return { ok: true as const, valor: await fn(item, i) };
    } catch (error) {
      return { ok: false as const, error };
    }
  });
}

El error clásico es trocear en tandas con slice y hacer un Promise.all por tanda. Funciona, pero es hasta un 40 % más lento: cada tanda espera a su elemento más lento antes de empezar la siguiente, así que el pool se vacía continuamente. El patrón de «N trabajadores compitiendo por un índice compartido» mantiene siempre N tareas en vuelo.

Por qué no hay condición de carrera en siguiente++: JavaScript es de un solo hilo y el incremento es síncrono, así que no puede interrumpirse. Este mismo código en Java o Go necesitaría un mutex o un contador atómico.

Nota sobre el fallo rápido. mapaConcurrente rechaza en cuanto una tarea falla, pero las tareas ya lanzadas siguen ejecutándose: Promise.all no cancela nada. Si necesitas cancelación real, pasa un AbortSignal a fn y dispáralo en el catch. Esto es exactamente lo que ocurre con las peticiones HTTP y por lo que hace falta HttpClient + switchMap en Angular, que sí aborta la petición anterior.

TS-12 · Nivel 3 · reintentos con retroceso exponencial

Enunciado. Implementa conReintentos de calidad de producción: retroceso exponencial con jitter, techo máximo de espera, filtro de errores reintentables (solo 5xx, 429 y errores de red; nunca un 400), soporte de AbortSignal y callback de observabilidad. Debe relanzar el último error si se agotan los intentos.

await conReintentos(() => fetch('/api/tareas').then((r) => r.json()), {
  intentos: 5,
  baseMs: 200,
  maxMs: 10_000,
  reintentable: (e) => e instanceof ErrorHttp && (e.estado >= 500 || e.estado === 429),
  alReintentar: (e, intento, esperaMs) => console.warn(`intento ${intento} tras ${esperaMs} ms`, e),
});
Solución comentada · TS-12
src/ts-12-reintentos.ts
import { setTimeout as dormir } from 'node:timers/promises';

export class ErrorHttp extends Error {
  constructor(readonly estado: number, mensaje = `HTTP ${estado}`) {
    super(mensaje);
    this.name = 'ErrorHttp';
  }
}

export interface OpcionesReintento {
  intentos?: number;
  baseMs?: number;
  maxMs?: number;
  factor?: number;
  senal?: AbortSignal;
  reintentable?: (error: unknown) => boolean;
  alReintentar?: (error: unknown, intento: number, esperaMs: number) => void;
}

const REINTENTABLE_POR_DEFECTO = (e: unknown): boolean =>
  e instanceof ErrorHttp ? e.estado >= 500 || e.estado === 429 : true;   // errores de red: sí

export async function conReintentos<T>(
  fn: (senal?: AbortSignal) => Promise<T>,
  opciones: OpcionesReintento = {},
): Promise<T> {
  const {
    intentos = 3, baseMs = 200, maxMs = 10_000, factor = 2,
    senal, reintentable = REINTENTABLE_POR_DEFECTO, alReintentar,
  } = opciones;

  let ultimoError: unknown;

  for (let intento = 0; intento < intentos; intento++) {
    senal?.throwIfAborted();
    try {
      return await fn(senal);
    } catch (error) {
      ultimoError = error;

      const esUltimo = intento === intentos - 1;
      if (esUltimo || !reintentable(error)) break;    // no esperes si no vas a reintentar

      // «Full jitter» (AWS): espera aleatoria dentro del techo exponencial.
      // Evita que mil clientes reintenten a la vez y tumben el servicio que se recupera.
      const techo = Math.min(maxMs, baseMs * factor ** intento);
      const esperaMs = Math.round(Math.random() * techo);

      alReintentar?.(error, intento + 1, esperaMs);
      await dormir(esperaMs, undefined, { signal: senal });
    }
  }

  throw ultimoError;
}

Las cuatro decisiones que separan esta versión de la ingenua.

  • No reintentar los errores del cliente. Un 400 o un 422 fallará igual las cinco veces: reintentarlo solo añade latencia y carga. Un 429, en cambio, sí se reintenta (respetando Retry-After si viene).
  • Jitter obligatorio. Sin aleatoriedad, todos los clientes que fallaron en el mismo instante reintentan sincronizados y provocan un thundering herd que impide al servicio recuperarse. La variante «full jitter» de AWS es la que mejor se comporta en las simulaciones publicadas.
  • Techo máximo. Sin maxMs, el octavo intento con base 200 ms esperaría 25 segundos.
  • Cancelación. throwIfAborted() al inicio de cada vuelta y la señal propagada al sleep evitan que un usuario que ha cerrado la pantalla siga generando peticiones durante medio minuto.

Lo que le falta para un sistema grande: un circuit breaker. Si el servicio lleva caído dos minutos, reintentar es contraproducente; lo correcto es abrir el circuito, fallar de inmediato durante un tiempo y probar con una petición de sondeo. En Angular el equivalente de este ejercicio es el operador retry({ count, delay }) de RxJS, y en NestJS suele resolverse con una biblioteca como cockatiel.

22.3 Retos de Angular

Catorce retos ordenados por dificultad, del componente de presentación al interceptor con refresco de token. Resuélvelos en un proyecto real creado con ng new taskflow-web: la mitad del aprendizaje está en los proveedores, el enrutado y el build, no en el fragmento de código.

Sobre las versiones El código usa el Angular moderno: componentes standalone, input()/output(), señales, control de flujo nativo (@if, @for, @defer) e interceptores funcionales. Las API de resource() y rxResource() han cambiado de nombres entre versiones menores: contrasta con la documentación de la versión que tengas instalada.
Nivel 1 · básico

NG-01 · Componente de presentación. Crea <tf-tarjeta-tarea> con ChangeDetectionStrategy.OnPush, una entrada obligatoria tarea, una entrada booleana opcional compacta que acepte el atributo sin valor (<tf-tarjeta-tarea compacta>) y una salida completar que emita el identificador. Pista: input.required() y booleanAttribute.

NG-02 · Pipe personalizado. Escribe el pipe puro tiempoRelativo que convierta una fecha en «hace 3 horas» o «dentro de 2 días» usando Intl.RelativeTimeFormat en es-ES. Debe tolerar null, cadenas ISO y fechas inválidas. Pista: una tabla de unidades y umbrales; nunca instancies el formateador dentro de transform.

NG-03 · Store con señales. Implementa TareasStore con estado privado escribible y lectura pública de solo lectura, más tres derivados con computed: lista visible según filtro, número de pendientes y porcentaje de progreso. Ninguna mutación puede alterar el array existente.

Soluciones comentadas · NG-01, NG-02 y NG-03
tareas/tarjeta-tarea.component.ts · NG-01
import {ChangeDetectionStrategy, Component, booleanAttribute, input, output} from '@angular/core';
import {DatePipe} from '@angular/common';

@Component({
  selector: 'tf-tarjeta-tarea',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [DatePipe],
  template: `
    <article class="tarjeta" [class.compacta]="compacta()">
      <h3>{{ tarea().titulo }}</h3>
      <p class="vence">{{ tarea().vence | date: 'shortDate' }}</p>
      @if (!tarea().completada) {
        <button type="button" (click)="completar.emit(tarea().id)">Completar</button>
      }
    </article>`,
})
export class TarjetaTareaComponent {
  readonly tarea = input.required<Tarea>();
  // booleanAttribute permite <tf-tarjeta-tarea compacta> sin valor, como los atributos nativos.
  readonly compacta = input(false, {transform: booleanAttribute});
  readonly completar = output<number>();
}

Con OnPush el componente solo se revisa si cambia la referencia de una entrada, si se dispara un evento suyo o si una señal leída en su plantilla se modifica. Como aquí las entradas son señales, la revisión es automática y precisa.

compartido/tiempo-relativo.pipe.ts · NG-02
import {Pipe, PipeTransform} from '@angular/core';

@Pipe({name: 'tiempoRelativo'})   // puro por defecto: se recalcula solo si cambia la entrada
export class TiempoRelativoPipe implements PipeTransform {
  // Instanciar Intl es caro: se crea UNA vez, no en cada transform().
  private readonly formateador = new Intl.RelativeTimeFormat('es-ES', {numeric: 'auto'});
  private readonly unidades: ReadonlyArray<[Intl.RelativeTimeFormatUnit, number]> = [
    ['year', 31_536_000], ['month', 2_592_000], ['week', 604_800],
    ['day', 86_400], ['hour', 3_600], ['minute', 60], ['second', 1],
  ];

  transform(valor: Date | string | null | undefined, ahora: number = Date.now()): string {
    if (valor === null || valor === undefined || valor === '') return '';
    const fecha = valor instanceof Date ? valor : new Date(valor);
    if (Number.isNaN(fecha.getTime())) return '';

    const segundos = (fecha.getTime() - ahora) / 1000;
    for (const [unidad, factor] of this.unidades) {
      if (Math.abs(segundos) >= factor || unidad === 'second') {
        return this.formateador.format(Math.round(segundos / factor), unidad);
      }
    }
    return '';
  }
}

El parámetro ahora es lo que hace el pipe testable: puedes fijar el instante de referencia sin falsear el reloj global. Ojo con un detalle sutil: un pipe puro no se recalcula con el paso del tiempo, así que «hace 1 minuto» se quedará congelado. Si necesitas que refresque, expón una señal de reloj en un servicio y léela en la plantilla.

tareas/tareas.store.ts · NG-03
import {Injectable, computed, signal} from '@angular/core';

export type Filtro = 'todas' | 'pendientes' | 'hechas';

@Injectable({providedIn: 'root'})
export class TareasStore {
  // Estado privado escribible; fuera solo se ve la versión de solo lectura.
  private readonly _tareas = signal<readonly Tarea[]>([]);
  private readonly _filtro = signal<Filtro>('todas');

  readonly tareas = this._tareas.asReadonly();
  readonly filtro = this._filtro.asReadonly();

  readonly visibles = computed(() => {
    const f = this._filtro();
    if (f === 'todas') return this._tareas();
    return this._tareas().filter((t) => (f === 'hechas' ? t.completada : !t.completada));
  });
  readonly pendientes = computed(() => this._tareas().filter((t) => !t.completada).length);
  readonly progreso = computed(() => {
    const total = this._tareas().length;
    return total === 0 ? 0 : Math.round(((total - this.pendientes()) / total) * 100);
  });

  cargar(tareas: readonly Tarea[]): void { this._tareas.set(tareas); }
  filtrar(f: Filtro): void { this._filtro.set(f); }

  alternar(id: number): void {
    // Inmutable: nuevo array y nuevo objeto para el elemento modificado.
    this._tareas.update((ts) =>
      ts.map((t) => (t.id === id ? {...t, completada: !t.completada} : t)));
  }
}

Tres reglas se cumplen aquí: el estado escribible nunca sale del servicio (asReadonly()), todo lo derivable es computed y no un segundo signal sincronizado a mano, y las actualizaciones son inmutables. Mutar this._tareas()[0].completada = true no notificaría a nadie: la referencia del array no cambia.

Nivel 2 · intermedio

NG-04 · Buscador con debounce y cancelación. Campo de búsqueda que consulta /api/tareas?q=. Requisitos: esperar 300 ms de inactividad, ignorar términos repetidos, no buscar con menos de 2 caracteres y cancelar la petición anterior cuando llega una nueva. Demuestra con la pestaña de red que la petición vieja se aborta. Pista: switchMap, nunca mergeMap.

NG-05 · Formulario reactivo con validación cruzada y asíncrona. Formulario de tarea con inicio y fin (el fin no puede ser anterior al inicio: validador a nivel de grupo) y un campo email del responsable validado contra el servidor. La validación asíncrona debe ejecutarse solo al perder el foco y no debe disparar una petición por tecla.

NG-06 · ControlValueAccessor. Crea <tf-input-etiquetas>, un campo que gestiona una lista de etiquetas y se integra con formControlName como si fuera un input nativo: soporta writeValue, notifica cambios, marca «tocado» y respeta setDisabledState.

NG-07 · Tabla con orden, filtro y paginación. Con señales y computed: filtro de texto, orden por columna con dirección alternante y paginación en cliente. Cada derivación debe ser un computed encadenado, no un método llamado desde la plantilla.

NG-08 · Directiva estructural *hasRole. Muestra el contenido solo si el usuario tiene alguno de los roles indicados: <button *hasRole="['admin','gestor']">. Debe reaccionar a los cambios de sesión sin recargar la página y no dejar vistas duplicadas.

NG-09 · Guard con returnUrl. Guard funcional que redirige a /login conservando la URL de destino y que, tras autenticarse, devuelve al usuario exactamente donde iba, incluidos los parámetros de consulta.

Soluciones comentadas · NG-04, NG-05 y NG-06
tareas/buscador.component.ts · NG-04
import {ChangeDetectionStrategy, Component, inject, signal} from '@angular/core';
import {toObservable, toSignal} from '@angular/core/rxjs-interop';
import {debounceTime, distinctUntilChanged, of, startWith, switchMap} from 'rxjs';

@Component({
  selector: 'tf-buscador',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <input type="search" [value]="termino()" (input)="termino.set($any($event.target).value)"
           placeholder="Buscar tareas" />
    @for (t of resultados(); track t.id) { <p>{{ t.titulo }}</p> }`,
})
export class BuscadorComponent {
  private readonly api = inject(TareasApi);
  readonly termino = signal('');

  readonly resultados = toSignal(
    toObservable(this.termino).pipe(
      debounceTime(300),               // espera a que el usuario pare de teclear
      distinctUntilChanged(),          // 'ana' -> 'an' -> 'ana' no repite la búsqueda
      switchMap((q) => (q.trim().length < 2 ? of([]) : this.api.buscar(q))),
      startWith([] as Tarea[]),
    ),
    {initialValue: [] as Tarea[]},
  );
}

Por qué switchMap y no mergeMap. switchMap cancela la suscripción anterior antes de crear la nueva, y HttpClient traduce esa cancelación en un abort() real de la petición. Con mergeMap conviven todas las peticiones y, si la de «an» tarda más que la de «ana», el usuario ve resultados obsoletos: es la carrera clásica de todo buscador mal hecho. Con la API de recursos el equivalente sería rxResource({params: () => terminoDebounced(), stream: ({params}) => api.buscar(params)}), que cancela igual.

tareas/validadores.ts + formulario · NG-05
import {AbstractControl, AsyncValidatorFn, FormBuilder, ValidationErrors, ValidatorFn, Validators}
  from '@angular/forms';
import {catchError, first, map, of, switchMap, timer} from 'rxjs';

/** Validador de grupo: compara dos controles hermanos. */
export const rangoDeFechas: ValidatorFn = (grupo: AbstractControl): ValidationErrors | null => {
  const inicio = grupo.get('inicio')?.value as string | null;
  const fin = grupo.get('fin')?.value as string | null;
  if (!inicio || !fin) return null;                       // sin datos no se valida
  return new Date(inicio) <= new Date(fin) ? null : {rangoInvalido: true};
};

/** Validador asíncrono con su propio debounce y sin propagar errores de red. */
export function emailDisponible(api: UsuariosApi): AsyncValidatorFn {
  return (control) => {
    if (!control.value) return of(null);
    return timer(400).pipe(
      switchMap(() => api.existeEmail(control.value as string)),
      map((existe) => (existe ? {emailDuplicado: true} : null)),
      catchError(() => of(null)),   // si la API cae, no bloquees el formulario
      first(),                      // OBLIGATORIO: debe completar o el control queda en PENDING
    );
  };
}

// Uso en el componente
private readonly fb = inject(FormBuilder);
readonly formulario = this.fb.nonNullable.group(
  {
    titulo: ['', [Validators.required, Validators.maxLength(120)]],
    inicio: [''],
    fin: [''],
    email: this.fb.nonNullable.control('', {
      validators: [Validators.required, Validators.email],
      asyncValidators: [emailDisponible(inject(UsuariosApi))],
      updateOn: 'blur',            // una petición al salir del campo, no una por tecla
    }),
  },
  {validators: rangoDeFechas},
);

El first() es el fallo más frecuente de los validadores asíncronos: si el observable no completa, el control se queda para siempre en estado PENDING y el botón de enviar nunca se habilita. El catchError es la segunda trampa: sin él, un 500 del servidor deja el control en un estado inconsistente.

compartido/input-etiquetas.component.ts · NG-06
import {ChangeDetectionStrategy, Component, forwardRef, signal} from '@angular/core';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';

@Component({
  selector: 'tf-input-etiquetas',
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => InputEtiquetasComponent),   // la clase aún no existe al evaluar
    multi: true,
  }],
  template: `
    <div class="chips" (focusout)="alPerderFoco()">
      @for (e of etiquetas(); track e) {
        <span class="chip">{{ e }}
          <button type="button" [disabled]="deshabilitado()" (click)="quitar(e)">&times;</button>
        </span>
      }
      <input #campo type="text" [disabled]="deshabilitado()"
             (keydown.enter)="$event.preventDefault(); anadir(campo.value); campo.value = ''" />
    </div>`,
})
export class InputEtiquetasComponent implements ControlValueAccessor {
  readonly etiquetas = signal<readonly string[]>([]);
  readonly deshabilitado = signal(false);

  private alCambiar: (valor: readonly string[]) => void = () => {};
  private alTocar: () => void = () => {};

  writeValue(valor: readonly string[] | null): void { this.etiquetas.set(valor ?? []); }
  registerOnChange(fn: (valor: readonly string[]) => void): void { this.alCambiar = fn; }
  registerOnTouched(fn: () => void): void { this.alTocar = fn; }
  setDisabledState(deshabilitado: boolean): void { this.deshabilitado.set(deshabilitado); }

  anadir(texto: string): void {
    const limpio = texto.trim().toLowerCase();
    if (limpio === '' || this.etiquetas().includes(limpio)) return;
    this.etiquetas.update((e) => [...e, limpio]);
    this.alCambiar(this.etiquetas());     // notifica al FormControl
  }
  quitar(etiqueta: string): void {
    this.etiquetas.update((e) => e.filter((x) => x !== etiqueta));
    this.alCambiar(this.etiquetas());
  }
  alPerderFoco(): void { this.alTocar(); }   // sin esto, el control nunca pasa a «touched»
}

Los cuatro métodos de la interfaz no son opcionales en la práctica: olvidar setDisabledState hace que formulario.disable() no tenga efecto visual, y olvidar registerOnTouched impide que los mensajes de error aparezcan al abandonar el campo. forwardRef es necesario porque el array providers se evalúa antes de que la clase esté definida.

Soluciones comentadas · NG-07, NG-08 y NG-09
tareas/tabla-tareas.component.ts · NG-07
import {Component, computed, input, signal} from '@angular/core';

type Columna = 'titulo' | 'prioridad' | 'vence';

@Component({selector: 'tf-tabla-tareas', /* … plantilla … */})
export class TablaTareasComponent {
  readonly datos = input.required<readonly Tarea[]>();

  readonly texto = signal('');
  readonly columna = signal<Columna>('vence');
  readonly ascendente = signal(true);
  readonly pagina = signal(1);
  readonly tamano = signal(20);

  // Cadena de derivaciones: filtrar -> ordenar -> paginar. Cada paso se memoriza
  // y solo se recalcula si cambia alguna de sus dependencias.
  private readonly filtradas = computed(() => {
    const q = this.texto().trim().toLowerCase();
    return q === '' ? this.datos() : this.datos().filter((t) => t.titulo.toLowerCase().includes(q));
  });

  private readonly ordenadas = computed(() => {
    const col = this.columna();
    const signo = this.ascendente() ? 1 : -1;
    // toSorted no muta el array de origen (Node 20+ / navegadores modernos).
    return this.filtradas().toSorted((a, b) =>
      signo * String(a[col] ?? '').localeCompare(String(b[col] ?? ''), 'es'));
  });

  readonly totalPaginas = computed(() => Math.max(1, Math.ceil(this.ordenadas().length / this.tamano())));
  readonly visibles = computed(() => {
    const desde = (this.pagina() - 1) * this.tamano();
    return this.ordenadas().slice(desde, desde + this.tamano());
  });

  ordenarPor(col: Columna): void {
    if (this.columna() === col) this.ascendente.update((a) => !a);
    else { this.columna.set(col); this.ascendente.set(true); }
    this.pagina.set(1);           // cambiar el orden invalida la página actual
  }
}

La razón de no llamar a un método desde la plantilla ({{ filtrar(datos) }}) es que se ejecutaría en cada ciclo de detección de cambios. Un computed se recalcula solo cuando alguna señal de la que depende cambia, y cachea el resultado.

auth/has-role.directive.ts · NG-08
import {Directive, TemplateRef, ViewContainerRef, effect, inject, input} from '@angular/core';

@Directive({selector: '[hasRole]'})
export class HasRoleDirective {
  private readonly vista = inject(ViewContainerRef);
  private readonly plantilla = inject<TemplateRef<unknown>>(TemplateRef);
  private readonly sesion = inject(SesionStore);
  private incrustada = false;

  // El nombre del input debe coincidir con el selector para la sintaxis *hasRole="…".
  readonly hasRole = input.required<string | readonly string[]>();

  constructor() {
    effect(() => {
      const requeridos = [this.hasRole()].flat();
      const permitido = requeridos.some((r) => this.sesion.roles().includes(r));

      if (permitido && !this.incrustada) {
        this.vista.createEmbeddedView(this.plantilla);
        this.incrustada = true;
      } else if (!permitido && this.incrustada) {
        this.vista.clear();
        this.incrustada = false;
      }
    });
  }
}

El indicador incrustada evita el fallo más típico: sin él, cada ejecución del efecto crearía una vista nueva y acabarías con el botón repetido diez veces. Advertencia de seguridad: esto es presentación, no autorización. Ocultar un botón no impide llamar al endpoint; la comprobación real vive en el servidor (ver NEST-04).

auth/autenticado.guard.ts · NG-09
import {inject} from '@angular/core';
import {CanActivateFn, Router} from '@angular/router';

export const autenticadoGuard: CanActivateFn = (_ruta, estado) => {
  const sesion = inject(SesionStore);
  const router = inject(Router);
  if (sesion.autenticado()) return true;

  // Devolver un UrlTree redirige de forma atómica: mejor que router.navigate() + return false,
  // que provoca dos eventos de navegación y puede dejar la URL a medias.
  return router.createUrlTree(['/login'], {queryParams: {returnUrl: estado.url}});
};

// En el componente de login, tras autenticarse:
private readonly ruta = inject(ActivatedRoute);
private readonly router = inject(Router);

async entrar(credenciales: Credenciales): Promise<void> {
  await this.auth.login(credenciales);
  const destino = this.ruta.snapshot.queryParamMap.get('returnUrl') ?? '/';
  // parseUrl conserva los query params y el fragmento del destino original.
  await this.router.navigateByUrl(this.router.parseUrl(destino));
}

estado.url incluye los parámetros de consulta, así que parseUrl los restaura tal cual. Un detalle de seguridad que se olvida siempre: valida que returnUrl sea una ruta interna. Si aceptas returnUrl=https://malicioso.example has construido un open redirect, que es una vulnerabilidad real de phishing.

Nivel 3 · avanzado

NG-10 · Interceptor con refresco de token. Ante un 401, refresca el token y reintenta la petición original una sola vez. Requisitos duros: si diez peticiones fallan a la vez debe hacerse un único refresco compartido; el propio endpoint de refresco no debe interceptarse; y si el refresco falla hay que cerrar la sesión sin entrar en bucle.

NG-11 · Carga diferida con @defer. Difiere un gráfico pesado hasta que entre en el área visible, con precarga en tiempo de inactividad, marcador de posición, indicador de carga con umbral y bloque de error. Justifica cada umbral y comprueba en el build que se genera un fragmento separado.

NG-12 · Virtual scroll. Renderiza 50.000 filas con el CDK manteniendo 60 fps. Mide antes y después con el panel de rendimiento y explica por qué @for con track no basta.

NG-13 · Test con HttpTestingController. Prueba un servicio de API: que envía los parámetros correctos, que mapea la respuesta, que propaga el error 500 y que no quedan peticiones pendientes.

NG-14 · Migración de RxJS a señales. Toma un componente con BehaviorSubject, combineLatest y tres async en la plantilla y reescríbelo con señales conservando el comportamiento. Identifica qué parte debe seguir siendo RxJS.

Soluciones comentadas · NG-10, NG-11 y NG-12
auth/auth.interceptor.ts · NG-10
import {HttpErrorResponse, HttpInterceptorFn, HttpRequest} from '@angular/common/http';
import {inject} from '@angular/core';
import {Observable, catchError, finalize, shareReplay, switchMap, tap, throwError} from 'rxjs';

@Injectable({providedIn: 'root'})
export class RefrescoService {
  private readonly api = inject(AuthApi);
  private readonly sesion = inject(SesionStore);
  // Se guarda en el servicio, no en una variable de módulo: así no hay estado compartido
  // entre peticiones distintas en renderizado en servidor (SSR).
  private enCurso: Observable<string> | null = null;

  refrescar(): Observable<string> {
    this.enCurso ??= this.api.refrescar(this.sesion.refreshToken()).pipe(
      tap((token) => this.sesion.establecerAcceso(token)),
      catchError((e: unknown) => { this.sesion.cerrar(); return throwError(() => e); }),
      finalize(() => { this.enCurso = null; }),          // permite un refresco futuro
      shareReplay({bufferSize: 1, refCount: false}),      // TODOS comparten la misma llamada
    );
    return this.enCurso;
  }
}

const SIN_AUTH = ['/auth/login', '/auth/refresh', '/auth/registro'];

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const sesion = inject(SesionStore);
  const refresco = inject(RefrescoService);

  // Excluir el endpoint de refresco es lo que rompe el bucle infinito.
  if (SIN_AUTH.some((r) => req.url.includes(r))) return next(req);

  const conToken = (r: HttpRequest<unknown>, token: string | null) =>
    token ? r.clone({setHeaders: {Authorization: `Bearer ${token}`}}) : r;

  return next(conToken(req, sesion.accessToken())).pipe(
    catchError((error: unknown) => {
      if (!(error instanceof HttpErrorResponse) || error.status !== 401) {
        return throwError(() => error);
      }
      return refresco.refrescar().pipe(
        switchMap((token) => next(conToken(req, token))),  // un solo reintento
      );
    }),
  );
};

Las tres piezas críticas: ??= más shareReplay hacen que diez 401 simultáneos compartan un único POST /auth/refresh; finalize limpia la referencia para que el siguiente 401, dentro de una hora, vuelva a refrescar; y la lista de exclusión evita que el 401 del propio refresco se intercepte a sí mismo, que es la causa exacta del caso DEBUG-07.

panel/panel.component.html · NG-11
@defer (on viewport; prefetch on idle) {
  <tf-grafico-progreso [datos]="metricas()" />
} @placeholder (minimum 300ms) {
  <div class="esqueleto" style="height:320px"></div>
} @loading (after 150ms; minimum 500ms) {
  <tf-spinner />
} @error {
  <p role="alert">No se pudo cargar el gráfico. <button (click)="recargar()">Reintentar</button></p>
}

Cada umbral tiene una razón medida: after 150ms evita el parpadeo del spinner en conexiones rápidas (si carga en 80 ms, el usuario no ve nada raro); minimum 500ms impide que el spinner aparezca y desaparezca de golpe, que se percibe como un fallo; minimum 300ms en el placeholder evita saltos de maquetación. prefetch on idle descarga el fragmento cuando el navegador está ocioso, de modo que al llegar al viewport el cambio es instantáneo. Comprueba en dist/ que aparece un chunk nuevo: si el componente también está importado de forma estática en otro sitio, Angular no puede separarlo y el @defer no sirve de nada.

tareas/lista-virtual.component.ts · NG-12
import {ScrollingModule} from '@angular/cdk/scrolling';

@Component({
  selector: 'tf-lista-virtual',
  imports: [ScrollingModule, FilaTareaComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styles: `.visor { height: 70vh; } .fila { height: 56px; }`,
  template: `
    <cdk-virtual-scroll-viewport itemSize="56" class="visor">
      <tf-fila-tarea class="fila" *cdkVirtualFor="let t of tareas(); trackBy: porId" [tarea]="t" />
    </cdk-virtual-scroll-viewport>`,
})
export class ListaVirtualComponent {
  readonly tareas = input.required<readonly Tarea[]>();
  porId = (_i: number, t: Tarea) => t.id;
}

track en @for resuelve un problema distinto: evita recrear nodos al reordenar, pero sigue creando 50.000 nodos la primera vez. El virtual scroll mantiene en el DOM solo las filas visibles (unas 15) más un pequeño margen, y simula la altura total con un espaciador. La consecuencia práctica: el DOM pasa de ~600.000 nodos a ~200 y el tiempo de renderizado inicial de varios segundos a unas decenas de milisegundos. Requisito imprescindible: la altura de fila debe ser fija y coincidir con itemSize; con alturas variables necesitas una estrategia personalizada o autosize del experimental.

Soluciones comentadas · NG-13 y NG-14
tareas/tareas.api.spec.ts · NG-13
import {HttpErrorResponse, provideHttpClient} from '@angular/common/http';
import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
import {TestBed} from '@angular/core/testing';

describe('TareasApi', () => {
  let api: TareasApi;
  let http: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideHttpClient(), provideHttpClientTesting(), TareasApi],
    });
    api = TestBed.inject(TareasApi);
    http = TestBed.inject(HttpTestingController);
  });

  // Falla el test si quedó alguna petición sin atender: detecta fugas y peticiones duplicadas.
  afterEach(() => http.verify());

  it('envía los parámetros de paginación y mapea la respuesta', () => {
    let recibidas: readonly Tarea[] | undefined;
    api.listar({pagina: 2, tamano: 20}).subscribe((r) => (recibidas = r.datos));

    const peticion = http.expectOne((r) => r.url === '/api/tareas');
    expect(peticion.request.method).toBe('GET');
    expect(peticion.request.params.get('pagina')).toBe('2');
    expect(peticion.request.params.get('tamano')).toBe('20');

    peticion.flush({datos: [{id: 1, titulo: 'Escribir tests'}], total: 1});
    expect(recibidas?.length).toBe(1);
  });

  it('propaga el 500 como HttpErrorResponse', () => {
    let error: unknown;
    api.listar({pagina: 1, tamano: 20}).subscribe({error: (e: unknown) => (error = e)});

    http.expectOne((r) => r.url === '/api/tareas')
        .flush('boom', {status: 500, statusText: 'Internal Server Error'});

    expect((error as HttpErrorResponse).status).toBe(500);
  });
});

http.verify() en el afterEach es lo que convierte este test en una red de seguridad de verdad: detecta el caso en que un cambio introduce una segunda petición inesperada (por ejemplo, al perder un shareReplay).

antes.component.tsRXJS
export class AntesComponent implements OnInit, OnDestroy {
  private readonly filtro$ = new BehaviorSubject('');
  private readonly pagina$ = new BehaviorSubject(1);
  tareas: Tarea[] = [];
  private sub?: Subscription;

  ngOnInit(): void {
    this.sub = combineLatest([this.filtro$, this.pagina$])
      .pipe(switchMap(([f, p]) => this.api.listar(f, p)))
      .subscribe((t) => { this.tareas = t; this.cdr.markForCheck(); });
  }
  ngOnDestroy(): void { this.sub?.unsubscribe(); }

  buscar(f: string) { this.filtro$.next(f); this.pagina$.next(1); }
}
despues.component.tsSEÑALES
export class DespuesComponent {
  private readonly api = inject(TareasApi);
  readonly filtro = signal('');
  readonly pagina = signal(1);

  // El HTTP sigue siendo RxJS: es un flujo con tiempo y cancelación.
  readonly tareas = rxResource({
    params: () => ({f: this.filtro(), p: this.pagina()}),
    stream: ({params}) => this.api.listar(params.f, params.p),
  });

  buscar(f: string): void {
    this.filtro.set(f);
    this.pagina.set(1);
  }
}

Qué desaparece: ngOnInit, ngOnDestroy, la suscripción manual, el markForCheck y los dos BehaviorSubject. Además obtienes tareas.isLoading() y tareas.error() gratis.

Qué debe seguir siendo RxJS: todo lo que tenga tiempo o cancelación. Peticiones HTTP, debounce, reintentos con retroceso, WebSockets, eventos del router y valueChanges de los formularios reactivos. La regla mental: las señales modelan estado; los observables modelan eventos en el tiempo. Convertir de uno a otro con toSignal y toObservable en la frontera es lo correcto; intentar hacerlo todo con uno solo, no.

22.4 Retos de NestJS

Catorce retos que recorren el ciclo completo de una petición: inyección de dependencias, validación, guardas, interceptores, filtros, módulos dinámicos, autenticación, límites de tasa, caché, colas, tiempo real, salud y pruebas de extremo a extremo. Trabájalos sobre un proyecto nest new taskflow-api.

Nivel 1 · básico

NEST-01 · Inyectar una interfaz. El módulo de notificaciones debe poder enviar por SMTP en producción y guardar en memoria en los tests, sin que el servicio que las usa se entere. Como las interfaces de TypeScript no existen en tiempo de ejecución, necesitas un token. Implementa el puerto, dos adaptadores y el proveedor que elige uno según la configuración.

NEST-02 · DTO con validación compleja y anidada. CrearProyectoDto con: nombre de 3 a 120 caracteres; fecha de fin opcional en ISO 8601 posterior a la de inicio; array de hasta 10 etiquetas únicas y en minúsculas; un objeto ajustes anidado; y una lista de miembros con al menos un elemento, cada uno validado. Configura ValidationPipe para que rechace propiedades desconocidas.

NEST-03 · Pipe personalizado. ParseOrdenPipe convierte ?orden=-vence,titulo en [{campo:'vence',dir:'DESC'},{campo:'titulo',dir:'ASC'}], rechazando con 400 cualquier campo que no esté en una lista blanca. Pista: la lista blanca no es opcional: sin ella tienes una inyección de SQL por la puerta de atrás.

Soluciones comentadas · NEST-01, NEST-02 y NEST-03
notificaciones/notificador.port.ts + module · NEST-01
// 1) El puerto: contrato del dominio, sin dependencias de infraestructura.
export interface Notificador {
  enviar(destino: string, asunto: string, cuerpo: string): Promise<void>;
}
// 2) El token: un Symbol evita colisiones que sí ocurren con tokens de tipo string.
export const NOTIFICADOR = Symbol('NOTIFICADOR');

// 3) Adaptadores
@Injectable()
export class SmtpNotificador implements Notificador {
  private readonly logger = new Logger(SmtpNotificador.name);
  constructor(private readonly config: ConfigService) {}
  async enviar(destino: string, asunto: string, cuerpo: string): Promise<void> {
    // … nodemailer con this.config.getOrThrow('SMTP_URL')
  }
}

@Injectable()
export class NotificadorEnMemoria implements Notificador {
  readonly enviadas: Array<{destino: string; asunto: string; cuerpo: string}> = [];
  async enviar(destino: string, asunto: string, cuerpo: string): Promise<void> {
    this.enviadas.push({destino, asunto, cuerpo});
  }
}

// 4) El módulo decide la implementación; nadie más lo sabe.
@Module({
  providers: [
    SmtpNotificador,
    NotificadorEnMemoria,
    {
      provide: NOTIFICADOR,
      inject: [ConfigService, SmtpNotificador, NotificadorEnMemoria],
      useFactory: (c: ConfigService, smtp: SmtpNotificador, memoria: NotificadorEnMemoria) =>
        c.get('NODE_ENV') === 'production' ? smtp : memoria,
    },
  ],
  exports: [NOTIFICADOR],
})
export class NotificacionesModule {}

// 5) Consumo: el servicio depende de la abstracción, no de SMTP.
@Injectable()
export class TareasService {
  constructor(@Inject(NOTIFICADOR) private readonly notificador: Notificador) {}
}

Esto es el principio de inversión de dependencias de SOLID aplicado literalmente: el dominio define el contrato y la infraestructura lo implementa. El beneficio inmediato es que en los tests inyectas NotificadorEnMemoria y compruebas notificador.enviadas sin levantar un servidor de correo.

proyectos/dto/crear-proyecto.dto.ts · NEST-02
import {Transform, Type} from 'class-transformer';
import {ArrayMaxSize, ArrayMinSize, IsArray, IsBoolean, IsEmail, IsISO8601, IsIn,
  IsInt, IsOptional, IsString, Length, Max, Min, ValidateNested, Validate} from 'class-validator';

export class AjustesDto {
  @IsBoolean() publico!: boolean;
  @IsInt() @Min(1) @Max(365) diasRetencion!: number;
}

export class MiembroDto {
  @IsEmail() email!: string;
  @IsIn(['admin', 'editor', 'lector']) rol!: 'admin' | 'editor' | 'lector';
}

export class CrearProyectoDto {
  @IsString() @Length(3, 120) nombre!: string;

  @IsISO8601() inicio!: string;

  @IsOptional() @IsISO8601()
  @Validate(EsPosteriorA, ['inicio'])          // validador de clase personalizado
  fin?: string;

  @IsArray() @ArrayMaxSize(10) @IsString({each: true}) @Length(1, 24, {each: true})
  @Transform(({value}) => Array.isArray(value)
    ? [...new Set(value.map((v: unknown) => String(v).trim().toLowerCase()))]
    : value)
  etiquetas: string[] = [];

  @ValidateNested() @Type(() => AjustesDto)     // @Type es OBLIGATORIO o llega un objeto plano
  ajustes!: AjustesDto;

  @IsArray() @ArrayMinSize(1) @ValidateNested({each: true}) @Type(() => MiembroDto)
  miembros!: MiembroDto[];
}
main.ts · configuración del ValidationPipe
app.useGlobalPipes(new ValidationPipe({
  whitelist: true,              // elimina propiedades sin decorador
  forbidNonWhitelisted: true,   // …y además responde 400 si llegan: detecta clientes desalineados
  transform: true,              // convierte el JSON plano en instancias de la clase DTO
  transformOptions: {enableImplicitConversion: false},   // conversiones explícitas: menos sorpresas
  stopAtFirstError: false,      // devuelve todos los errores de una vez: mejor experiencia de usuario
}));

@Type(() => AjustesDto) es el olvido número uno: sin él, class-transformer deja un objeto plano y @ValidateNested() no valida absolutamente nada, dando una falsa sensación de seguridad. Y sin forbidNonWhitelisted, un cliente que envía rol: 'admin' en el DTO de registro solo verá su campo silenciosamente descartado; con él, recibe un 400 claro.

comun/pipes/parse-orden.pipe.ts · NEST-03
export interface Orden { campo: string; dir: 'ASC' | 'DESC' }

@Injectable()
export class ParseOrdenPipe implements PipeTransform<string | undefined, Orden[]> {
  constructor(private readonly permitidos: readonly string[]) {}

  transform(valor: string | undefined): Orden[] {
    if (!valor) return [];
    const partes = valor.split(',').map((p) => p.trim()).filter(Boolean);
    if (partes.length > 3) throw new BadRequestException('Máximo 3 criterios de orden');

    return partes.map((parte) => {
      const dir = parte.startsWith('-') ? 'DESC' : 'ASC';
      const campo = parte.replace(/^[-+]/, '');
      // Lista blanca: el nombre de columna acabará en el SQL, así que NUNCA se acepta libre.
      if (!this.permitidos.includes(campo)) {
        throw new BadRequestException(
          `Campo de orden no permitido: '${campo}'. Permitidos: ${this.permitidos.join(', ')}`);
      }
      return {campo, dir};
    });
  }
}

// Uso: se instancia con sus parámetros en el propio decorador.
@Get()
listar(@Query('orden', new ParseOrdenPipe(['titulo', 'vence', 'prioridad'])) orden: Orden[]) { … }
Nivel 2 · intermedio

NEST-04 · Guard de roles con Reflector. Decorador @Roles('admin','gestor') aplicable a método o a controlador, con el guard que lo lee. El de método debe ganar al de clase, y las rutas marcadas con @Publico() deben saltarse la autenticación.

NEST-05 · Interceptores de transformación y de timeout. El primero envuelve toda respuesta correcta en { datos, meta: { correlacionId, ms } } sin tocar los errores. El segundo aborta cualquier petición que supere 5 segundos con un 408 y libera los recursos. Justifica el orden en que se aplican.

NEST-06 · Filtro global con formato Problem Details. Todas las respuestas de error, vengan de HttpException, del ValidationPipe, de MikroORM o de un fallo no controlado, deben salir con Content-Type: application/problem+json y la forma del RFC 9457. Los 5xx se registran con traza; los 4xx, no. Nunca se filtra el mensaje interno al cliente.

NEST-07 · Módulo dinámico configurable. AlmacenamientoModule.forRoot(opciones) y forRootAsync({inject, useFactory}), con opciones tipadas y validadas al arrancar.

NEST-08 · Autenticación JWT con refresh rotativo. Access token de 15 minutos y refresh de 7 días con rotación: cada uso invalida el anterior. Almacena solo el hash del refresh, agrúpalos por familia y, si detectas la reutilización de uno ya consumido, revoca la familia entera.

Soluciones comentadas · NEST-04, NEST-05 y NEST-06
auth/roles.decorator.ts + roles.guard.ts · NEST-04
export type Rol = 'admin' | 'gestor' | 'miembro';
export const ROLES_CLAVE = 'roles';
export const PUBLICO_CLAVE = 'esPublico';

export const Roles = (...roles: Rol[]) => SetMetadata(ROLES_CLAVE, roles);
export const Publico = () => SetMetadata(PUBLICO_CLAVE, true);

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(contexto: ExecutionContext): boolean {
    // getAllAndOverride: el metadato del método tiene prioridad sobre el de la clase.
    const esPublico = this.reflector.getAllAndOverride<boolean>(PUBLICO_CLAVE,
      [contexto.getHandler(), contexto.getClass()]);
    if (esPublico) return true;

    const requeridos = this.reflector.getAllAndOverride<Rol[]>(ROLES_CLAVE,
      [contexto.getHandler(), contexto.getClass()]);
    if (!requeridos || requeridos.length === 0) return true;   // sin @Roles, basta estar autenticado

    const peticion = contexto.switchToHttp().getRequest<{usuario?: {roles: Rol[]}}>();
    const usuario = peticion.usuario;
    if (!usuario) throw new UnauthorizedException('Sin sesión');

    if (!requeridos.some((r) => usuario.roles.includes(r))) {
      throw new ForbiddenException(`Se requiere uno de estos roles: ${requeridos.join(', ')}`);
    }
    return true;
  }
}

La diferencia entre getAllAndOverride (el primero que encuentre gana; el método pisa a la clase) y getAllAndMerge (une los dos arrays) es una pregunta habitual de entrevista. Para roles casi siempre quieres Override: un método concreto puede ser más restrictivo o más laxo que su controlador.

comun/interceptores · NEST-05
export interface Sobre<T> { datos: T; meta: {correlacionId: string; ms: number} }

@Injectable()
export class EnvolturaInterceptor<T> implements NestInterceptor<T, Sobre<T>> {
  intercept(contexto: ExecutionContext, siguiente: CallHandler<T>): Observable<Sobre<T>> {
    const inicio = Date.now();
    const peticion = contexto.switchToHttp().getRequest<{correlacionId?: string}>();
    // Solo se transforma el camino feliz: los errores van al filtro de excepciones.
    return siguiente.handle().pipe(
      map((datos) => ({
        datos,
        meta: {correlacionId: peticion.correlacionId ?? '-', ms: Date.now() - inicio},
      })),
    );
  }
}

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  constructor(private readonly ms = 5_000) {}
  intercept(_c: ExecutionContext, siguiente: CallHandler): Observable<unknown> {
    return siguiente.handle().pipe(
      timeout({each: this.ms}),
      catchError((error: unknown) =>
        throwError(() => (error instanceof TimeoutError
          ? new RequestTimeoutException('La operación superó el tiempo máximo')
          : error))),
    );
  }
}

// main.ts — el orden importa: el primero es el más externo.
app.useGlobalInterceptors(new TimeoutInterceptor(5_000), new EnvolturaInterceptor());

El timeout va fuera para que cuente también el tiempo del resto de la cadena; la envoltura, dentro, para que solo vea respuestas ya producidas. Advertencia importante: timeout cancela la suscripción, pero no cancela la consulta SQL que ya está corriendo en el servidor de base de datos. Para eso necesitas statement_timeout en PostgreSQL; el interceptor solo libera al cliente.

comun/filtros/problem-details.filter.ts · NEST-06
interface ProblemDetails {
  type: string; title: string; status: number; detail?: string; instance?: string;
  correlationId?: string; errors?: Record<string, string[]>;
}

@Catch()
export class ProblemDetailsFilter implements ExceptionFilter {
  private readonly logger = new Logger(ProblemDetailsFilter.name);
  private readonly titulos: Record<number, string> = {
    400: 'Petición incorrecta', 401: 'No autenticado', 403: 'Prohibido',
    404: 'Recurso no encontrado', 409: 'Conflicto', 422: 'Entidad no procesable',
    429: 'Demasiadas peticiones', 500: 'Error interno del servidor',
  };

  catch(excepcion: unknown, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const respuesta = ctx.getResponse<Response>();
    const peticion = ctx.getRequest<Request & {correlacionId?: string}>();

    let estado = HttpStatus.INTERNAL_SERVER_ERROR;
    let detalle: string | undefined;
    let errores: Record<string, string[]> | undefined;

    if (excepcion instanceof HttpException) {
      estado = excepcion.getStatus();
      const cuerpo = excepcion.getResponse();
      if (typeof cuerpo === 'string') {
        detalle = cuerpo;
      } else if (typeof cuerpo === 'object' && cuerpo !== null) {
        const c = cuerpo as {message?: string | string[]};
        // El ValidationPipe devuelve message como array de cadenas.
        if (Array.isArray(c.message)) errores = {cuerpo: c.message};
        else detalle = c.message;
      }
    } else if (excepcion instanceof UniqueConstraintViolationException) {
      estado = HttpStatus.CONFLICT;                    // 409, no 500
      detalle = 'Ya existe un recurso con esos datos';
    } else if (excepcion instanceof NotFoundError) {   // NotFoundError de MikroORM
      estado = HttpStatus.NOT_FOUND;
    }

    const problema: ProblemDetails = {
      type: `https://api.taskflow.dev/errores/${estado}`,
      title: this.titulos[estado] ?? 'Error',
      status: estado,
      // Regla de oro: en 5xx NUNCA se devuelve el mensaje interno al cliente.
      detail: estado >= 500 ? 'Se ha producido un error inesperado' : detalle,
      instance: peticion.url,
      correlationId: peticion.correlacionId,
      ...(errores ? {errors: errores} : {}),
    };

    if (estado >= 500) {
      this.logger.error(
        {correlationId: problema.correlationId, ruta: peticion.url},
        excepcion instanceof Error ? excepcion.stack : String(excepcion));
    }

    respuesta.status(estado).type('application/problem+json').json(problema);
  }
}

Tres decisiones defendibles en una revisión: @Catch() sin argumentos captura todo, incluidos los errores no controlados, y garantiza que ninguna respuesta se escape del formato; los 4xx no se registran como error porque son culpa del cliente y llenarían el registro de ruido; y el correlationId es lo que permite que el usuario te dé un identificador por teléfono y tú encuentres la traza exacta.

Soluciones comentadas · NEST-07 y NEST-08
almacenamiento/almacenamiento.module.ts · NEST-07
export interface OpcionesAlmacenamiento { raiz: string; maxMb: number; publico: boolean }
export const OPCIONES_ALMACENAMIENTO = Symbol('OPCIONES_ALMACENAMIENTO');

export interface OpcionesAsincronas {
  imports?: ModuleMetadata['imports'];
  inject?: InjectionToken[];
  useFactory: (...args: never[]) => Promise<OpcionesAlmacenamiento> | OpcionesAlmacenamiento;
}

@Module({})
export class AlmacenamientoModule {
  static forRoot(opciones: OpcionesAlmacenamiento): DynamicModule {
    return {
      module: AlmacenamientoModule,
      global: true,                       // disponible sin reimportar en cada módulo
      providers: [
        {provide: OPCIONES_ALMACENAMIENTO, useValue: this.validar(opciones)},
        AlmacenamientoService,
      ],
      exports: [AlmacenamientoService],
    };
  }

  static forRootAsync(op: OpcionesAsincronas): DynamicModule {
    return {
      module: AlmacenamientoModule,
      global: true,
      imports: op.imports ?? [],
      providers: [
        {
          provide: OPCIONES_ALMACENAMIENTO,
          inject: op.inject ?? [],
          useFactory: async (...args: never[]) => this.validar(await op.useFactory(...args)),
        },
        AlmacenamientoService,
      ],
      exports: [AlmacenamientoService],
    };
  }

  // Fallar al arrancar es infinitamente mejor que fallar en la primera subida de un usuario.
  private static validar(o: OpcionesAlmacenamiento): OpcionesAlmacenamiento {
    if (!o.raiz) throw new Error('AlmacenamientoModule: falta "raiz"');
    if (o.maxMb <= 0 || o.maxMb > 500) throw new Error('AlmacenamientoModule: maxMb fuera de rango');
    return o;
  }
}

// Uso asíncrono, leyendo de ConfigService
AlmacenamientoModule.forRootAsync({
  inject: [ConfigService],
  useFactory: (c: ConfigService) => ({
    raiz: c.getOrThrow('ALMACEN_RAIZ'), maxMb: Number(c.get('ALMACEN_MAX_MB') ?? 20), publico: false,
  }),
});
auth/auth.service.ts · NEST-08 · rotación con detección de reutilización
@Entity()
export class RefreshToken {
  @PrimaryKey() id: string = v4();
  @Property({unique: true}) hash!: string;      // SOLO el hash: si roban la BD, no sirven
  @Property() familia!: string;                 // todos los descendientes de un login
  @ManyToOne(() => Usuario) usuario!: Usuario;
  @Property() expiraEn!: Date;
  @Property({nullable: true}) usadoEn?: Date;   // marca de consumo
  @Property({default: false}) revocado!: boolean;
}

@Injectable()
export class AuthService {
  private readonly hash = (t: string) => createHash('sha256').update(t).digest('hex');

  async rotar(refreshPlano: string): Promise<{acceso: string; refresco: string}> {
    const registro = await this.em.findOne(RefreshToken, {hash: this.hash(refreshPlano)},
      {populate: ['usuario']});

    if (!registro || registro.revocado || registro.expiraEn < new Date()) {
      throw new UnauthorizedException('Refresh token inválido');
    }

    // DETECCIÓN DE REUTILIZACIÓN: alguien está usando un token ya consumido.
    // O bien es un atacante con un token robado, o bien el usuario legítimo tras un robo.
    // En cualquier caso, la única respuesta segura es revocar TODA la familia.
    if (registro.usadoEn) {
      await this.em.nativeUpdate(RefreshToken, {familia: registro.familia}, {revocado: true});
      this.logger.warn({familia: registro.familia}, 'Reutilización de refresh token: familia revocada');
      throw new UnauthorizedException('Sesión comprometida, vuelve a iniciar sesión');
    }

    registro.usadoEn = new Date();               // consumido, no borrado: hace falta para detectar

    const nuevoPlano = randomBytes(48).toString('base64url');
    this.em.create(RefreshToken, {
      hash: this.hash(nuevoPlano),
      familia: registro.familia,                 // misma familia: encadena la sesión
      usuario: registro.usuario,
      expiraEn: new Date(Date.now() + 7 * 24 * 3600_000),
      revocado: false,
    });
    await this.em.flush();                       // una sola transacción para consumo y creación

    return {
      acceso: await this.jwt.signAsync(
        {sub: registro.usuario.id, roles: registro.usuario.roles}, {expiresIn: '15m'}),
      refresco: nuevoPlano,
    };
  }
}

Por qué este diseño y no un simple JWT de larga duración: un JWT no se puede revocar sin lista negra, así que un token robado vale hasta que caduque. Con rotación, el robo se detecta en cuanto los dos —atacante y usuario— intentan usar la misma cadena, y la familia entera se corta. Detalles imprescindibles: el refresh viaja en una cookie httpOnly, secure y sameSite=strict, no en localStorage; y el consumo más la creación deben ocurrir en la misma transacción, o una caída entre ambas operaciones deja al usuario sin sesión.

Nivel 3 · avanzado

NEST-09 · Límite de tasa en el login. Máximo 5 intentos fallidos por minuto y par IP + email, con respuesta 429 y cabecera Retry-After. Los intentos correctos no deben contar. Explica por qué limitar solo por IP es insuficiente y por qué limitar solo por email permite un ataque de denegación de servicio contra un usuario concreto.

NEST-10 · Caché con invalidación. Cachea GET /proyectos/:id durante 60 segundos y invalida la entrada en cualquier escritura sobre ese proyecto o sobre sus tareas. Debe funcionar con varias instancias del servidor.

NEST-11 · Cola BullMQ con reintentos e idempotencia. El envío del resumen diario por correo debe reintentarse con retroceso exponencial y no puede enviarse dos veces aunque el trabajo se procese dos veces (reinicio del worker, reintento tras un fallo parcial).

NEST-12 · WebSocket autenticado. Gateway que valida el JWT en la conexión, une al cliente a una sala por proyecto y emite los cambios de tarea solo a quien tiene acceso a ese proyecto.

NEST-13 · Health check. /salud/vivo y /salud/listo con Terminus: comprobación de base de datos, de memoria y un indicador propio para Redis. Explica la diferencia entre liveness y readiness y qué hace Kubernetes con cada uno.

NEST-14 · Test e2e completo de un recurso. Cubre el ciclo entero de /tareas: crear, listar con filtros, actualizar, borrar, 404, 400 de validación y 403 por rol. Con base de datos real y aislamiento entre tests.

Soluciones comentadas · NEST-09, NEST-10 y NEST-11
auth/login-throttle.guard.ts · NEST-09
// La clave combina IP y email: ninguna de las dos por separado es suficiente.
@Injectable()
export class LoginThrottleGuard extends ThrottlerGuard {
  protected async getTracker(peticion: Record<string, unknown>): Promise<string> {
    const cuerpo = (peticion['body'] ?? {}) as {email?: string};
    const ip = (peticion['ip'] as string) ?? 'desconocida';
    return `${ip}:${(cuerpo.email ?? '').toLowerCase()}`;
  }
}

@Controller('auth')
export class AuthController {
  @Publico()
  @UseGuards(LoginThrottleGuard)
  @Throttle({default: {limit: 5, ttl: 60_000}})
  @HttpCode(200)
  @Post('login')
  async login(@Body() dto: LoginDto) {
    return this.auth.login(dto);   // el ThrottlerGuard añade Retry-After al responder 429
  }
}

Por qué la clave combinada. Solo por IP: una red corporativa o un CGNAT comparten IP y bloquearías a cientos de usuarios legítimos; además, un atacante con una botnet la rota trivialmente. Solo por email: cualquiera puede bloquear la cuenta de otro enviando cinco intentos fallidos, que es un ataque de denegación de servicio dirigido. La combinación limita la fuerza bruta contra una cuenta desde un origen sin castigar a terceros. En producción se añade un límite global por IP más laxo (por ejemplo 100/min) y almacenamiento en Redis, porque el contador en memoria no sirve con varias instancias.

proyectos/proyectos.service.ts · NEST-10
@Injectable()
export class ProyectosService {
  constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache, private readonly em: EntityManager) {}

  private clave(id: string) { return `proyecto:${id}`; }

  async obtener(id: string): Promise<ProyectoDto> {
    const enCache = await this.cache.get<ProyectoDto>(this.clave(id));
    if (enCache) return enCache;

    const proyecto = await this.em.findOneOrFail(Proyecto, {id}, {populate: ['miembros']});
    const dto = aDto(proyecto);
    await this.cache.set(this.clave(id), dto, 60_000);
    return dto;
  }

  async actualizar(id: string, dto: ActualizarProyectoDto): Promise<ProyectoDto> {
    const proyecto = await this.em.findOneOrFail(Proyecto, {id});
    this.em.assign(proyecto, dto);
    await this.em.flush();
    await this.invalidar(id);          // invalidar DESPUÉS del commit, nunca antes
    return aDto(proyecto);
  }

  /** Lo llaman también TareasService y MiembrosService: cualquier escritura que afecte al proyecto. */
  async invalidar(id: string): Promise<void> {
    await this.cache.del(this.clave(id));
  }
}

Con varias instancias, el almacén debe ser Redis (CacheModule.registerAsync con cache-manager-redis-yet): una caché en memoria haría que la instancia B siguiera sirviendo datos viejos tras un cambio hecho por la A. El orden importa: si invalidas antes del flush, una lectura concurrente puede volver a cachear el valor antiguo entre la invalidación y el commit. Y el CacheInterceptor automático solo sirve para GET sin datos por usuario; si la respuesta depende de quién pregunta, o incluyes el usuario en la clave o filtras información entre cuentas (caso DEBUG-06).

colas/resumen.processor.ts · NEST-11
// 1) Encolado idempotente: el jobId determinista impide duplicar el mismo trabajo lógico.
await this.cola.add(
  'resumen-diario',
  {usuarioId, fecha: '2026-07-31'},
  {
    jobId: `resumen:${usuarioId}:2026-07-31`,   // BullMQ ignora un add con jobId ya existente
    attempts: 5,
    backoff: {type: 'exponential', delay: 2_000},
    removeOnComplete: {age: 3600, count: 1000},
    removeOnFail: {age: 24 * 3600},
  },
);

// 2) Idempotencia REAL en el procesado: el jobId no basta si el worker muere a mitad.
@Processor('correos')
export class ResumenProcessor extends WorkerHost {
  constructor(private readonly em: EntityManager, private readonly correo: Notificador) { super(); }

  async process(trabajo: Job<{usuarioId: string; fecha: string}>): Promise<void> {
    const clave = `resumen:${trabajo.data.usuarioId}:${trabajo.data.fecha}`;

    // Inserción con clave única: si ya existe, este envío ya se hizo. Es una barrera
    // atómica en la base de datos, que es la única fuente de verdad fiable.
    try {
      await this.em.insert(EnvioRealizado, {clave, creadoEn: new Date()});
    } catch (e) {
      if (e instanceof UniqueConstraintViolationException) return;   // ya enviado: éxito silencioso
      throw e;
    }

    try {
      await this.correo.enviar(/* … */);
    } catch (e) {
      // Si el envío falla, se retira la marca para que el reintento pueda volver a intentarlo.
      await this.em.nativeDelete(EnvioRealizado, {clave});
      throw e;                                   // relanzar activa el backoff de BullMQ
    }
  }
}

La lección de fondo: «al menos una vez» es lo que garantiza cualquier cola; «exactamente una vez» lo tienes que construir tú, y se construye con una clave de idempotencia persistida en una tabla con restricción única. Confiar solo en jobId falla en el escenario clásico: el worker envía el correo, muere antes de marcar el trabajo como completado y BullMQ lo reencola.

Soluciones comentadas · NEST-12, NEST-13 y NEST-14
tiempo-real/eventos.gateway.ts · NEST-12
@WebSocketGateway({namespace: '/eventos', cors: {origin: process.env['ORIGEN_WEB']}})
export class EventosGateway implements OnGatewayConnection {
  @WebSocketServer() servidor!: Server;

  constructor(private readonly jwt: JwtService, private readonly acceso: AccesoService) {}

  async handleConnection(cliente: Socket): Promise<void> {
    try {
      // El token NO se pone en la URL: queda en logs y en el historial del navegador.
      const token = cliente.handshake.auth?.['token'] as string | undefined;
      if (!token) throw new Error('sin token');
      const carga = await this.jwt.verifyAsync<{sub: string}>(token);
      cliente.data['usuarioId'] = carga.sub;
    } catch {
      cliente.disconnect(true);        // desconexión inmediata: nunca se une a ninguna sala
    }
  }

  @SubscribeMessage('unirse-proyecto')
  async unirse(@ConnectedSocket() cliente: Socket, @MessageBody() proyectoId: string): Promise<void> {
    const usuarioId = cliente.data['usuarioId'] as string;
    // Autorización por mensaje: estar conectado no da derecho a escuchar cualquier proyecto.
    if (!(await this.acceso.puedeVerProyecto(usuarioId, proyectoId))) {
      throw new WsException('Sin acceso a ese proyecto');
    }
    await cliente.join(`proyecto:${proyectoId}`);
  }

  emitirCambioTarea(proyectoId: string, tarea: TareaDto): void {
    this.servidor.to(`proyecto:${proyectoId}`).emit('tarea-actualizada', tarea);
  }
}
salud/salud.controller.ts · NEST-13
@Controller('salud')
export class SaludController {
  constructor(
    private readonly salud: HealthCheckService,
    private readonly orm: MikroOrmHealthIndicator,
    private readonly memoria: MemoryHealthIndicator,
    private readonly redis: RedisHealthIndicator,       // indicador propio
  ) {}

  /** LIVENESS: ¿el proceso está vivo? No comprueba dependencias externas. */
  @Publico() @Get('vivo')
  vivo() { return {estado: 'ok', tiempoActivo: process.uptime()}; }

  /** READINESS: ¿puede atender tráfico? Aquí sí se comprueban las dependencias. */
  @Publico() @Get('listo')
  @HealthCheck()
  listo() {
    return this.salud.check([
      () => this.orm.pingCheck('base-datos', {timeout: 1_500}),
      () => this.redis.estaDisponible('redis'),
      () => this.memoria.checkHeap('memoria', 400 * 1024 * 1024),
    ]);
  }
}

La distinción es operativamente crítica. Si liveness falla, Kubernetes reinicia el contenedor; si readiness falla, solo lo saca del balanceador hasta que se recupere. El error clásico es comprobar la base de datos en liveness: cuando la base de datos parpadea, Kubernetes reinicia en cascada todos los pods sanos y convierte una incidencia menor en una caída total.

test/tareas.e2e-spec.ts · NEST-14
describe('Tareas (e2e)', () => {
  let app: INestApplication;
  let orm: MikroORM;
  let tokenMiembro: string;
  let tokenLector: string;

  beforeAll(async () => {
    const modulo = await Test.createTestingModule({imports: [AppModule]})
      .overrideProvider(NOTIFICADOR).useClass(NotificadorEnMemoria)
      .compile();

    app = modulo.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({whitelist: true, forbidNonWhitelisted: true, transform: true}));
    app.useGlobalFilters(new ProblemDetailsFilter());
    await app.init();

    orm = app.get(MikroORM);
    await orm.getSchemaGenerator().refreshDatabase();   // esquema limpio, base de datos real
    ({tokenMiembro, tokenLector} = await sembrarUsuarios(orm));
  });

  // Aislamiento: cada test parte del mismo estado conocido.
  beforeEach(async () => { await orm.getSchemaGenerator().clearDatabase(); await sembrarProyecto(orm); });
  afterAll(async () => { await app.close(); });

  it('POST /tareas crea y devuelve 201 con Location', async () => {
    const res = await request(app.getHttpServer())
      .post('/tareas')
      .set('Authorization', `Bearer ${tokenMiembro}`)
      .send({titulo: 'Escribir el test e2e', proyectoId: PROYECTO_ID, prioridad: 'alta'})
      .expect(201);

    expect(res.body.datos).toMatchObject({titulo: 'Escribir el test e2e', estado: 'pendiente'});
    expect(res.headers['location']).toMatch(/^\/tareas\//);
  });

  it('POST /tareas rechaza campos desconocidos con 400 y formato problem+json', async () => {
    const res = await request(app.getHttpServer())
      .post('/tareas')
      .set('Authorization', `Bearer ${tokenMiembro}`)
      .send({titulo: 'x', proyectoId: PROYECTO_ID, esAdmin: true})
      .expect(400)
      .expect('Content-Type', /application\/problem\+json/);
    expect(res.body.errors.cuerpo.join(' ')).toContain('esAdmin');
  });

  it('GET /tareas filtra y pagina', async () => {
    const res = await request(app.getHttpServer())
      .get('/tareas?estado=pendiente&pagina=1&tamano=2&orden=-vence')
      .set('Authorization', `Bearer ${tokenMiembro}`)
      .expect(200);
    expect(res.body.datos.length).toBeLessThanOrEqual(2);
  });

  it('DELETE /tareas/:id devuelve 403 para el rol lector', async () => {
    await request(app.getHttpServer())
      .delete(`/tareas/${TAREA_ID}`)
      .set('Authorization', `Bearer ${tokenLector}`)
      .expect(403);
  });

  it('GET /tareas/:id inexistente devuelve 404', async () => {
    await request(app.getHttpServer())
      .get('/tareas/00000000-0000-0000-0000-000000000000')
      .set('Authorization', `Bearer ${tokenMiembro}`)
      .expect(404);
  });
});

Dos decisiones importantes. Primera: base de datos real (PostgreSQL en Docker o Testcontainers), no SQLite en memoria; si el destino es PostgreSQL, un test contra SQLite no prueba los tipos, los índices parciales ni el comportamiento transaccional que vas a usar. Segunda: el aislamiento entre tests debe ser explícito. Limpiar y sembrar en beforeEach es lento pero infalible; envolver cada test en una transacción con rollback es mucho más rápido, pero solo funciona si el código bajo prueba no abre transacciones propias.

22.5 Retos de MikroORM

Catorce retos sobre el mapeo, las consultas y la concurrencia. Aquí es donde se pierden los proyectos: un modelo mal cardinalizado o un N+1 en la pantalla principal cuestan más que cualquier decisión de frontend. Todo el código es MikroORM 6 con PostgreSQL.

Nivel 1 · modelado

ORM-01 · Las cuatro cardinalidades. Modela este dominio: un Usuario tiene un Perfil (1:1); un Proyecto tiene muchas Tareas (1:N); una Tarea tiene muchas Etiquetas y una etiqueta está en muchas tareas (N:M); y una Tarea puede depender de otra (auto-referencia N:1). Indica en cada relación qué lado es el propietario y dónde cae la clave foránea.

ORM-02 · Entidad pivote con atributos. La pertenencia de un usuario a un proyecto tiene rol y fecha de alta. Explica por qué un @ManyToMany puro no vale y modela MiembroProyecto con clave primaria compuesta.

ORM-03 · orphanRemoval frente a cascade. Un proyecto tiene tareas y una tarea tiene comentarios. Decide qué se borra en cascada, qué se queda huérfano y qué debe impedirse. Escribe el test que demuestra la diferencia entre cascade: [Cascade.REMOVE] y orphanRemoval: true.

Soluciones comentadas · ORM-01, ORM-02 y ORM-03
// ORM-01 · Las cuatro cardinalidades
@Entity()
export class Usuario {
  @PrimaryKey() id: string = v4();
  @Property({unique: true}) email!: string;
  // 1:1 propietario: la FK perfil_id vive en la tabla usuario.
  @OneToOne(() => Perfil, (p) => p.usuario, {owner: true, orphanRemoval: true}) perfil!: Perfil;
}

@Entity()
export class Proyecto {
  @PrimaryKey() id: string = v4();
  @Property() nombre!: string;
  // 1:N lado inverso: NO hay columna en proyecto; la FK está en tarea.proyecto_id.
  @OneToMany(() => Tarea, (t) => t.proyecto) tareas = new Collection<Tarea>(this);
}

@Entity()
@Index({properties: ['proyecto', 'estado']})   // el índice sigue al patrón de consulta real
export class Tarea {
  @PrimaryKey() id: string = v4();
  @Property({length: 160}) titulo!: string;
  @Enum(() => Estado) estado: Estado = Estado.Pendiente;

  // N:1 propietario: aquí está la FK. Es SIEMPRE el lado propietario en un 1:N.
  @ManyToOne(() => Proyecto, {deleteRule: 'cascade'}) proyecto!: Proyecto;

  // N:M propietario: MikroORM crea la tabla pivote tarea_etiquetas.
  @ManyToMany(() => Etiqueta, (e) => e.tareas, {owner: true}) etiquetas = new Collection<Etiqueta>(this);

  // Auto-referencia N:1: una tarea bloquea a otra.
  @ManyToOne(() => Tarea, {nullable: true}) dependeDe?: Tarea;
  @OneToMany(() => Tarea, (t) => t.dependeDe) bloquea = new Collection<Tarea>(this);
}

Regla que no falla: la clave foránea vive siempre en el lado @ManyToOne, y ese es el lado propietario. En un @OneToMany puro no hay ninguna columna nueva; si añades un elemento a la colección sin asignar el lado @ManyToOne, MikroORM lo resuelve, pero conviene asignar los dos lados para que el objeto en memoria sea coherente antes del flush.

// ORM-02 · Pivote con atributos: un @ManyToMany puro no puede almacenar rol ni fecha.
@Entity()
export class MiembroProyecto {
  @ManyToOne(() => Usuario, {primary: true}) usuario!: Usuario;
  @ManyToOne(() => Proyecto, {primary: true}) proyecto!: Proyecto;
  // La PK compuesta impide por diseño que alguien sea miembro dos veces del mismo proyecto.
  [PrimaryKeyProp]?: ['usuario', 'proyecto'];

  @Enum(() => RolProyecto) rol: RolProyecto = RolProyecto.Lector;
  @Property() altaEn: Date = new Date();
  @Property({nullable: true}) bajaEn?: Date;
}

La clave primaria compuesta es preferible a un id sintético más un índice único: expresa el invariante en el propio esquema y ahorra una columna y un índice. El coste es que las relaciones hacia esta entidad necesitan dos columnas.

// ORM-03 · Semántica de borrado, decidida por el dominio
@Entity()
export class Tarea {
  // Los comentarios NO tienen vida propia: si desaparece la tarea, desaparecen.
  @OneToMany(() => Comentario, (c) => c.tarea, {orphanRemoval: true})
  comentarios = new Collection<Comentario>(this);

  // Los adjuntos se conservan en un archivo documental aunque se borre la tarea.
  @OneToMany(() => Adjunto, (a) => a.tarea) adjuntos = new Collection<Adjunto>(this);
}

// El test que demuestra la diferencia
it('orphanRemoval borra al sacar de la colección; cascade REMOVE solo al borrar el padre', async () => {
  const tarea = await em.findOneOrFail(Tarea, {id}, {populate: ['comentarios']});
  const comentario = tarea.comentarios[0]!;

  tarea.comentarios.remove(comentario);   // solo se desvincula…
  await em.flush();
  // …pero con orphanRemoval: true la fila se BORRA, no queda con FK nula.
  expect(await em.count(Comentario, {id: comentario.id})).toBe(0);
});

Los tres conceptos que se confunden. cascade: [Cascade.REMOVE] propaga el borrado del padre a los hijos, y lo hace en memoria, cargando las entidades. orphanRemoval: true hace eso y además borra al hijo cuando se le saca de la colección. deleteRule: 'cascade' es una cláusula ON DELETE CASCADE en la base de datos: es la más rápida (una sola sentencia) pero invisible para el ORM, así que los hooks y suscriptores de las entidades hijas no se ejecutan. Elige el nivel de base de datos para volúmenes grandes y el nivel de ORM cuando necesites lógica de dominio.

Nivel 2 · consultas y rendimiento

ORM-04 · Diagnosticar un N+1. El listado de 50 proyectos genera este registro. Explica qué está pasando, cuántas consultas se ejecutan y corrígelo sin cambiar el resultado.

select "p"."id", "p"."nombre" from "proyecto" as "p" limit 50;
select "t".* from "tarea" as "t" where "t"."proyecto_id" = 'a1';
select "t".* from "tarea" as "t" where "t"."proyecto_id" = 'a2';
select "t".* from "tarea" as "t" where "t"."proyecto_id" = 'a3';
-- … 47 líneas más idénticas …

ORM-05 · Consulta paginada con filtros dinámicos. GET /tareas admite estado, prioridad, texto, etiquetas (varias, en Y), venceAntesDe y asignadoA, todos opcionales y combinables. Devuelve datos y total en una sola pasada, sin concatenar cadenas SQL.

ORM-06 · Agregados con HAVING. Con QueryBuilder: proyectos con más de 10 tareas pendientes, mostrando el nombre, el total de tareas, las pendientes y la media de días de retraso, ordenado por retraso descendente.

ORM-07 · Paginación por cursor. Sustituye LIMIT/OFFSET por un cursor estable sobre (vence, id). Explica qué problema resuelve y qué pierdes.

Soluciones comentadas · ORM-04 a ORM-07
// ORM-04 · Son 51 consultas: 1 del listado + 1 por proyecto al tocar la colección.
// MAL: la colección se carga perezosamente dentro del bucle.
const proyectos = await em.find(Proyecto, {}, {limit: 50});
for (const p of proyectos) console.log(p.nombre, (await p.tareas.loadItems()).length);

// BIEN (opción A): populate declarativo. MikroORM emite UNA segunda consulta con IN (…).
const proyectos = await em.find(Proyecto, {}, {populate: ['tareas'], limit: 50});

// BIEN (opción B): si solo necesitas el recuento, no traigas las filas.
const filas = await em.createQueryBuilder(Proyecto, 'p')
  .select(['p.id', 'p.nombre', 'count(t.id) as total'])
  .leftJoin('p.tareas', 't')
  .groupBy('p.id')
  .limit(50)
  .execute<Array<{id: string; nombre: string; total: string}>>();

Cómo se detecta antes de que llegue a producción: activa debug: ['query'] en desarrollo y pon un umbral en los tests («esta ruta no debe emitir más de 3 consultas») usando un contador sobre el logger. Un N+1 no se ve en local con 10 filas de prueba; se ve en producción con 5.000.

// ORM-05 · Filtros dinámicos: se construye un objeto FilterQuery, nunca una cadena SQL.
async function listar(f: FiltroTareas, pagina: number, tamano: number) {
  const where: FilterQuery<Tarea> = {};
  if (f.estado)        where.estado = f.estado;
  if (f.prioridad?.length) where.prioridad = {$in: f.prioridad};
  if (f.venceAntesDe)  where.vence = {$lte: f.venceAntesDe};
  if (f.asignadoA)     where.asignado = f.asignadoA;
  if (f.texto)         where.titulo = {$ilike: `%${f.texto}%`};   // parametrizado por el ORM
  // Etiquetas en Y: una condición por etiqueta, no un $in (que sería una O).
  if (f.etiquetas?.length) where.$and = f.etiquetas.map((e) => ({etiquetas: {nombre: e}}));

  const [datos, total] = await em.findAndCount(Tarea, where, {
    populate: ['asignado', 'etiquetas'],
    orderBy: {vence: 'ASC', id: 'ASC'},     // desempate estable: sin él la paginación baila
    limit: tamano,
    offset: (pagina - 1) * tamano,
  });
  return {datos, total, pagina, tamano};
}
// ORM-06 · Agregados con HAVING
const filas = await em.createQueryBuilder(Proyecto, 'p')
  .select(['p.id', 'p.nombre'])
  .addSelect('count(t.id) as total')
  .addSelect(`count(t.id) filter (where t.estado = 'pendiente') as pendientes`)
  .addSelect(`avg(case when t.vence < now() and t.estado <> 'hecha'
                       then extract(day from now() - t.vence) end) as dias_retraso`)
  .leftJoin('p.tareas', 't')
  .groupBy(['p.id', 'p.nombre'])
  .having(`count(t.id) filter (where t.estado = 'pendiente') > ?`, [10])
  .orderBy({dias_retraso: 'DESC NULLS LAST'})
  .execute<FilaResumen[]>();

WHERE filtra filas antes de agrupar; HAVING filtra grupos después. Poner estado = 'pendiente' en el WHERE daría un resultado distinto: perderías los proyectos sin ninguna pendiente y el total dejaría de ser el total.

// ORM-07 · Paginación por cursor sobre (vence, id)
interface Cursor { vence: string; id: string }
const codificar = (c: Cursor) => Buffer.from(JSON.stringify(c)).toString('base64url');
const decodificar = (s: string) => JSON.parse(Buffer.from(s, 'base64url').toString()) as Cursor;

async function pagina(cursor: string | undefined, tamano = 20) {
  const where: FilterQuery<Tarea> = {};
  if (cursor) {
    const c = decodificar(cursor);
    // Comparación lexicográfica de la tupla: «lo que vaya después de (vence, id)».
    where.$or = [{vence: {$gt: c.vence}}, {vence: c.vence, id: {$gt: c.id}}];
  }
  const filas = await em.find(Tarea, where, {orderBy: {vence: 'ASC', id: 'ASC'}, limit: tamano + 1});
  const hayMas = filas.length > tamano;
  const datos = hayMas ? filas.slice(0, tamano) : filas;
  const ultimo = datos.at(-1);
  return {
    datos,
    siguiente: hayMas && ultimo ? codificar({vence: ultimo.vence.toISOString(), id: ultimo.id}) : null,
  };
}

Qué resuelve. Dos cosas. Rendimiento: OFFSET 100000 obliga a PostgreSQL a leer y descartar cien mil filas, y el tiempo crece linealmente con la página; el cursor usa el índice (vence, id) y cuesta lo mismo en la página 1 que en la 5.000. Corrección: con OFFSET, si alguien inserta una fila mientras paginas, verás un elemento repetido o te saltarás otro. Qué pierdes: no puedes saltar a la página 37 ni mostrar «página 3 de 200», y el orden debe ser único y estable (de ahí el desempate por id). Regla práctica: cursor para scroll infinito y APIs; offset para tablas de administración con pocas páginas.

Nivel 3 · concurrencia, esquema y volumen

ORM-08 · Transacción con bloqueo pesimista. Mover una tarea entre columnas de un tablero recalculando la posición de las demás. Dos usuarios que arrastran a la vez no pueden dejar posiciones duplicadas. Pista: LockMode.PESSIMISTIC_WRITE.

ORM-09 · Bloqueo optimista y 409. Añade versionado a Tarea y traduce el conflicto en una respuesta 409 con los datos actuales, para que el cliente pueda resolver el conflicto.

ORM-10 · Soft delete. Filtro global que oculta las borradas, más un índice único parcial que permita reutilizar el título de una tarea borrada pero no duplicarlo entre las vivas.

ORM-11 · Migración expand/contract. Renombra tarea.desc a tarea.descripcion sin parar el servicio, sabiendo que durante el despliegue conviven la versión vieja y la nueva del código. Describe las tres migraciones y en qué orden se despliegan.

ORM-12 · Seeder con factorías. Genera 5 proyectos, 20 usuarios y 500 tareas con datos realistas y reproducibles (semilla fija).

ORM-13 · Suscriptor de auditoría. Registra automáticamente toda creación, modificación y borrado de Tarea, guardando qué campos cambiaron, con qué valores y quién lo hizo.

ORM-14 · Importación masiva. Importa un CSV de 100.000 filas sin que el proceso supere 300 MB de memoria y en menos de un minuto.

Soluciones comentadas · ORM-08, ORM-09 y ORM-10
// ORM-08 · Bloqueo pesimista: se serializa el acceso a las filas implicadas.
await em.transactional(async (em) => {
  // SELECT … FOR UPDATE: cualquier otra transacción que pida estas filas espera aquí.
  const destino = await em.find(Tarea,
    {columna: columnaDestinoId},
    {lockMode: LockMode.PESSIMISTIC_WRITE, orderBy: {posicion: 'ASC'}});
    // orderBy en TODAS las transacciones que bloqueen: bloquear en distinto orden = interbloqueo.

  const tarea = await em.findOneOrFail(Tarea, {id: tareaId}, {lockMode: LockMode.PESSIMISTIC_WRITE});

  tarea.columna = em.getReference(Columna, columnaDestinoId);
  destino.filter((t) => t.id !== tarea.id && t.posicion >= nuevaPosicion)
         .forEach((t) => { t.posicion += 1; });
  tarea.posicion = nuevaPosicion;
});   // el commit libera los bloqueos

Coste real del bloqueo pesimista: las transacciones se serializan, así que la transacción debe ser corta y no contener llamadas HTTP ni envíos de correo. Mantén siempre el mismo orden de bloqueo (por eso el orderBy) o dos transacciones que bloqueen A→B y B→A se quedarán en interbloqueo hasta que PostgreSQL mate a una.

// ORM-09 · Bloqueo optimista: sin bloqueos, se detecta el conflicto al escribir.
@Entity()
export class Tarea {
  @Property({version: true}) version!: number;   // UPDATE … WHERE id = ? AND version = ?
}

// En el servicio: la versión llega del cliente (cabecera If-Match o campo del DTO).
async actualizar(id: string, dto: ActualizarTareaDto, versionCliente: number) {
  const tarea = await this.em.findOneOrFail(Tarea, {id});
  try {
    this.em.assign(tarea, dto);
    await this.em.lock(tarea, LockMode.OPTIMISTIC, {lockVersion: versionCliente});
    await this.em.flush();
    return tarea;
  } catch (e) {
    if (e instanceof OptimisticLockError) {
      // 409 con el estado actual: el cliente puede mostrar un diff y dejar decidir al usuario.
      throw new ConflictException({
        title: 'La tarea fue modificada por otra persona',
        actual: aDto(await this.em.refresh(tarea) ?? tarea),
      });
    }
    throw e;
  }
}

Optimista frente a pesimista: usa el optimista cuando el conflicto es raro (edición de un formulario por dos personas) porque no cuesta nada mientras no ocurra; usa el pesimista cuando el conflicto es frecuente y caro (inventario, asientos, saldos). Nunca uses «leer, comprobar en JavaScript y escribir» sin ninguno de los dos: entre la lectura y la escritura cabe otra transacción entera.

// ORM-10 · Soft delete con filtro global
@Entity()
@Filter({name: 'noBorradas', cond: {borradaEn: null}, default: true})
export class Tarea {
  @Property({nullable: true, index: true}) borradaEn?: Date;
}

// Consultas normales: el filtro se aplica solo. Para verlas todas:
const todas = await em.find(Tarea, {}, {filters: {noBorradas: false}});
-- El índice único parcial: la unicidad solo se exige entre las filas vivas.
create unique index tarea_titulo_unico_vivas
  on tarea (proyecto_id, lower(titulo))
  where borrada_en is null;

Sin la cláusula where, borrar una tarea llamada «Desplegar» impediría para siempre crear otra con ese nombre, lo que produce el ticket de soporte más desconcertante posible. Ten en cuenta también que un filtro global es fácil de saltarse: las consultas nativas, el QueryBuilder con disableFilters y las claves foráneas de la base de datos no lo conocen.

Soluciones comentadas · ORM-11 a ORM-14
-- ORM-11 · Expand / migrate / contract: tres despliegues, cero tiempo de inactividad.

-- (1) EXPAND. Se despliega ANTES que el código nuevo. Compatible con el código viejo.
alter table tarea add column descripcion text;
update tarea set descripcion = "desc" where descripcion is null;
-- Disparador que mantiene ambas columnas sincronizadas mientras convivan las dos versiones:
create function sync_desc() returns trigger as $$
begin
  new.descripcion := coalesce(new.descripcion, new."desc");
  new."desc" := coalesce(new."desc", new.descripcion);
  return new;
end $$ language plpgsql;
create trigger tarea_sync_desc before insert or update on tarea
  for each row execute function sync_desc();

-- (2) Se despliega el CÓDIGO NUEVO, que solo usa `descripcion`. Se espera a que
--     todas las instancias viejas hayan desaparecido (y a poder revertir con seguridad).

-- (3) CONTRACT. Despliegue posterior, cuando ya nadie usa la columna vieja.
drop trigger tarea_sync_desc on tarea;
drop function sync_desc();
alter table tarea drop column "desc";

Un ALTER TABLE … RENAME COLUMN directo rompe el servicio durante los segundos o minutos en que conviven las dos versiones del código: las instancias viejas consultan una columna que ya no existe. La regla general de las migraciones sin parada: cada migración debe ser compatible con la versión anterior y con la siguiente del código. Lo mismo aplica a añadir una columna NOT NULL (añádela nullable, rellena y luego impón la restricción) y a borrar cualquier cosa.

// ORM-12 · Seeder con factorías reproducibles
export class TareaFactory extends Factory<Tarea> {
  model = Tarea;
  protected definition(faker: Faker): EntityData<Tarea> {
    return {
      titulo: faker.hacker.phrase().slice(0, 120),
      prioridad: faker.helpers.arrayElement(['baja', 'media', 'alta']),
      estado: faker.helpers.weightedArrayElement([
        {weight: 6, value: 'pendiente'}, {weight: 3, value: 'en_curso'}, {weight: 1, value: 'hecha'},
      ]),
      vence: faker.date.between({from: '2026-01-01', to: '2026-12-31'}),
    };
  }
}

export class DatabaseSeeder extends Seeder {
  async run(em: EntityManager): Promise<void> {
    faker.seed(20260731);          // semilla fija: los datos son IDÉNTICOS en cada ejecución
    const usuarios = new UsuarioFactory(em).make(20);
    for (const proyecto of new ProyectoFactory(em).make(5)) {
      new TareaFactory(em).each((t) => {
        t.proyecto = proyecto;
        t.asignado = faker.helpers.arrayElement(usuarios);
      }).make(100);
    }
    await em.flush();              // un único flush: una transacción, no 500
  }
}
// ORM-13 · Suscriptor de auditoría
@Injectable()
export class AuditoriaSubscriber implements EventSubscriber<Tarea> {
  constructor(em: EntityManager, private readonly ctx: ContextoPeticionService) {
    em.getEventManager().registerSubscriber(this);
  }
  getSubscribedEntities() { return [Tarea]; }

  // onFlush es el ÚNICO momento en que el changeset original sigue disponible.
  async onFlush(args: FlushEventArgs): Promise<void> {
    const uow = args.uow;
    for (const cambio of uow.getChangeSets()) {
      if (!(cambio.entity instanceof Tarea)) continue;

      const registro = args.em.create(Auditoria, {
        entidad: 'Tarea',
        entidadId: cambio.entity.id,
        operacion: cambio.type,                       // create | update | delete
        cambios: cambio.payload,                      // solo los campos modificados
        anterior: cambio.originalEntity ?? null,
        usuarioId: this.ctx.usuarioActual()?.id ?? null,
        creadoEn: new Date(),
      });
      // Hay que registrar el nuevo objeto en la MISMA unidad de trabajo, o no se persistirá.
      uow.computeChangeSet(registro);
    }
  }
}

Los suscriptores son la respuesta correcta a «necesito que esto pase siempre, sin que nadie tenga que acordarse»: auditoría, invalidación de caché, publicación de eventos de dominio. La trampa es el momento: en afterFlush el changeset ya no tiene los valores anteriores, y en onFlush hay que llamar a computeChangeSet para que las entidades creadas dentro del suscriptor entren en la misma transacción.

// ORM-14 · 100.000 filas con memoria acotada
import {createReadStream} from 'node:fs';
import {parse} from 'csv-parse';

const TAMANO_LOTE = 1_000;

export async function importar(ruta: string, orm: MikroORM): Promise<number> {
  const em = orm.em.fork();                    // NUNCA el em global en un proceso largo
  const flujo = createReadStream(ruta).pipe(parse({columns: true, trim: true}));

  let lote: EntityData<Tarea>[] = [];
  let total = 0;

  const volcar = async () => {
    if (lote.length === 0) return;
    // insertMany omite el Unit of Work: no crea entidades gestionadas ni dispara hooks.
    await em.insertMany(Tarea, lote);
    total += lote.length;
    lote = [];
    em.clear();          // vacía el Identity Map: SIN ESTO la memoria crece sin límite
  };

  for await (const fila of flujo) {            // el stream aplica contrapresión: no lee todo el CSV
    lote.push({id: v4(), titulo: fila.titulo, proyecto: fila.proyecto_id, estado: 'pendiente'});
    if (lote.length >= TAMANO_LOTE) await volcar();
  }
  await volcar();
  return total;
}

Los tres errores que hacen explotar la memoria: leer el archivo entero con readFileSync en lugar de un flujo; hacer em.persist() de las 100.000 entidades y un solo flush al final (el Identity Map las retiene todas); y olvidar em.clear() entre lotes, que es exactamente el mismo problema en cámara lenta. Con esta versión la memoria se mantiene plana porque en ningún momento hay más de 1.000 objetos vivos.

22.6 Retos de SQL

Un ORM no te exime de saber SQL: te exime de escribirlo casi siempre. El día que una consulta tarda ocho segundos, la herramienta es EXPLAIN, no la documentación del ORM. Estos diez retos usan el esquema de TaskFlow.

usuario(id, email, nombre, creado_en)
proyecto(id, nombre, propietario_id → usuario, creado_en, archivado_en)
miembro_proyecto(usuario_id → usuario, proyecto_id → proyecto, rol, alta_en)   PK compuesta
tarea(id, proyecto_id → proyecto, asignado_id → usuario, titulo, estado,
      prioridad, vence, creado_en, actualizado_en, borrada_en)
comentario(id, tarea_id → tarea, autor_id → usuario, padre_id → comentario, texto, creado_en)
etiqueta(id, nombre)      tarea_etiqueta(tarea_id → tarea, etiqueta_id → etiqueta)
auditoria(id, entidad, entidad_id, operacion, cambios jsonb, usuario_id, creado_en)
SQL-01 a SQL-05 · consultas

SQL-01. Lista título de la tarea, nombre del proyecto y correo del asignado, para las tareas pendientes del proyecto «Rediseño», incluyendo las que no tienen a nadie asignado.

SQL-02. Proyectos con más de 10 tareas pendientes: nombre, total, pendientes y porcentaje completado, ordenados por pendientes descendente.

SQL-03. Con una subconsulta correlacionada: por cada usuario, su tarea pendiente más urgente (la de fecha de vencimiento más próxima).

SQL-04. CTE recursiva: el hilo completo de comentarios de una tarea, con el nivel de anidamiento y ordenado de forma que cada respuesta salga bajo su padre.

SQL-05. Función de ventana: los 3 usuarios con más tareas completadas por proyecto.

Soluciones SQL-01 a SQL-05
-- SQL-01 · LEFT JOIN en el asignado (puede no existir), INNER en el proyecto (siempre existe).
select t.titulo, p.nombre as proyecto, u.email as asignado
from tarea t
join proyecto p on p.id = t.proyecto_id
left join usuario u on u.id = t.asignado_id
where p.nombre = 'Rediseño' and t.estado = 'pendiente' and t.borrada_en is null
order by t.vence nulls last;

-- SQL-02 · FILTER es la forma limpia del agregado condicional en PostgreSQL.
select p.nombre,
       count(t.id)                                        as total,
       count(t.id) filter (where t.estado = 'pendiente')   as pendientes,
       round(100.0 * count(t.id) filter (where t.estado = 'hecha')
             / nullif(count(t.id), 0), 1)                  as pct_completado
from proyecto p
left join tarea t on t.proyecto_id = p.id and t.borrada_en is null
group by p.id, p.nombre
having count(t.id) filter (where t.estado = 'pendiente') > 10
order by pendientes desc;

-- SQL-03 · Subconsulta correlacionada: se evalúa una vez por fila externa.
select u.email,
       (select t.titulo from tarea t
        where t.asignado_id = u.id and t.estado = 'pendiente' and t.borrada_en is null
        order by t.vence asc nulls last, t.id
        limit 1) as tarea_mas_urgente
from usuario u
order by u.email;
-- Alternativa casi siempre más rápida en PostgreSQL: LATERAL.
select u.email, x.titulo
from usuario u
left join lateral (
  select t.titulo from tarea t
  where t.asignado_id = u.id and t.estado = 'pendiente'
  order by t.vence asc nulls last, t.id limit 1
) x on true;

-- SQL-04 · CTE recursiva sobre comentarios anidados.
with recursive hilo as (
  select c.id, c.padre_id, c.texto, c.creado_en, 0 as nivel,
         -- camino jerárquico: fecha + id como texto, para ordenar hijos bajo su padre
         array[to_char(c.creado_en, 'YYYYMMDDHH24MISS') || ':' || c.id::text] as camino
  from comentario c
  where c.tarea_id = $1 and c.padre_id is null
  union all
  select h2.id, h2.padre_id, h2.texto, h2.creado_en, hilo.nivel + 1,
         hilo.camino || (to_char(h2.creado_en, 'YYYYMMDDHH24MISS') || ':' || h2.id::text)
  from comentario h2
  join hilo on h2.padre_id = hilo.id
  where hilo.nivel < 20                     -- cortafuegos contra ciclos y árboles patológicos
)
select repeat('  ', nivel) || texto as comentario, nivel
from hilo
order by camino;

-- SQL-05 · Ranking por partición.
with completadas as (
  select t.proyecto_id, t.asignado_id, count(*) as n
  from tarea t
  where t.estado = 'hecha' and t.asignado_id is not null
  group by t.proyecto_id, t.asignado_id
), ordenadas as (
  select c.*, dense_rank() over (partition by c.proyecto_id order by c.n desc) as puesto
  from completadas c
)
select p.nombre as proyecto, u.email, o.n as completadas, o.puesto
from ordenadas o
join proyecto p on p.id = o.proyecto_id
join usuario  u on u.id = o.asignado_id
where o.puesto <= 3
order by p.nombre, o.puesto;

Los dos errores clásicos. En SQL-01, poner la condición del asignado en el WHERE en lugar de en el ON convierte un LEFT JOIN en un INNER JOIN encubierto y hace desaparecer las tareas sin asignar. En SQL-05, row_number() daría exactamente 3 filas rompiendo empates de forma arbitraria; rank() deja huecos tras un empate; dense_rank() no los deja. Elige según lo que signifique «los 3 primeros» en tu dominio.

SQL-06 a SQL-10 · análisis, rendimiento y escritura

SQL-06. Función de ventana acumulada: tareas creadas por semana en 2026 y total acumulado del año hasta cada semana.

SQL-07. Tareas «zombis»: pendientes, creadas hace más de 30 días y sin ningún comentario ni cambio de estado en los últimos 14 días.

SQL-08. Dado este plan, di qué índice falta y por qué. Escribe el CREATE INDEX y estima la mejora.

EXPLAIN (ANALYZE, BUFFERS) select * from tarea
where proyecto_id = 'a1' and estado = 'pendiente' order by vence limit 20;

Limit  (cost=48210.31..48210.36 rows=20 width=210) (actual time=812.4..812.4 rows=20 loops=1)
  ->  Sort  (cost=48210.31..48284.9 rows=29836) (actual time=812.4..812.4 rows=20 loops=1)
        Sort Key: vence
        Sort Method: top-N heapsort  Memory: 41kB
        ->  Seq Scan on tarea  (cost=0.00..47416.0 rows=29836) (actual time=0.1..788.2 rows=29102)
              Filter: ((proyecto_id = 'a1') AND (estado = 'pendiente'))
              Rows Removed by Filter: 1970898
              Buffers: shared hit=124 read=31892
Planning Time: 0.2 ms   Execution Time: 812.6 ms

SQL-09. Upsert: importa etiquetas evitando duplicados por nombre en minúsculas y devolviendo el identificador tanto si se insertó como si ya existía.

SQL-10. Auditoría: quién cambió el estado de las tareas del proyecto «Rediseño» en los últimos 7 días, con el valor anterior y el nuevo, leyendo del jsonb.

Soluciones SQL-06 a SQL-10
-- SQL-06 · Acumulado con marco de ventana explícito.
select date_trunc('week', creado_en)::date as semana,
       count(*) as creadas,
       sum(count(*)) over (order by date_trunc('week', creado_en)
                           rows between unbounded preceding and current row) as acumulado
from tarea
where creado_en >= date '2026-01-01' and creado_en < date '2027-01-01'
group by 1
order by 1;
-- sum(count(*)) no es un error: el agregado se calcula primero y la ventana se aplica al resultado.

-- SQL-07 · NOT EXISTS es más legible y suele ser más rápido que un LEFT JOIN … IS NULL.
select t.id, t.titulo, t.creado_en, t.actualizado_en
from tarea t
where t.estado = 'pendiente'
  and t.borrada_en is null
  and t.creado_en < now() - interval '30 days'
  and t.actualizado_en < now() - interval '14 days'
  and not exists (
    select 1 from comentario c
    where c.tarea_id = t.id and c.creado_en >= now() - interval '14 days'
  )
order by t.creado_en;

-- SQL-08 · Diagnóstico: Seq Scan sobre 2.000.000 de filas, 1.970.898 descartadas por el filtro,
-- 31.892 bloques leídos de disco y una ordenación posterior. No hay índice utilizable.
create index concurrently tarea_proyecto_estado_vence
  on tarea (proyecto_id, estado, vence)
  where borrada_en is null;
-- El orden de las columnas importa: primero las de igualdad (proyecto_id, estado) y después
-- la del ORDER BY (vence). Así el índice sirve para filtrar Y para ordenar, y el LIMIT 20
-- puede parar tras leer 20 entradas: el plan pasa a Index Scan y de ~810 ms a <1 ms.
-- CONCURRENTLY evita bloquear la tabla en producción (no puede ir dentro de una transacción).

-- SQL-09 · Upsert idempotente. Requiere el índice único correspondiente.
create unique index if not exists etiqueta_nombre_unico on etiqueta (lower(nombre));

insert into etiqueta (id, nombre)
values (gen_random_uuid(), $1)
on conflict (lower(nombre))
do update set nombre = excluded.nombre   -- «no-op» necesario para que RETURNING devuelva fila
returning id, nombre, (xmax = 0) as fue_insertada;
-- Con DO NOTHING, RETURNING no devuelve nada cuando la fila ya existía: es el fallo típico.

-- SQL-10 · Consulta de auditoría sobre jsonb.
select a.creado_en, u.email as autor,
       a.cambios -> 'estado' ->> 'anterior' as estado_anterior,
       a.cambios -> 'estado' ->> 'nuevo'    as estado_nuevo,
       t.titulo
from auditoria a
join tarea t    on t.id = a.entidad_id and a.entidad = 'Tarea'
join proyecto p on p.id = t.proyecto_id
left join usuario u on u.id = a.usuario_id
where p.nombre = 'Rediseño'
  and a.creado_en >= now() - interval '7 days'
  and a.cambios ? 'estado'          -- el operador ? comprueba la existencia de la clave
order by a.creado_en desc;
-- Para que esto escale hace falta un índice GIN: create index on auditoria using gin (cambios);

Cómo leer un EXPLAIN en treinta segundos: busca primero Seq Scan sobre tablas grandes, después Rows Removed by Filter muy alto (leíste mucho para tirarlo casi todo), y por último la diferencia entre las filas estimadas y las reales (si difieren en un orden de magnitud, las estadísticas están desfasadas y necesitas ANALYZE). BUFFERS distingue lo que vino de memoria (hit) de lo que vino de disco (read), que es donde se va el tiempo.

22.7 Retos de depuración

Este bloque tiene otro formato y es, con diferencia, el más valioso. Se te da un síntoma y un fragmento de código con un fallo real de producción; tú debes diagnosticar antes de abrir la solución. Depurar es la habilidad que más separa a los buenos ingenieros y la que menos se enseña, porque no consiste en saber la respuesta sino en saber qué medir.

Método de diagnóstico en cuatro pasos

1) Reproduce el fallo de forma fiable y reduce el caso al mínimo. 2) Formula una hipótesis falsable («si es esto, entonces al hacer X debería ocurrir Y»). 3) Mide, no adivines: registro, EXPLAIN, panel de red, instantánea de memoria, perfilador. 4) Corrige la causa, no el síntoma, y añade la prueba que habría detectado el fallo.

DEBUG-01 · «La vista no se actualiza»

Síntoma. Al añadir una tarea, la lista no cambia. Si redimensionas la ventana, aparece.

@Component({
  selector: 'tf-lista', changeDetection: ChangeDetectionStrategy.OnPush,
  template: `@for (t of tareas; track t.id) { <p>{{ t.titulo }}</p> }`,
})
export class ListaComponent {
  @Input() tareas: Tarea[] = [];
  anadir(nueva: Tarea): void { this.tareas.push(nueva); }
}
Diagnóstico · DEBUG-01

Causa raíz. Con OnPush, Angular solo revisa el componente si cambia la referencia de una entrada. push muta el array en su sitio: la referencia es la misma, así que el componente nunca se marca como sucio. Al redimensionar la ventana se dispara un evento que provoca una detección global y «arregla» la pantalla, lo que da la pista definitiva.

Cómo se detecta. Si al quitar OnPush funciona, es un problema de referencias, no de datos. Confírmalo comparando tareas antes y después con ===.

Corrección. Reemplazar la referencia o, mejor, usar señales, que notifican por sí mismas:

readonly tareas = input.required<readonly Tarea[]>();
readonly anadida = output<Tarea>();
// El padre actualiza inmutablemente: this.lista.update((ts) => [...ts, nueva]);

Prevención. Tipa las entradas como readonly T[]: el compilador rechazará push. Usa OnPush en todos los componentes desde el primer día (si lo activas al final, aparecen veinte fallos como este a la vez) y estado en señales.

DEBUG-02 · «La API devuelve 500 en el segundo intento»

Síntoma. El trabajo programado funciona la primera vez tras arrancar y a partir de la segunda falla con ValidationError: Using global EntityManager instance methods for context specific actions is disallowed. En las peticiones HTTP normales no ocurre.

@Injectable()
export class ResumenService {
  constructor(private readonly em: EntityManager) {}

  @Cron('0 6 * * *')
  async generarResumen(): Promise<void> {
    const tareas = await this.em.find(Tarea, {vence: {$lte: new Date()}});
    for (const t of tareas) t.notificada = true;
    await this.em.flush();
  }
}
Diagnóstico · DEBUG-02

Causa raíz. El EntityManager inyectado es el global. En las peticiones HTTP, el middleware de @mikro-orm/nestjs crea un contexto por petición (con AsyncLocalStorage) y cada una recibe su propio fork con su Identity Map y su Unit of Work. Un trabajo programado o un procesador de cola se ejecutan fuera de ese contexto, así que reutilizan el manager global: la primera ejecución lo deja lleno de entidades gestionadas y la segunda encuentra un estado sucio.

Cómo se detecta. El propio mensaje lo dice. La pista adicional es que solo falla fuera del ciclo HTTP: cron, colas, WebSockets y onApplicationBootstrap.

@Cron('0 6 * * *')
async generarResumen(): Promise<void> {
  // fork() da un EntityManager limpio y aislado: propio Identity Map, propia transacción.
  const em = this.em.fork();
  const tareas = await em.find(Tarea, {vence: {$lte: new Date()}});
  for (const t of tareas) t.notificada = true;
  await em.flush();
}
// Alternativa equivalente: envolver todo en RequestContext.create(this.orm.em, async () => { … })

Prevención. Regla de equipo: todo punto de entrada que no sea HTTP empieza con un fork(). Y añade un test que ejecute el trabajo dos veces seguidas en el mismo proceso; es justo el escenario que nadie prueba.

DEBUG-03 · «El test pasa en local y falla en CI»

Síntoma. Dos tests verdes en tu máquina y rojos en el servidor de integración, de forma intermitente el primero y siempre el segundo.

it('devuelve las tareas más recientes primero', async () => {
  const tareas = await em.find(Tarea, {proyecto: p.id}, {limit: 3});
  expect(tareas[0]?.titulo).toBe('La última');
});

it('agrupa por día natural', () => {
  const grupos = agruparPorDia([new Date('2026-03-15T23:30:00Z')]);
  expect(grupos['2026-03-15']).toHaveLength(1);
});
Diagnóstico · DEBUG-03

Causa raíz (test 1). No hay ORDER BY. SQL no garantiza ningún orden sin él; en local, con la tabla recién creada, PostgreSQL devuelve las filas en orden físico de inserción y parece que funciona. En CI, con otro plan o tras un VACUUM, el orden cambia. Es un test que nunca fue correcto: simplemente tenía suerte.

Causa raíz (test 2). agruparPorDia usa la zona horaria local del proceso. En tu máquina (Europe/Madrid, UTC+1) las 23:30 UTC son las 00:30 del día 16; en CI (UTC) siguen siendo el día 15. El test codifica la zona horaria del desarrollador.

Cómo se detecta. Ejecuta en local con el entorno de CI: TZ=UTC npm test. Para el orden, ejecuta la suite con semilla aleatoria (vitest --sequence.shuffle): los tests que dependen del orden caen enseguida.

// 1) Orden explícito y desempate estable.
const tareas = await em.find(Tarea, {proyecto: p.id}, {orderBy: {creadoEn: 'DESC', id: 'DESC'}, limit: 3});

// 2) La zona horaria es un parámetro del dominio, no del entorno.
export function agruparPorDia(fechas: Date[], zona = 'Europe/Madrid'): Record<string, Date[]> { … }

Prevención. Fija TZ=UTC en local y en CI y trata la zona como dato explícito; nunca uses new Date() directamente en la lógica de negocio (inyecta un reloj); y ordena siempre de forma determinista. Un test intermitente que se «arregla» reintentando es deuda técnica con intereses.

DEBUG-04 · «La memoria del proceso crece sin parar»

Síntoma. El contenedor arranca con 120 MB y a las seis horas Kubernetes lo mata por OOMKilled. El tráfico es constante.

@Injectable({scope: Scope.REQUEST})
export class TarifasService implements OnModuleInit {
  private readonly cache = new Map<string, Tarifa>();

  constructor(private readonly bus: EventEmitter2) {}

  onModuleInit(): void {
    this.bus.on('tarifas.actualizadas', () => this.cache.clear());
  }

  async tarifa(clave: string): Promise<Tarifa> {
    const guardada = this.cache.get(clave);
    if (guardada) return guardada;
    const nueva = await this.calcular(clave);
    this.cache.set(clave, nueva);
    return nueva;
  }
}
Diagnóstico · DEBUG-04

Causa raíz: dos fugas superpuestas. La primera y más grave es el Scope.REQUEST combinado con bus.on(...): se crea una instancia del servicio por petición y cada una registra un listener que nunca se elimina. El emisor mantiene una referencia al closure, el closure al servicio y el servicio a su caché, así que nada se puede recolectar. Con 10 peticiones por segundo son 216.000 listeners en seis horas. La segunda fuga es la caché sin límite ni caducidad: si la clave tiene alta cardinalidad, crece indefinidamente.

Cómo se detecta. Registra process.memoryUsage().heapUsed cada minuto: una fuga da una recta ascendente que los GC no bajan, frente al diente de sierra normal. Después, node --inspect, dos instantáneas del montón separadas por diez minutos y la vista «Comparison» de Chrome DevTools: el tipo con más objetos retenidos aparece arriba. En este caso process.listenerCount('tarifas.actualizadas') o el aviso MaxListenersExceededWarning lo delatan de inmediato.

@Injectable()                                     // singleton: UNA instancia, UN listener
export class TarifasService implements OnModuleInit, OnModuleDestroy {
  private readonly cache = new LRUCache<string, Tarifa>({max: 5_000, ttl: 5 * 60_000});
  private readonly limpiar = () => this.cache.clear();

  onModuleInit(): void { this.bus.on('tarifas.actualizadas', this.limpiar); }
  onModuleDestroy(): void { this.bus.off('tarifas.actualizadas', this.limpiar); }   // simétrico
}

Prevención. Todo on necesita su off en el hook de destrucción simétrico, y para poder quitarlo hace falta guardar la referencia de la función (una flecha en línea es imposible de desregistrar). Toda caché en memoria necesita max y ttl. Y usa Scope.REQUEST solo cuando de verdad lo necesites: además de este riesgo, obliga a instanciar toda la cadena de dependencias en cada petición.

DEBUG-05 · «El listado tarda 8 segundos»

Síntoma. GET /proyectos tarda 8 s con 200 proyectos. La base de datos muestra un 60 % de CPU y miles de consultas por segundo.

@Get()
async listar(): Promise<ProyectoDto[]> {
  const proyectos = await this.em.find(Proyecto, {});
  return Promise.all(proyectos.map(async (p) => ({
    ...wrap(p).toObject(),
    tareas: await p.tareas.loadItems(),
    miembros: await p.miembros.loadItems(),
    totalPendientes: (await p.tareas.loadItems()).filter((t) => t.estado === 'pendiente').length,
  })));
}
Diagnóstico · DEBUG-05

Causa raíz: tres problemas encadenados. (1) N+1 por partida triple: con 200 proyectos son 1 + 200 + 200 + 200 = 601 consultas, y encima tareas.loadItems() se llama dos veces. (2) Se transfiere todo: si cada proyecto tiene 500 tareas, son 100.000 filas serializadas a JSON para pintar una lista. (3) No hay paginación ni límite: el coste crece sin techo con los datos.

Cómo se detecta. Activa debug: ['query'] y cuenta líneas; o mira las métricas de la base de datos: muchas consultas rápidas en lugar de una lenta es la firma inconfundible del N+1. El tamaño de la respuesta en el panel de red confirma el punto 2.

@Get()
async listar(@Query() q: ListarProyectosDto): Promise<Pagina<ProyectoResumenDto>> {
  // Una sola consulta con agregados: no se cargan las tareas, se cuentan en la base de datos.
  const qb = this.em.createQueryBuilder(Proyecto, 'p')
    .select(['p.id', 'p.nombre', 'p.creadoEn'])
    .addSelect(`count(t.id) filter (where t.estado = 'pendiente') as pendientes`)
    .leftJoin('p.tareas', 't')
    .groupBy(['p.id'])
    .orderBy({'p.creadoEn': 'DESC'})
    .limit(q.tamano).offset((q.pagina - 1) * q.tamano);

  const [datos, total] = await Promise.all([qb.execute<ProyectoResumenDto[]>(), this.em.count(Proyecto)]);
  return {datos, total, pagina: q.pagina, tamano: q.tamano};
}

Prevención. Ningún endpoint de listado sin paginación obligatoria con un tamaño máximo. Un DTO de resumen distinto del DTO de detalle: la lista no necesita las tareas. Y un test de rendimiento que falle si una ruta supera un número de consultas: es la única forma de que el N+1 no vuelva en el siguiente sprint.

DEBUG-06 · «El usuario A ve datos del usuario B»

Síntoma. Con poca carga nunca pasa. En hora punta, algunos usuarios ven el nombre o las tareas de otro. No es reproducible en local.

@Injectable()
export class PerfilService {
  private usuarioActual!: Usuario;                     // (1)

  establecer(u: Usuario): void { this.usuarioActual = u; }

  async panel(): Promise<PanelDto> {
    const enCache = await this.cache.get<PanelDto>('panel');   // (2)
    if (enCache) return enCache;
    const dto = await this.construir(this.usuarioActual);
    await this.cache.set('panel', dto, 60_000);
    return dto;
  }
}
Diagnóstico · DEBUG-06

Causa raíz. Dos fallos que se refuerzan. (1) Un proveedor de NestJS es singleton por defecto: hay una sola instancia para todo el proceso. Guardar el usuario en un campo significa que la petición de B sobrescribe el campo justo entre el establecer() y el await de la petición de A. Es una condición de carrera clásica y solo se manifiesta con concurrencia, de ahí que en local no aparezca. (2) La clave de caché 'panel' es global: el primer panel calculado se sirve a todo el mundo durante 60 segundos.

Cómo se detecta. Prueba de carga con dos usuarios distintos en paralelo (autocannon, k6) comprobando que cada respuesta corresponde a su token. Y una auditoría del código: cualquier campo mutable en un proveedor singleton es sospechoso.

@Injectable()
export class PerfilService {
  // El usuario se pasa explícitamente: sin estado compartido no hay carrera posible.
  async panel(usuarioId: string): Promise<PanelDto> {
    const clave = `panel:${usuarioId}`;                // la clave SIEMPRE incluye la identidad
    const enCache = await this.cache.get<PanelDto>(clave);
    if (enCache) return enCache;
    const dto = await this.construir(usuarioId);
    await this.cache.set(clave, dto, 60_000);
    return dto;
  }
}
// En el controlador: this.perfil.panel(peticion.usuario.id)
// Alternativa cuando la firma se ensucia demasiado: AsyncLocalStorage (ver TS-10) o nestjs-cls.

Prevención. Prohíbe por convención el estado mutable en singletons y revísalo en cada pull request. Toda clave de caché lleva el identificador de usuario o de tenant. Y en la base de datos, filtros de acceso obligatorios (un filtro global de MikroORM por tenant o row level security de PostgreSQL) para que el fallo sea imposible incluso si alguien olvida el WHERE. Este tipo de fallo es notificable como brecha de datos bajo el RGPD: no es un bug menor.

DEBUG-07 · «El token se refresca en bucle»

Síntoma. Al caducar el token, el navegador se congela y el panel de red muestra cientos de POST /auth/refresh por segundo hasta que la pestaña deja de responder.

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const sesion = inject(SesionStore);
  const auth = inject(AuthApi);
  const conToken = req.clone({setHeaders: {Authorization: `Bearer ${sesion.token()}`}});

  return next(conToken).pipe(
    catchError((e: unknown) => {
      if (e instanceof HttpErrorResponse && e.status === 401) {
        return auth.refrescar().pipe(switchMap(() => next(conToken)));
      }
      return throwError(() => e);
    }),
  );
};
Diagnóstico · DEBUG-07

Causa raíz. El interceptor se aplica a todas las peticiones, incluida /auth/refresh. Si el refresco devuelve 401 (porque el refresh token también ha caducado), el catchError vuelve a llamar al refresco, que vuelve a dar 401: recursión infinita. Además se reintenta con conToken, que lleva el token viejo ya clonado, así que el reintento vuelve a dar 401 aunque el refresco funcione. Y como no hay deduplicación, diez peticiones simultáneas lanzan diez refrescos que, con rotación de tokens, se invalidan entre sí.

Cómo se detecta. El panel de red lo enseña de inmediato: la misma URL repetida sin fin. Un contador de reintentos por petición o un tope de profundidad convierte el bucle en un error visible.

Corrección. Las tres piezas de la solución de NG-10: excluir las rutas de autenticación mediante lista, clonar la petición después de obtener el token nuevo, y compartir un único refresco en curso con shareReplay. Si el refresco falla, cerrar la sesión y redirigir al login en lugar de reintentar.

Prevención. Un test que simule dos 401 seguidos y afirme que solo se emite un POST /auth/refresh, con HttpTestingController. Y un límite duro de un reintento por petición, para que un fallo futuro degrade en un error y no en una tormenta de peticiones que puede tumbar tu propio servidor de autenticación.

DEBUG-08 · «Los cambios no se guardan»

Síntoma. El PATCH responde 200 con los datos ya modificados, pero al recargar la página vuelve el valor antiguo. No hay ningún error en el registro.

async actualizar(id: string, dto: ActualizarTareaDto): Promise<Tarea> {
  const tarea = await this.em.findOneOrFail(Tarea, {id});
  const actualizada = {...tarea, ...dto};
  await this.em.persist(actualizada as Tarea);
  return actualizada as Tarea;
}
Diagnóstico · DEBUG-08

Causa raíz: tres errores en cuatro líneas. (1) {...tarea, ...dto} produce un objeto plano, no una entidad gestionada: MikroORM sigue el patrón Data Mapper y solo escribe los cambios de las entidades que su Unit of Work vigila; ese objeto nuevo es invisible para él. (2) persist() únicamente marca para persistir; quien escribe en la base de datos es flush(). (3) La respuesta se construye con el objeto en memoria, por lo que parece correcta y oculta el fallo. El as Tarea es la señal de alarma: una aserción que silencia al compilador cuando este tenía razón.

Cómo se detecta. Activa el registro de SQL: no aparece ningún UPDATE. Y añade siempre al test de actualización un em.clear() seguido de una relectura, que es lo que reproduce el «recargar la página».

async actualizar(id: string, dto: ActualizarTareaDto): Promise<Tarea> {
  const tarea = await this.em.findOneOrFail(Tarea, {id});
  this.em.assign(tarea, dto);       // muta la entidad GESTIONADA: el UoW detecta el cambio
  await this.em.flush();            // aquí, y solo aquí, se emite el UPDATE
  return tarea;
}

// El test que lo habría detectado:
it('persiste el cambio', async () => {
  await servicio.actualizar(id, {titulo: 'Nuevo'});
  em.clear();                                        // vacía el Identity Map: obliga a releer
  const releida = await em.findOneOrFail(Tarea, {id});
  expect(releida.titulo).toBe('Nuevo');
});

Prevención. Interioriza el modelo: cargar → mutar la entidad → flush. Nunca copies entidades con spread; usa em.assign o wrap(entidad).assign(). Y que todo test de escritura incluya un em.clear() antes de comprobar, o estarás verificando la memoria en lugar de la base de datos.

22.8 Ejercicios de diseño

Seis enunciados abiertos de nivel entrevista de arquitectura. Aquí no hay una respuesta correcta, sino respuestas defendibles: lo que se evalúa es que identifiques las restricciones, propongas alternativas y justifiques la elección con criterios explícitos. Dedica al menos veinte minutos a cada uno, en papel, antes de leer la respuesta modelo. Escribir «depende» sin decir de qué depende es la peor respuesta posible; decir de qué depende y qué harías en cada caso es la mejor.

DIS-01 · Modelo de datos de un sistema de reservas

Diseña el modelo de datos de un sistema de reservas de salas: salas con aforo y equipamiento, franjas horarias, reservas recurrentes («todos los martes de 10 a 11 hasta junio»), cancelaciones, lista de espera y zonas horarias distintas por sede. Restricción dura: es imposible que dos reservas confirmadas se solapen en la misma sala, incluso con mil peticiones simultáneas.

Respuesta modelo · DIS-01

Decisión central: cómo garantizar la no superposición. Tres alternativas y sus consecuencias:

AlternativaCómo funcionaVentajaRiesgo
Comprobar en el servicioSELECT de solapamientos y luego INSERTTrivial de escribirIncorrecta. Entre las dos sentencias cabe otra transacción
Bloqueo pesimista de la salaSELECT … FOR UPDATE sobre la fila de la sala Simple y correctaSerializa todas las reservas de esa sala
Restricción de exclusiónEXCLUDE USING gist sobre (sala_id, rango)La garantiza el motor: imposible violarlaEspecífica de PostgreSQL; hay que traducir el error a un 409
create extension if not exists btree_gist;
alter table reserva add column periodo tstzrange
  generated always as (tstzrange(inicio, fin, '[)')) stored;
alter table reserva add constraint reserva_sin_solape
  exclude using gist (sala_id with =, periodo with &&) where (estado = 'confirmada');

Razonamiento. Elijo la tercera. La corrección de un invariante de negocio tan duro no debe depender de que todos los caminos del código recuerden bloquear: se declara una vez en el esquema y ya no se puede violar, ni desde una migración, ni desde un script de importación, ni desde otro servicio. El where (estado = 'confirmada') permite que convivan solicitudes pendientes y canceladas sobre el mismo hueco. El coste es acoplarse a PostgreSQL, algo que asumo sin dudar: la portabilidad entre motores es un requisito imaginario en el 95 % de los proyectos.

Recurrencia: guardar la regla (RRULE de iCalendar) y materializar las ocurrencias como filas hasta un horizonte (por ejemplo 12 meses), regenerándolas con un trabajo programado. Solo la regla impide usar la restricción de exclusión y obliga a expandir en cada consulta; solo las filas impide editar «la serie entera». Con las dos cosas, cada ocurrencia puede además desviarse (mover una sesión concreta) mediante una fila de excepción.

Zonas horarias: almacenar siempre timestamptz (UTC) y guardar aparte la zona IANA de la sede ('Europe/Madrid', nunca un desfase fijo, que se rompe con el horario de verano). La recurrencia se expande en la zona de la sede: «todos los martes a las 10:00» debe seguir siendo a las 10:00 locales después del cambio de hora, aunque el instante UTC cambie.

Lista de espera: tabla propia con orden de llegada y ventana de aceptación; al cancelarse una reserva, un evento de dominio notifica al primero y le reserva el hueco durante N minutos.

DIS-02 · ¿Monolito o microservicios?

Una empresa de 12 personas (7 desarrolladores) lanza un SaaS B2B de gestión documental. Prevén 200 clientes el primer año. El director técnico propone empezar con 8 microservicios «para estar preparados cuando escalemos». Argumenta tu recomendación con criterios, no con opiniones.

Respuesta modelo · DIS-02

Recomendación: monolito modular. Un único despliegue, una única base de datos, pero con módulos de NestJS de fronteras explícitas, cada uno con su esquema de base de datos propio y comunicación entre módulos solo a través de interfaces publicadas y eventos, nunca por consultas SQL cruzadas.

Razonamiento cuantitativo. Con 7 desarrolladores no hay problema de coordinación que resolver: la ley de Conway funciona en los dos sentidos y 8 servicios para 7 personas significa que todos tocan todo, con el coste de los microservicios y ninguna de sus ventajas. Los costes son concretos y se pagan desde el primer día: transacciones distribuidas (adiós al ACID, hola a las sagas), depuración a través de la red, ocho pipelines, ocho conjuntos de secretos, versionado de contratos, latencia añadida y observabilidad distribuida obligatoria. Los beneficios (escalado independiente, aislamiento de fallos, despliegue independiente, heterogeneidad tecnológica) solo se cobran con equipos y volúmenes que aquí no existen.

Qué mediría para cambiar de opinión. Extraería un servicio cuando se cumpla alguna de estas condiciones, no antes: (1) un módulo tiene un perfil de escalado radicalmente distinto (el conversor de PDF consume CPU en ráfagas y obliga a sobredimensionar todo el monolito); (2) un módulo necesita otra tecnología (OCR en Python); (3) un módulo tiene requisitos de cumplimiento o aislamiento distintos; (4) el equipo supera las 25-30 personas y los despliegues se bloquean entre sí; (5) un fallo en un módulo tumba el sistema entero y el aislamiento tiene valor de negocio medible.

Cómo dejarlo preparado sin pagarlo ahora. Fronteras de módulo estrictas y verificadas con reglas de dependencia automatizadas; nada de join entre esquemas de módulos distintos; eventos de dominio internos desde el principio (si mañana pasan por una cola, el código que los emite no cambia); y contratos con DTO explícitos en lugar de exponer entidades. Extraer un módulo bien delimitado cuesta semanas; deshacer ocho microservicios prematuros cuesta años. La arquitectura debe ser la más simple que resuelva el problema de hoy sin impedir el de mañana.

DIS-03 · Estrategia de caché de un catálogo

Catálogo de 500.000 productos, 50 millones de visitas al mes, precios que cambian varias veces al día y stock en tiempo real. Diseña la estrategia de caché completa: qué se cachea, dónde, con qué caducidad y cómo se invalida. Justifica qué datos no deben cachearse.

Respuesta modelo · DIS-03

Principio rector: cachear por volatilidad y por coste de estar equivocado, no «cachearlo todo». Un precio desactualizado 30 segundos es un problema legal y de confianza; una descripción desactualizada una hora no le importa a nadie.

DatoCapaTTLInvalidación
Imágenes y estáticosCDN1 añoHuella en el nombre del archivo
Ficha (nombre, descripción)CDN + Redis1 h / 24 hEvento producto.actualizado purga la clave
PrecioRedis60 sEvento de cambio de precio, purga inmediata
Stock exactoSin cachéConsulta directa
Indicador «hay stock»Redis30 sTolerable y suficiente para el listado
Resultados de búsquedaRedis por consulta normalizada5 minCaducidad natural
Carrito, pedidos, perfilSin caché compartidaDatos personales: riesgo de fuga entre usuarios

Decisiones que hay que saber defender. El stock exacto no se cachea porque el coste de equivocarse es vender lo que no tienes; sí se cachea el booleano «disponible» para el listado, donde un error transitorio solo lleva a la ficha, que consulta en directo. La caché de HTML completo en CDN se descarta porque la página lleva precio y personalización; la alternativa es cachear el armazón y cargar la parte volátil aparte.

Los tres problemas que hay que resolver explícitamente. Estampida: cuando caduca una clave muy popular, mil peticiones van a la vez a la base de datos; se resuelve con un bloqueo de recálculo (solo uno recalcula, los demás sirven el valor viejo) o con recálculo anticipado probabilístico. Avalancha: si todas las claves se cargaron a la vez, caducan a la vez; se resuelve añadiendo jitter al TTL. Incoherencia entre capas: la CDN puede servir durante horas algo que ya purgaste en Redis, así que la CDN necesita su propia purga por etiquetas y TTL cortos en lo volátil.

Cómo se mide el éxito: tasa de aciertos por tipo de clave (por debajo del 80 % la caché no está pagando su complejidad), latencia p95 y p99, y carga de la base de datos antes y después. Sin métricas, una caché es una fuente de bugs imposibles de reproducir.

DIS-04 · Autenticación multi-dispositivo con revocación

Los usuarios se conectan desde el móvil, la web y una extensión de navegador. Requisitos: no volver a pedir la contraseña cada día; poder ver las sesiones activas y cerrar una concreta desde el perfil; cierre inmediato de todas las sesiones al cambiar la contraseña; y detección de robo de credenciales.

Respuesta modelo · DIS-04

Tensión de fondo. Los JWT no se pueden revocar: esa es toda su gracia (el servidor no consulta nada) y todo su problema. Cualquier diseño con revocación introduce estado en el servidor; la pregunta es cuánto.

OpciónRevocaciónCoste por peticiónVeredicto
JWT largo (30 días)ImposibleCeroDescartada: incompatible con el requisito
Sesión opaca en RedisInmediata1 lectura de RedisVálida y simple; el punto único de fallo es Redis
Access corto + refresh rotativo≤ 15 min para el acceso, inmediata para el refrescoCero en el caso normalElegida
Access corto + lista negraInmediata1 lectura por peticiónSolo si se exige revocación instantánea

Diseño elegido. Access token JWT de 15 minutos, firmado y no consultado; refresh token opaco de 30 días, almacenado hasheado en la base de datos, uno por dispositivo, con rotación en cada uso (ver NEST-08). Cada fila guarda dispositivo, agente de usuario, IP aproximada, último uso y familia. La pantalla de «sesiones activas» es literalmente un SELECT sobre esa tabla, y cerrar una sesión es marcar su familia como revocada. Al cambiar la contraseña se revocan todas las familias del usuario y se incrementa un contador tokenVersion incluido en el JWT, de modo que los access tokens ya emitidos dejan de validar de inmediato: es el único punto donde se paga una comprobación adicional, y solo compara un entero que ya viaja en el token.

Detección de robo. La rotación con detección de reutilización: si aparece un refresh ya consumido, alguien tiene una copia. Se revoca la familia completa, se registra el incidente y se avisa al usuario. Complementos razonables: alerta por cambio brusco de país o de dispositivo, y reautenticación obligatoria para operaciones sensibles.

Almacenamiento en el cliente: el refresh en cookie httpOnly, secure, sameSite=strict, con ruta limitada al endpoint de refresco; el access token en memoria, nunca en localStorage, que es legible por cualquier XSS. En la extensión y en el móvil, almacenamiento seguro del sistema.

DIS-05 · Exportar un informe de 2 millones de filas

Los clientes piden un CSV con hasta 2 millones de filas y 30 columnas (unos 600 MB). Ahora mismo el endpoint hace find(), construye el CSV en memoria y lo devuelve: agota la memoria y el balanceador corta a los 60 segundos. Rediseña la funcionalidad completa.

Respuesta modelo · DIS-05

Primer cambio de marco: esto no es una petición, es un trabajo. Ninguna petición HTTP síncrona debe durar minutos. El flujo correcto es asíncrono: el cliente solicita la exportación y recibe un 202 con un identificador; un worker la genera; cuando termina, el cliente se la descarga.

POST /exportaciones        -> 202 { id, estado: 'en_cola' }
GET  /exportaciones/:id    -> 200 { estado: 'procesando', progreso: 42 }
GET  /exportaciones/:id    -> 200 { estado: 'lista', url: 'https://…?firma', expiraEn }

Cómo se genera sin agotar la memoria. Tres reglas: (1) leer con cursor del servidor (em.getConnection().execute con un cursor, o paginación por clave), nunca find() completo; (2) transformar por filas con un flujo (Readable → transformador a CSV → Writable), de modo que la memoria dependa del tamaño del búfer y no del número de filas; (3) escribir directamente al almacenamiento de objetos (S3) con multipart upload, sin tocar el disco local ni acumular en RAM.

const cursor = em.getConnection().stream('select … from tarea where … order by id');
await pipeline(cursor, transformarACsv(), subidaMultiparteS3(clave));

Entrega. URL prefirmada con caducidad corta (15 min) que apunta al almacenamiento de objetos: el archivo nunca pasa por tu API, así que no consume ni memoria ni conexiones. Notificación por WebSocket o por correo cuando esté lista.

Alternativas consideradas. Streaming síncrono con Transfer-Encoding: chunked: funciona técnicamente y evita el problema de memoria, pero mantiene una conexión abierta varios minutos, depende de que el cliente no pierda la red y no permite reintentar; aceptable solo hasta unos cientos de miles de filas. Generación previa nocturna: válida si el informe es estándar y no depende de filtros del usuario. Criterio de decisión: por debajo de unos 30 segundos y 50 MB, streaming síncrono por su simplicidad; por encima, trabajo asíncrono.

Detalles que se olvidan: límite de exportaciones concurrentes por cliente (o un usuario tumba el sistema con diez peticiones); caducidad y borrado automático de los archivos (son datos personales); registro de auditoría de quién exportó qué; y BOM UTF-8 al inicio del CSV si tus usuarios lo abren con Excel, o verán los acentos rotos.

DIS-06 · Versionado de una API pública

Tu API la consumen 300 clientes externos que no puedes obligar a actualizar. Necesitas: renombrar un campo, cambiar el tipo de otro, eliminar un endpoint y añadir un campo obligatorio en una petición. Diseña la estrategia de versionado y la política de ciclo de vida.

Respuesta modelo · DIS-06

Primero, clasificar el cambio. No todo necesita versión nueva: añadir un campo opcional a la respuesta, añadir un endpoint o añadir un valor a un enumerado que el cliente ya debe ignorar son cambios compatibles. Solo rompen: renombrar o eliminar un campo, cambiar un tipo, endurecer una validación, cambiar un código de estado o convertir un campo opcional en obligatorio.

MecanismoEjemploA favorEn contra
URI/v1/tareasExplícito, visible, trivial de enrutar y cachearLa versión «contamina» todas las URL
CabeceraAccept: application/vnd.tf.v2+jsonURL estables, puristaDifícil de probar en un navegador; caché por cabecera
Parámetro?version=2CómodoSe pierde en redirecciones; ensucia la caché
Por fechaX-Api-Version: 2026-07-31Granular; lo usa Stripe Exige mantener transformadores encadenados

Recomendación: versión mayor en la URI (/v1, /v2) por su simplicidad operativa, combinada con la regla de solo versiones mayores y muy pocas. Internamente, una sola implementación con la lógica actual y una capa de traducción por versión: los controladores de /v1 son adaptadores finos que transforman la entrada y la salida hacia el modelo interno. Duplicar la lógica de negocio por versión es lo que hace insostenible el versionado.

Los cuatro cambios del enunciado. Renombrar un campo: en /v2 el nombre nuevo, y el adaptador de /v1 lo reexpone con el viejo. Cambiar un tipo: igual, con conversión en el adaptador; si no es convertible sin pérdida, campo nuevo junto al viejo y el antiguo se marca como obsoleto. Eliminar un endpoint: no se elimina en /v1; se marca como obsoleto y desaparece en /v2. Campo obligatorio nuevo en la petición: en /v1 se aplica un valor por defecto documentado; obligatorio solo en /v2.

Política de ciclo de vida (esto es lo que más se olvida y lo que más valoran en una entrevista): anuncio con 12 meses de antelación; cabecera Deprecation y Sunset en las respuestas de la versión antigua; métricas de uso por versión y por cliente para saber a quién llamar; avisos por correo a los clientes que siguen usándola a los 6, 3 y 1 mes; y «días de brownout» antes del apagado, en los que la versión antigua devuelve 410 durante unas horas para que nadie se lleve una sorpresa el día final. Máximo dos versiones vivas a la vez: cada versión adicional multiplica la matriz de pruebas.

22.9 Proyecto integrador «TaskFlow»

Todo lo anterior son piezas sueltas. TaskFlow es el proyecto donde se montan: un gestor de tareas de equipo, pequeño en alcance pero completo en profundidad, con la misma exigencia que un producto real. Trátalo como un encargo profesional: la especificación que sigue es la que recibirías de un cliente y la rúbrica del apartado 22.9.7 es la que usaría un tribunal.

22.9.1 Descripción funcional y alcance

TaskFlow permite a equipos pequeños (5 a 50 personas) organizar el trabajo en proyectos y tareas, con tablero, comentarios, etiquetas, asignaciones y notificaciones en tiempo real. Multiusuario y multiproyecto, con permisos por rol dentro de cada proyecto.

Dentro del alcance

  • Registro, inicio de sesión y perfil.
  • Proyectos con miembros y roles (propietario, editor, lector).
  • Tareas con estado, prioridad, fecha de vencimiento, responsable y etiquetas.
  • Tablero kanban con reordenación y listado con filtros, orden y paginación.
  • Comentarios anidados en las tareas.
  • Notificaciones en tiempo real y actividad reciente.
  • Búsqueda por texto, exportación a CSV y auditoría de cambios.

Fuera del alcance (v1)

  • Facturación, planes de pago y pasarela.
  • Aplicación móvil nativa (la web debe ser responsive).
  • Integraciones con terceros (Slack, GitHub, calendario).
  • Diagramas de Gantt e informes avanzados.
  • Internacionalización más allá del español.
  • Edición colaborativa simultánea del mismo texto.

22.9.2 Historias de usuario con criterios de aceptación

IDHistoriaCriterios de aceptación
HU-01Como visitante quiero registrarme con correo y contraseña para tener una cuenta. Contraseña de 12 caracteres mínimo; correo único (409 si existe); contraseña almacenada con Argon2id; se devuelve el perfil sin ningún dato sensible.
HU-02Como usuario quiero iniciar sesión y seguir conectado varios días sin repetir la contraseña.Access token de 15 min y refresh de 7 días en cookie httpOnly; el refresco es transparente; tras 5 intentos fallidos por minuto se responde 429.
HU-03Como usuario quiero cerrar sesión en un dispositivo concreto.Listado de sesiones activas con dispositivo y último uso; cerrar una invalida su refresh de inmediato; las demás siguen activas.
HU-04Como usuario quiero crear un proyecto y ser su propietario.Nombre de 3 a 120 caracteres; el creador queda como propietario; responde 201 con cabecera Location.
HU-05Como propietario quiero invitar a personas con un rol.Roles propietario, editor y lector; invitación por correo con enlace de un solo uso caducable; no se puede invitar dos veces al mismo usuario (409).
HU-06Como lector no quiero poder modificar nada.Toda escritura responde 403; la interfaz no muestra los botones; la prueba e2e verifica ambas cosas.
HU-07Como editor quiero crear tareas con título, descripción, prioridad, vencimiento y responsable.Título obligatorio de 3 a 160; el responsable debe ser miembro del proyecto (422 si no); la fecha de vencimiento no puede ser anterior a hoy al crear.
HU-08Como usuario quiero ver las tareas en un tablero por estado y moverlas arrastrando.Cuatro columnas; el orden persiste; dos usuarios que mueven a la vez no dejan posiciones duplicadas; el cambio se refleja en menos de 1 s en las demás pantallas.
HU-09Como usuario quiero filtrar y ordenar el listado.Filtros por estado, prioridad, responsable, etiqueta y texto, combinables; orden por cualquier columna; paginación de 20; los filtros se reflejan en la URL y sobreviven a recargar.
HU-10Como usuario quiero comentar una tarea y responder a otros comentarios. Anidamiento de hasta 3 niveles; se muestra autor y fecha relativa; solo el autor o el propietario pueden borrar.
HU-11Como usuario quiero etiquetar tareas y filtrar por etiqueta.Máximo 10 etiquetas por tarea; se crean al vuelo si no existen; nombres normalizados en minúsculas y únicos.
HU-12Como usuario quiero recibir una notificación cuando me asignan una tarea o me mencionan.Llega por WebSocket en menos de 1 s; se guarda para verla después; contador de no leídas; solo la reciben quienes tienen acceso al proyecto.
HU-13Como usuario quiero ver la actividad reciente de un proyecto.Últimos 50 cambios con quién, qué y cuándo; se lee de la auditoría; paginación por cursor.
HU-14Como usuario quiero buscar tareas por texto en todos mis proyectos. Busca en título y descripción; solo en proyectos con acceso; responde en menos de 300 ms con 100.000 tareas.
HU-15Como propietario quiero exportar las tareas de un proyecto a CSV.Respeta los filtros aplicados; codificación UTF-8 con BOM; hasta 50.000 filas en streaming; queda registrado en la auditoría.
HU-16Como usuario quiero que borrar una tarea sea reversible durante 30 días. Borrado lógico; desaparece de todos los listados; una papelera permite restaurarla; un trabajo programado la elimina definitivamente a los 30 días.
HU-17Como usuario con discapacidad visual quiero manejar la aplicación con teclado y lector de pantalla.Todo alcanzable con tabulador y foco visible; roles ARIA en el tablero; contraste AA; los cambios dinámicos se anuncian en una región live.

22.9.3 Modelo de dominio

┌──────────────┐ 1      1 ┌──────────────┐
│   USUARIO    │──────────│    PERFIL    │
│ id (uuid) PK │          │ avatar, bio  │
│ email UQ     │          └──────────────┘
│ passwordHash │
│ tokenVersion │ 1                                       N ┌────────────────────┐
│ creadoEn     │───────────────────────────────────────────│  MIEMBRO_PROYECTO  │
└──────┬───────┘                                           │ usuario_id  PK,FK  │
       │ 1                                              1  │ proyecto_id PK,FK  │
       │                                        ┌──────────│ rol, alta_en       │
       │ N (asignado)                           │ N        └────────────────────┘
┌──────┴───────────────┐   N          1  ┌──────┴────────┐
│        TAREA         │─────────────────│   PROYECTO    │
│ id (uuid) PK         │                 │ id (uuid) PK  │
│ proyecto_id  FK      │                 │ nombre        │
│ asignado_id  FK NULL │                 │ propietario_id│
│ titulo, descripcion  │                 │ archivado_en  │
│ estado, prioridad    │                 └───────────────┘
│ posicion, vence      │
│ version (optimista)  │  N        N ┌──────────────┐
│ borrada_en NULL      │─────────────│   ETIQUETA   │   (pivote: tarea_etiqueta)
│ creado_en, actual.   │             │ nombre UQ    │
└──────┬───────────────┘             └──────────────┘
       │ 1
       │ N
┌──────┴────────────────┐        ┌───────────────────────────┐
│      COMENTARIO       │        │        AUDITORIA          │
│ id PK, tarea_id FK    │        │ entidad, entidad_id       │
│ autor_id FK           │        │ operacion, cambios jsonb  │
│ padre_id FK NULL ─────┼──┐     │ usuario_id, creado_en     │
│ texto, creado_en      │  │auto │ (índice GIN en cambios)   │
└───────────────────────┘◄─┘     └───────────────────────────┘

Cardinalidades:  1:1 Usuario–Perfil · 1:N Proyecto–Tarea · 1:N Tarea–Comentario
                 N:M Tarea–Etiqueta · N:M Usuario–Proyecto con atributos (MiembroProyecto)
                 Auto-referencia N:1 en Comentario (padre_id)

22.9.4 Requisitos no funcionales

ÁmbitoRequisito medibleCómo se verifica
Rendimientop95 de la API por debajo de 200 ms con 100.000 tareas y 50 usuarios concurrentes; ninguna ruta emite más de 5 consultas SQL.Prueba de carga con k6 y contador de consultas en los tests
FrontendLCP < 2,5 s y CLS < 0,1 en 3G simulada; bundle inicial < 300 kB comprimido.Lighthouse en CI con presupuesto de tamaño
SeguridadContraseñas con Argon2id; sin secretos en el repositorio; cabeceras de seguridad (CSP, HSTS); validación en el servidor de todo dato de entrada; autorización comprobada en cada endpoint; sin dependencias con vulnerabilidades altas.npm audit, análisis estático y tests de autorización
AccesibilidadWCAG 2.1 nivel AA: navegación completa por teclado, foco visible, contraste, etiquetas de formulario, anuncios de cambios dinámicos.axe-core en los tests e2e y revisión manual con lector de pantalla
ObservabilidadRegistro estructurado en JSON con identificador de correlación en todas las líneas; métricas de latencia y errores por ruta; /salud/vivo y /salud/listo; trazas de las operaciones lentas.Revisión del registro de una petición completa de extremo a extremo
FiabilidadMigraciones reversibles y compatibles con la versión anterior; despliegue sin tiempo de inactividad; copias de seguridad diarias con restauración probada.Despliegue de prueba con tráfico y ensayo de restauración
CalidadCobertura de líneas ≥ 80 % y de ramas ≥ 70 % en la capa de dominio y servicios; cero avisos del linter; TypeScript estricto sin any.Puerta de calidad en CI

22.9.5 Contrato de la API

Prefijo /api/v1. Todas las respuestas de error usan application/problem+json. Todas las rutas exigen autenticación salvo las marcadas como públicas.

Método y rutaCuerpo de la peticiónRespuesta correctaCódigos
POST /auth/registro público{email, password, nombre} {id, email, nombre}201, 400, 409
POST /auth/login público{email, password} {accessToken, expiraEn} + cookie de refresco200, 400, 401, 429
POST /auth/refresh público— (cookie) {accessToken, expiraEn}200, 401
POST /auth/logout204
GET /auth/sesiones[{id, dispositivo, ip, ultimoUso}]200, 401
DELETE /auth/sesiones/:id204, 401, 404
GET /usuarios/yo{id, email, nombre, perfil}200, 401
PATCH /usuarios/yo{nombre?, perfil?}Usuario actualizado200, 400, 401
GET /proyectos— (?pagina&tamano&q) {datos:[{id,nombre,pendientes}], total, pagina, tamano}200, 401
POST /proyectos{nombre, descripcion?} Proyecto + cabecera Location201, 400, 401
GET /proyectos/:idProyecto con miembros y recuentos200, 401, 403, 404
PATCH /proyectos/:id{nombre?, descripcion?, archivado?} Proyecto actualizado200, 400, 403, 404, 409
DELETE /proyectos/:id204, 403, 404
POST /proyectos/:id/miembros{email, rol}Miembro creado201, 400, 403, 409
PATCH /proyectos/:id/miembros/:uid{rol}Miembro actualizado200, 403, 404
DELETE /proyectos/:id/miembros/:uid204, 403, 404
GET /tareas— (?proyectoId&estado&prioridad&asignadoId&etiqueta&q&venceAntesDe&orden&pagina&tamano) {datos, total, pagina, tamano}200, 400, 401
POST /tareas{proyectoId, titulo, descripcion?, prioridad, vence?, asignadoId?, etiquetas?} Tarea + Location201, 400, 403, 422
GET /tareas/:idTarea con etiquetas, asignado y contador de comentarios200, 403, 404
PATCH /tareas/:id{…campos} + cabecera If-Match: version Tarea actualizada200, 400, 403, 404, 409
PATCH /tareas/:id/mover{estado, posicion}Tarea movida200, 403, 404, 409
DELETE /tareas/:id— (borrado lógico)204, 403, 404
POST /tareas/:id/restaurarTarea restaurada200, 403, 404
GET /tareas/:id/comentarios— (?cursor){datos, siguiente}200, 403, 404
POST /tareas/:id/comentarios{texto, padreId?}Comentario creado201, 400, 403, 422
DELETE /comentarios/:id204, 403, 404
GET /proyectos/:id/actividad— (?cursor){datos, siguiente}200, 403
GET /notificaciones— (?soloNoLeidas){datos, noLeidas}200, 401
POST /exportaciones{proyectoId, filtros}{id, estado}202, 400, 403
GET /exportaciones/:id{estado, progreso, url?}200, 403, 404
GET /salud/vivo · /salud/listo público {status, info}200, 503

WebSocket /eventos (namespace autenticado por JWT en el handshake): mensajes unirse-proyecto, y eventos emitidos tarea-creada, tarea-actualizada, tarea-movida, comentario-nuevo y notificacion.

22.9.6 Los diez hitos evaluables

HitoObjetivo y tareasCriterios de aceptaciónEst.
TF-01
Entorno y esqueleto
Monorepo con apps/api (NestJS) y apps/web (Angular). PostgreSQL y Redis en docker-compose. TypeScript estricto, ESLint, Prettier, EditorConfig. Configuración validada al arrancar. Registro estructurado con identificador de correlación. docker compose up deja el sistema funcionando con un solo comando. npm run lint y typecheck sin avisos. Falta una variable de entorno y el proceso no arranca, con un mensaje claro.4–6 h
TF-02
Modelo de datos y migraciones
Las 9 entidades con sus cardinalidades, índices y restricciones. Migración inicial. Seeder con factorías y semilla fija. Índice único parcial para el borrado lógico. La migración se aplica y se revierte sin errores. El seeder genera 5 proyectos, 20 usuarios y 500 tareas reproducibles. El esquema generado coincide con las entidades (schema:check limpio).6–8 h
TF-03
CRUD de proyectos y tareas
Controladores, servicios y DTO con validación. Filtro global de errores en formato Problem Details. Interceptor de envoltura. Paginación, filtros y orden. Documentación OpenAPI. Todos los endpoints de proyectos y tareas responden según el contrato. Los errores salen en problem+json. Swagger publicado y correcto. Ningún endpoint devuelve entidades sin DTO.10–14 h
TF-04
Autenticación y autorización
Registro con Argon2id, login, JWT de 15 min, refresco rotativo con familias, guard de roles con Reflector, límite de tasa en el login, gestión de sesiones. Un lector recibe 403 en toda escritura. Reutilizar un refresh consumido revoca la familia. 6 intentos fallidos en un minuto devuelven 429 con Retry-After. Ninguna respuesta incluye el hash de la contraseña.10–14 h
TF-05
Frontend base
Rutas con carga diferida, layout con barra lateral, guard de autenticación con returnUrl, interceptor de token con refresco, store de sesión con señales, tema claro y oscuro. Entrar en una ruta protegida sin sesión lleva al login y vuelve al destino tras entrar. El refresco es transparente y no se duplica. Cada ruta principal es un fragmento independiente del bundle.10–14 h
TF-06
Listados con filtros y paginación
Listado y tablero con filtros combinables sincronizados con la URL, orden, paginación, estados de carga, vacío y error, y virtualización si hay más de 200 filas. Recargar la página conserva los filtros. Teclear en el buscador no dispara una petición por tecla y cancela la anterior. El listado con 5.000 tareas se desplaza con fluidez.10–14 h
TF-07
Formularios y validación extremo a extremo
Formularios reactivos de tarea y proyecto con validación cruzada y asíncrona, componente de etiquetas con ControlValueAccessor, mapeo de los errores 422 del servidor a los controles, bloqueo optimista con resolución de conflictos. Las mismas reglas se validan en cliente y servidor. Un 409 muestra un diálogo de conflicto con los datos actuales. Los errores del servidor se pintan en el campo correspondiente.10–14 h
TF-08
Tiempo real y notificaciones
Gateway WebSocket autenticado con salas por proyecto, eventos de dominio publicados tras el commit, actualización optimista en el cliente, cola BullMQ para los correos con reintentos e idempotencia. Un cambio aparece en otra pestaña en menos de 1 s. Quien no es miembro no recibe eventos del proyecto. Reiniciar el worker a mitad no duplica correos.12–16 h
TF-09
Tests y calidad
Tests unitarios del dominio, de integración de los repositorios, e2e de la API con base de datos real, tests de componente con HttpTestingController y un recorrido e2e de navegador. Puerta de calidad en CI. Cobertura ≥ 80 % en dominio y servicios. La suite pasa con orden aleatorio y con TZ=UTC. Ningún test depende de otro. Los tests de autorización cubren los tres roles.14–20 h
TF-10
Docker, CI y despliegue
Dockerfile multietapa con usuario sin privilegios, imagen final mínima. CI con lint, typecheck, tests, build, auditoría de dependencias y Lighthouse. Migraciones automáticas en el despliegue. Health checks y despliegue sin tiempo de inactividad. README y ADR. La imagen de la API pesa menos de 250 MB y no corre como root. La CI falla si baja la cobertura o si hay una vulnerabilidad alta. Un despliegue con tráfico no pierde ni una petición.10–14 h
Cómo trabajar los hitos Una rama y un pull request por hito, con su descripción, sus pruebas y su registro de decisiones. No pases al siguiente sin cumplir los criterios de aceptación del anterior: la deuda técnica de un hito se multiplica en los siguientes. El total ronda las 100–135 horas, entre seis y diez semanas a ritmo de tarde.

22.9.7 Rúbrica de evaluación

Puntúa cada criterio de 0 a su peso máximo, siendo honesto contigo mismo: es la única forma de que el ejercicio sirva. Por debajo de 60 el proyecto no es presentable; entre 60 y 74 es un proyecto de bootcamp correcto; entre 75 y 89 es un proyecto que abre puertas; por encima de 90 es un proyecto que demuestra criterio de arquitecto.

CriterioPesoInsuficiente (0–40 %)Notable (60–80 %)Excelente (100 %)
Funcionalidad20Faltan historias o hay flujos rotosTodas las historias, algún criterio de aceptación sin cumplirLas 17 historias con todos sus criterios, incluidos los casos límite
Arquitectura15Lógica en los controladores, entidades expuestas Capas separadas, algún acoplamientoDominio aislado, dependencias invertidas, módulos con fronteras claras y decisiones documentadas
Calidad de código15any, código duplicado, nombres opacos Estricto y legible, con algún restoCero any, nombres precisos, funciones cortas, sin duplicación, linter limpio
Tests15Menos del 40 % o solo camino feliz≥ 70 %, e2e de lo principal≥ 80 % con ramas de error, e2e por rol, sin intermitencias, orden aleatorio
Seguridad15Secretos en el repositorio, sin autorización real, contraseñas mal guardadasAutenticación y autorización correctasAdemás: cabeceras, límites de tasa, rotación de refresh, auditoría, dependencias sin vulnerabilidades
Rendimiento10N+1 evidentes, sin paginaciónConsultas razonables e índices básicosPresupuestos medidos y cumplidos, índices justificados con EXPLAIN, caché con invalidación
Documentación5README genéricoInstrucciones que funcionan y OpenAPIAdemás: ADR de las decisiones, diagramas y guía de contribución
Experiencia de usuario5Sin estados de carga ni de errorEstados cubiertos y diseño coherenteAdemás: accesible con teclado y lector de pantalla, responsive, mensajes de error útiles y accionables

22.9.8 Extensiones opcionales para destacar

22.10 Tres mini-proyectos alternativos

Si no dispones de las cien horas que pide TaskFlow, estos tres proyectos de 12 a 20 horas cubren buena parte del temario cada uno. Son también excelentes piezas de portafolio: pequeños, terminados y con una decisión técnica interesante que contar en una entrevista.

MP-1 · Acortador de URL con estadísticas

12–16 h. Una API que acorta URL y cuenta las visitas.

  • Generación de códigos cortos sin colisiones y códigos personalizados.
  • Redirección 301/302 con contabilización asíncrona (la redirección no espera a la escritura).
  • Estadísticas: visitas por día, referente y país, con funciones de ventana.
  • Caducidad, desactivación y límite de tasa por IP.
  • Caché en Redis del código a URL con invalidación al editar.

Ejercita: NestJS, MikroORM, índices, caché, colas, SQL analítico, límite de tasa. Corresponde a los capítulos 9 a 17.

MP-2 · Panel de métricas con SSE

14–18 h. Un panel que se actualiza solo, sin recargar.

  • Endpoint Server-Sent Events que emite métricas cada segundo.
  • Frontend Angular con señales, gráficos y reconexión automática.
  • Agregación por ventanas temporales en la base de datos.
  • Filtros por rango de fechas sincronizados con la URL.
  • @defer para los gráficos y virtual scroll en la tabla de eventos.

Ejercita: señales, RxJS en la frontera, SSE frente a WebSocket, funciones de ventana, rendimiento del frontend. Capítulos 4 a 8 y 16.

MP-3 · Reservas con concurrencia

16–20 h. Pocas pantallas, mucha corrección.

  • Reserva de plazas limitadas sin sobreventa, con restricción en la base de datos.
  • Bloqueo pesimista frente a optimista: implementa los dos y compáralos midiendo.
  • Prueba de carga que lanza 500 reservas simultáneas sobre 100 plazas.
  • Cancelación con lista de espera y promoción automática.
  • Auditoría completa de cada cambio de estado.

Ejercita: transacciones, niveles de aislamiento, bloqueos, restricciones de integridad, pruebas de concurrencia. Capítulos 14 a 18.

22.11 Resumen y siguiente paso

  • El conocimiento que no se recupera con esfuerzo no se consolida. Veinte minutos de intento honesto antes de mirar la solución valen más que releer el capítulo tres veces.
  • Las katas de tipos no son acertijos: infer, los tipos condicionales y los tipos mapeados son exactamente el mecanismo con el que están escritos Angular, NestJS y MikroORM por dentro.
  • En Angular, las señales modelan estado y los observables modelan eventos en el tiempo. La frontera entre ambos (toSignal, toObservable) es una decisión de diseño, no un detalle.
  • En NestJS, el ciclo de la petición es el mapa mental completo: middleware, guard, interceptor, pipe, controlador, servicio, interceptor de vuelta y filtro. Casi cualquier requisito transversal encaja en una de esas piezas.
  • En MikroORM, todo se reduce a tres ideas: Identity Map, Unit of Work y flush. Los fallos «imposibles» casi siempre son un fork que falta, un flush que no se llamó o un N+1.
  • El SQL no es opcional. EXPLAIN, los índices compuestos y las funciones de ventana resuelven en minutos problemas que en la capa de aplicación cuestan semanas.
  • Depurar es medir, no adivinar. Reproducir, formular una hipótesis falsable, medir y corregir la causa; después, la prueba que lo habría detectado.
  • En diseño no se evalúa la respuesta, sino el razonamiento: qué alternativas hay, qué trade-offs tiene cada una y qué dato te haría cambiar de opinión.
  • Un proyecto terminado enseña más que diez empezados. TaskFlow existe para eso.
Siguiente paso Empieza hoy por el hito TF-01 de TaskFlow, aunque solo dispongas de una hora: tener el entorno funcionando elimina la fricción que hace abandonar los proyectos. En paralelo, resuelve dos katas de TypeScript al día; en seis días habrás terminado el bloque 22.2. Cuando el proyecto esté en marcha, el capítulo 23 pone a prueba lo aprendido con más de 150 preguntas de entrevista y examen, incluidas las de código en vivo y las de diseño de sistemas.

22.12 Recursos adicionales

22.12.1 Sitios de katas y práctica deliberada

22.12.2 Repositorios de referencia y proyectos de práctica

22.12.3 Conjuntos de datos públicos para poblar la base de datos

Trabajar con 500 filas de lorem ipsum oculta todos los problemas de rendimiento. Estos conjuntos permiten cargar volúmenes realistas y ver aparecer los N+1 y los índices que faltan.