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.
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:
- 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.
- 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.
- 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.
- Comprueba que compila (
tsc --noEmitsin errores y sin ningúnanyni@ts-ignoreañadido por desesperación) y que pasa los casos de prueba del enunciado. - 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.
- 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.
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:
| Criterio | Qué significa exactamente | Cómo se comprueba |
|---|---|---|
| Compila en estricto | Sin errores con strict: true,
noUncheckedIndexedAccess y sin any explícito ni implícito. |
npx tsc --noEmit |
| Pasa los tests | Todos los casos del enunciado, incluidos los límite (vacío, nulo, error). | npx vitest run |
| Maneja el error | Toda ruta de fallo está contemplada: no hay catch vacíos ni
promesas sin capturar. | Test que fuerza el fallo |
| Sin recursos colgando | Timers limpiados, suscripciones canceladas, conexiones cerradas,
AbortController disparado. | El proceso de test termina solo |
| Nombres honestos | El nombre dice qué hace, no cómo está implementado. Nada de
data, temp, handleIt. | Lectura en voz alta |
| Explicable | Puedes 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.
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
{
"name": "katas-fullstack",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2023", "DOM"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"]
}
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);
});
});
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):
| Prefijo | Bloque | Cantidad | Tiempo estimado |
|---|---|---|---|
TS-nn | Katas de TypeScript (22.2) | 12 | 6–10 h |
NG-nn | Retos de Angular (22.3) | 14 | 12–18 h |
NEST-nn | Retos de NestJS (22.4) | 14 | 12–18 h |
ORM-nn | Retos de MikroORM (22.5) | 14 | 10–16 h |
SQL-nn | Retos de SQL (22.6) | 10 | 4–6 h |
DEBUG-nn | Casos de depuración (22.7) | 8 | 4–6 h |
DIS-nn | Ejercicios de diseño (22.8) | 6 | 4–8 h |
TF-nn | Hitos del proyecto integrador (22.9) | 10 | 60–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.
| Nivel | Descripción |
|---|---|
| 0 · Nada | No supe ni por dónde empezar. Vuelve al capítulo teórico correspondiente. |
| 1 · Con ayuda | Lo resolví mirando la solución. Repítelo de memoria en 48 horas. |
| 2 · Con esfuerzo | Lo resolví solo, pero tardé mucho o mi versión tiene fallos de casos límite. |
| 3 · Sólido | Lo resolví solo y bien. Mi versión es equivalente a la propuesta. |
| 4 · Dominado | Lo 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.
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
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 tú 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.
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
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.
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
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.
inferEnunciado. 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
/** 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.
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
/** 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.
DeepReadonlyEnunciado. 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
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.
Result<T, E>, errores sin excepcionesEnunciado. 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
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)));
}
}
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.
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
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.
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
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;
};
}
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.
AsyncLocalStorage y contexto de peticiónEnunciado. 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
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);
}
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?».
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
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.
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
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-Aftersi 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.
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.
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
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.
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.
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.
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
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.
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.
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)">×</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
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.
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).
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.
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
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.
@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.
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
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).
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); }
}
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.
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
// 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.
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[];
}
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.
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[]) { … }
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
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.
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.
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
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,
}),
});
@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.
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
// 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.
@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).
// 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
@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);
}
}
@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.
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.
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.
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.
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. 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. 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
| Alternativa | Cómo funciona | Ventaja | Riesgo |
|---|---|---|---|
| Comprobar en el servicio | SELECT de solapamientos y luego
INSERT | Trivial de escribir | Incorrecta. Entre las dos sentencias cabe otra transacción |
| Bloqueo pesimista de la sala | SELECT … FOR UPDATE sobre la fila de la sala
| Simple y correcta | Serializa todas las reservas de esa sala |
| Restricción de exclusión | EXCLUDE USING gist sobre
(sala_id, rango) | La garantiza el motor: imposible violarla | Especí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.
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.
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.
| Dato | Capa | TTL | Invalidación |
|---|---|---|---|
| Imágenes y estáticos | CDN | 1 año | Huella en el nombre del archivo |
| Ficha (nombre, descripción) | CDN + Redis | 1 h / 24 h | Evento
producto.actualizado purga la clave |
| Precio | Redis | 60 s | Evento de cambio de precio, purga inmediata |
| Stock exacto | Sin caché | — | Consulta directa |
| Indicador «hay stock» | Redis | 30 s | Tolerable y suficiente para el listado |
| Resultados de búsqueda | Redis por consulta normalizada | 5 min | Caducidad natural |
| Carrito, pedidos, perfil | Sin caché compartida | — | Datos 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.
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ón | Revocación | Coste por petición | Veredicto |
|---|---|---|---|
| JWT largo (30 días) | Imposible | Cero | Descartada: incompatible con el requisito |
| Sesión opaca en Redis | Inmediata | 1 lectura de Redis | Válida y simple; el punto único de fallo es Redis |
| Access corto + refresh rotativo | ≤ 15 min para el acceso, inmediata para el refresco | Cero en el caso normal | Elegida |
| Access corto + lista negra | Inmediata | 1 lectura por petición | Solo 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.
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.
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.
| Mecanismo | Ejemplo | A favor | En contra |
|---|---|---|---|
| URI | /v1/tareas | Explícito, visible, trivial de enrutar y cachear | La versión «contamina» todas las URL |
| Cabecera | Accept: application/vnd.tf.v2+json | URL estables, purista | Difícil de probar en un navegador; caché por cabecera |
| Parámetro | ?version=2 | Cómodo | Se pierde en redirecciones; ensucia la caché |
| Por fecha | X-Api-Version: 2026-07-31 | Granular; 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
| ID | Historia | Criterios de aceptación |
|---|---|---|
| HU-01 | Como 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-02 | Como 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-03 | Como 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-04 | Como 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-05 | Como 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-06 | Como lector no quiero poder modificar nada. | Toda escritura responde 403; la interfaz no muestra los botones; la prueba e2e verifica ambas cosas. |
| HU-07 | Como 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-08 | Como 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-09 | Como 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-10 | Como 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-11 | Como 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-12 | Como 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-13 | Como 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-14 | Como 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-15 | Como 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-16 | Como 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-17 | Como 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
| Ámbito | Requisito medible | Cómo se verifica |
|---|---|---|
| Rendimiento | p95 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 |
| Frontend | LCP < 2,5 s y CLS < 0,1 en 3G simulada; bundle inicial < 300 kB comprimido. | Lighthouse en CI con presupuesto de tamaño |
| Seguridad | Contraseñ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 |
| Accesibilidad | WCAG 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 |
| Observabilidad | Registro 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 |
| Fiabilidad | Migraciones 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 |
| Calidad | Cobertura 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 ruta | Cuerpo de la petición | Respuesta correcta | Có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 refresco | 200, 400, 401, 429 |
POST /auth/refresh público | — (cookie) | {accessToken, expiraEn} | 200, 401 |
POST /auth/logout | — | — | 204 |
GET /auth/sesiones | — | [{id, dispositivo, ip, ultimoUso}] | 200, 401 |
DELETE /auth/sesiones/:id | — | — | 204, 401, 404 |
GET /usuarios/yo | — | {id, email, nombre, perfil} | 200, 401 |
PATCH /usuarios/yo | {nombre?, perfil?} | Usuario actualizado | 200, 400, 401 |
GET /proyectos | — (?pagina&tamano&q) |
{datos:[{id,nombre,pendientes}], total, pagina, tamano} | 200, 401 |
POST /proyectos | {nombre, descripcion?} |
Proyecto + cabecera Location | 201, 400, 401 |
GET /proyectos/:id | — | Proyecto con miembros y recuentos | 200, 401, 403, 404 |
PATCH /proyectos/:id | {nombre?, descripcion?, archivado?} |
Proyecto actualizado | 200, 400, 403, 404, 409 |
DELETE /proyectos/:id | — | — | 204, 403, 404 |
POST /proyectos/:id/miembros | {email, rol} | Miembro creado | 201, 400, 403, 409 |
PATCH /proyectos/:id/miembros/:uid | {rol} | Miembro actualizado | 200, 403, 404 |
DELETE /proyectos/:id/miembros/:uid | — | — | 204, 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 + Location | 201, 400, 403, 422 |
GET /tareas/:id | — | Tarea con etiquetas, asignado y contador de comentarios | 200, 403, 404 |
PATCH /tareas/:id | {…campos} + cabecera If-Match: version |
Tarea actualizada | 200, 400, 403, 404, 409 |
PATCH /tareas/:id/mover | {estado, posicion} | Tarea movida | 200, 403, 404, 409 |
DELETE /tareas/:id | — | — (borrado lógico) | 204, 403, 404 |
POST /tareas/:id/restaurar | — | Tarea restaurada | 200, 403, 404 |
GET /tareas/:id/comentarios | — (?cursor) | {datos, siguiente} | 200, 403, 404 |
POST /tareas/:id/comentarios | {texto, padreId?} | Comentario creado | 201, 400, 403, 422 |
DELETE /comentarios/:id | — | — | 204, 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
| Hito | Objetivo y tareas | Criterios de aceptación | Est. |
|---|---|---|---|
| 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 |
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.
| Criterio | Peso | Insuficiente (0–40 %) | Notable (60–80 %) | Excelente (100 %) |
|---|---|---|---|---|
| Funcionalidad | 20 | Faltan historias o hay flujos rotos | Todas las historias, algún criterio de aceptación sin cumplir | Las 17 historias con todos sus criterios, incluidos los casos límite |
| Arquitectura | 15 | Lógica en los controladores, entidades expuestas | Capas separadas, algún acoplamiento | Dominio aislado, dependencias invertidas, módulos con fronteras claras y decisiones documentadas |
| Calidad de código | 15 | any, código duplicado, nombres opacos |
Estricto y legible, con algún resto | Cero any, nombres precisos, funciones
cortas, sin duplicación, linter limpio |
| Tests | 15 | Menos del 40 % o solo camino feliz | ≥ 70 %, e2e de lo principal | ≥ 80 % con ramas de error, e2e por rol, sin intermitencias, orden aleatorio |
| Seguridad | 15 | Secretos en el repositorio, sin autorización real, contraseñas mal guardadas | Autenticación y autorización correctas | Además: cabeceras, límites de tasa, rotación de refresh, auditoría, dependencias sin vulnerabilidades |
| Rendimiento | 10 | N+1 evidentes, sin paginación | Consultas razonables e índices básicos | Presupuestos medidos y cumplidos, índices justificados con EXPLAIN,
caché con invalidación |
| Documentación | 5 | README genérico | Instrucciones que funcionan y OpenAPI | Además: ADR de las decisiones, diagramas y guía de contribución |
| Experiencia de usuario | 5 | Sin estados de carga ni de error | Estados cubiertos y diseño coherente | Además: accesible con teclado y lector de pantalla, responsive, mensajes de error útiles y accionables |
22.9.8 Extensiones opcionales para destacar
- Multi-tenant real con aislamiento por esquema o con row level security de PostgreSQL, y un test que demuestre que un tenant no puede leer datos de otro ni forzando el identificador.
- Búsqueda de texto completo con
tsvector, ponderación por campo, resaltado de coincidencias y tolerancia a errores tipográficos con trigramas. - Renderizado en servidor con Angular SSR e hidratación incremental, midiendo la mejora real de LCP frente a la versión SPA.
- Modo sin conexión con Service Worker, cola de mutaciones pendientes y resolución de conflictos al reconectar.
- Trazado distribuido con OpenTelemetry: una traza que cruce navegador, API, base de datos y worker de la cola.
- Arquitectura hexagonal estricta en el módulo de tareas, con puertos, adaptadores y un dominio que no importe nada de NestJS ni de MikroORM.
- Feature flags con evaluación en servidor y cliente, y despliegue progresivo por porcentaje de usuarios.
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.
@deferpara 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.
22.12 Recursos adicionales
22.12.1 Sitios de katas y práctica deliberada
- Type Challenges — el mejor gimnasio del sistema de tipos de TypeScript, de «warm-up» a «extreme»; complementa directamente el bloque 22.2.
- Exercism · TypeScript — ejercicios con revisión de código por mentores humanos, que es lo que de verdad corrige los malos hábitos.
- Codewars — katas cortas con soluciones de la comunidad; útil para la agilidad, no para la arquitectura.
- PostgreSQL Exercises — ejercicios de SQL progresivos con solución explicada; el complemento natural del bloque 22.6.
- Use The Index, Luke! — el mejor recurso gratuito sobre índices y planes de ejecución para desarrolladores.
- Front-End Handbook — mapa del ecosistema para detectar tus lagunas antes de estudiar a ciegas.
22.12.2 Repositorios de referencia y proyectos de práctica
- RealWorld — la misma aplicación implementada en decenas de stacks, con especificación y tests comunes; ideal para comparar tu backend NestJS con otras soluciones.
- Ejemplos oficiales de NestJS — más de treinta proyectos mínimos, cada uno centrado en una funcionalidad concreta.
- Tests de MikroORM — cuando la documentación no cubre un caso, la respuesta está aquí; leer los tests de una librería es una habilidad infravalorada.
- Tutoriales oficiales de Angular — guías interactivas actualizadas con la API moderna de señales.
- Node.js Best Practices — más de ochenta prácticas con su justificación; excelente lista de verificación para el hito TF-10.
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.
- Pagila — el clásico esquema de videoclub para PostgreSQL, con relaciones N:M y datos suficientes para practicar consultas.
- Volcados de Stack Exchange — millones de preguntas, respuestas y comentarios anidados: perfecto para probar la CTE recursiva de SQL-04 y la búsqueda de texto completo a escala real.
- Kaggle Datasets — miles de CSV de todos los tamaños; busca uno de más de 100.000 filas para el ejercicio ORM-14.
- datos.gob.es — datos abiertos de la administración española, con acentos, eñes y casos reales de codificación que te ahorrarán una sorpresa en producción.
- Faker.js — generación de datos sintéticos con localización
es, imprescindible para los seeders del hito TF-02. - Conjuntos para pruebas de carga — colecciones ligeras para generar volumen sin descargar gigabytes.