Category: Arquitectura de Software

  • Qué es el graph engineering: el mapa que tu agente no tiene

    Qué es el graph engineering: el mapa que tu agente no tiene

    Le pedí a un agente que renombrara una función. getUserData → fetchUserProfile. Dos minutos de trabajo.

    Hizo grep, encontró siete referencias, las cambió, corrió los tests. Verde. Commit.

    Reventó al día siguiente. La función también se invocaba desde un mapa de handlers, handlers[action], con el nombre viajando como string dentro de un JSON de configuración. Grep encontró siete referencias. Había doce.

    El agente no falló por falta de contexto ni por usar un modelo flojo. Falló porque grep solo compara cadenas y nadie le dio un mapa de relaciones. De eso va el graph engineering.

    Qué es el graph engineering (y el lío que hay con el nombre)

    Graph engineering es la práctica de representar tu código y tu documentación como un grafo explícito de relaciones: los nodos son símbolos —archivos, funciones, clases, conceptos— y las aristas son las relaciones reales entre ellos: importa, llama, hereda, contiene, referencia.

    En vez de que el agente busque texto y adivine, navega aristas.

    Antes de seguir, un aviso honesto: el término no tiene una definición canónica única en 2026. Se usa para dos cosas distintas.

    La primera es el grafo de orquestación. Nodos como unidades de ejecución, aristas como flujo de control: LangGraph, org graphs, work graphs. Ahí el "graph engineering" es diseñar cómo se conectan varios agentes. Es la conversación que arrancó Peter Steinberger en julio de 2026 con una pregunta de seis palabras — "Are we still talking loops or did we shift to graphs yet?" — y que es la continuación natural de lo que conté en loop engineering.

    La segunda es el grafo de recuperación. Nodos como símbolos de tu código, aristas como dependencias reales. Aquí no se decide qué agente actúa después: se decide qué sabe el agente antes de tocar nada.

    Este post va de la segunda. Y no compiten: una es control de flujo, la otra es recuperación. Misma palabra, dos capas del stack.

    Si necesitas el atajo: cuando hables de LangGraph o de coordinar varios agentes, es la primera. Cuando hables de qué código ve tu agente antes de editar, es la segunda.

    Las tres preguntas que ni grep ni los embeddings responden

    Hay tres preguntas sobre tu código que ni la búsqueda por texto ni la búsqueda semántica pueden responder:

    1. Si cambio esto, ¿qué se rompe? El radio de impacto a uno, dos o tres saltos. Grep te da el primer nivel. El transitivo no lo ve nadie.
    2. ¿Quién llama a quién? El call graph completo, con su dirección. Grep te dice que dos archivos mencionan AuthService. No te dice cuál lo consume y cuál lo define.
    3. ¿Qué depende de qué — y qué no depende de nada? Los nodos con grado cero son código muerto, y salen solos. Buscar código muerto con grep es un ejercicio de paciencia.

    Las tres son preguntas sobre topología, no sobre contenido. Por eso hacen falta aristas — y por eso las dos herramientas que usas hoy se quedan cortas.

    Tu agente tiene dos formas de encontrar código, y las dos tienen el mismo agujero.

    Grep busca coincidencia exacta de texto. Es preciso, rápido y determinista. No sabe nada de significado ni de estructura. Si la referencia está construida en runtime, no existe para grep.

    Los embeddings buscan parecido semántico. Encuentran la función de autenticación aunque se llame verificarCredenciales. Pero "se parece" no es "está conectado con". Un chunk sobre logging y otro sobre logging viven cerca en el espacio vectorial aunque uno nunca llame al otro. Es la limitación estructural de RAG que ya toqué en RAG vs fine-tuning.

    Las dos herramientas responden "¿dónde aparece esto?". Ninguna responde "¿con qué está conectado esto?".

    Resumido, con la tercera vía al lado:

    Grep Embeddings Grafo de código
    Pregunta que responde ¿Dónde aparece esta cadena? ¿Dónde hay algo parecido a esto? ¿Con qué está conectado esto?
    Qué necesitas saber antes El nombre exacto Una descripción aproximada Que el símbolo exista
    Ve el segundo salto No No Sí — affected --depth 2
    Ve llamadas indirectas No No Sí, marcadas como INFERRED
    Nivel de certeza Binario: aparece o no aparece Puntuación de similitud EXTRACTED o INFERRED
    Coste de mantenerlo Cero Reindexar + coste de embeddings Re-extracción AST, sin LLM
    Dónde se rompe La referencia se construye en runtime Dos cosas se parecen pero no se llaman Código muy dinámico: DI por string, metaprogramación

    Anatomía del grafo: nodos, aristas y confianza

    Un grafo de código tiene tres piezas: nodos (los símbolos: archivos, funciones, clases), aristas (las relaciones entre ellos) y un nivel de confianza por arista.

    Lo concreto. Construí un grafo con graphify —CLI open source, parseo AST local con tree-sitter, sin vector store— sobre un proyecto pequeño que tengo por ahí. Pequeño a propósito: quería poder verificar a mano cada arista antes de creerme nada. Salieron 115 nodos y 240 aristas.

    Los nodos llevan poco: id, etiqueta, archivo de origen y línea. Lo interesante está en las aristas.

    {
      "source": "src_chunker",
      "target": "src_chunker_needs_chunking",
      "relation": "contains",
      "confidence": "EXTRACTED",
      "source_file": "src/chunker.py",
      "source_location": "L10"
    }
    

    Los ocho tipos de relación que aparecieron en ese grafo:

    Relación Qué conecta ¿La ve grep?
    imports / imports_from Archivo → módulo o símbolo importado Sí, si el nombre aparece literal
    contains Archivo → función o clase que declara Parcialmente
    calls Función → función que invoca Solo el primer nivel
    references Símbolo usado sin invocarlo Sí, si el nombre aparece literal
    inherits Clase → clase base Sí
    method Clase → método que le pertenece Sí
    indirect_call Llamada resuelta en runtime No

    Fíjate en la última fila. indirect_call es exactamente la llamada que grep no ve.

    Y ahora el campo que más me interesa de todo esto, el que casi nadie menciona: confidence. Cada arista viene marcada como EXTRACTED o INFERRED. En mi grafo: 197 extraídas, 43 inferidas.

    EXTRACTED significa que la relación está literalmente en el AST. El parser la leyó, no la dedujo. INFERRED significa que la resolvió el motor uniendo puntos — una llamada cuyo destino tuvo que deducirse.

    Eso cambia cómo usas el resultado. Una arista EXTRACTED la das por buena. Una INFERRED es una hipótesis con nombre y apellidos que puedes ir a verificar al archivo y la línea que te da. Ni los embeddings ni grep te dan esa distinción: grep afirma sin matices, y el score de un embedding te dice cuánto se parece algo, nunca de dónde sale la relación. Aquí lo que se etiqueta es la procedencia.

    Un explain sobre un nodo devuelve esto:

    Node: needs_chunking()
      Source:    src/chunker.py L10
      Degree:    6
    
    Connections (6):
      <-- main() [calls] [INFERRED]
      <-- transcribe() [calls] [INFERRED]
      <-- chunker.py [contains] [EXTRACTED]
      --> Path [references] [EXTRACTED]
      <-- test_needs_chunking_false_for_small_file() [calls] [INFERRED]
      <-- test_needs_chunking_true_for_large_file() [calls] [INFERRED]
    

    Seis líneas. Ahí está el vecindario directo de esa función, con la dirección de cada arista y el nivel de confianza de cada una. Para llegar a lo mismo con grep necesitas varias pasadas y saber de antemano qué buscar.

    Pero el vecindario directo no es el radio de impacto. Para eso hay un comando aparte, que es el que responde literalmente a la pregunta 1: un recorrido inverso por las aristas que tú elijas, a la profundidad que tú digas.

    graphify affected "needs_chunking" --depth 2 --relation calls
    
    Affected nodes for needs_chunking()
    Relations: calls
    Depth: 2
    - test_needs_chunking_false_for_small_file() [calls] tests/test_chunker.py:L24
    - test_needs_chunking_true_for_large_file() [calls] tests/test_chunker.py:L30
    - main() [calls] transcribe.py:L17
    - transcribe() [calls] watch.py:L36
    - test_output_flag_saves_to_specified_path() [calls] tests/test_integration.py:L34
    - test_file_not_found_exits_with_code_1() [calls] tests/test_integration.py:L48
    - test_unsupported_format_exits_with_code_1() [calls] tests/test_integration.py:L55
    - test_api_key_not_in_output() [calls] tests/test_integration.py:L65
    - .on_created() [calls] watch.py:L72
    

    Mira la diferencia. De las seis conexiones del explain, solo cuatro eran llamadas entrantes. El affected a dos saltos da nueve, y las cinco nuevas son las interesantes: los cuatro tests de integración y el handler .on_created() del watcher no tocan needs_chunking directamente, llegan a través de main() y transcribe().

    Ese es el segundo nivel. El que revienta en producción al día siguiente y el que ninguna búsqueda por texto te va a dar, porque no hay ninguna cadena que buscar: la relación existe en la topología, no en el código fuente de esos archivos.

    Aquí está la tesis, y quiero decirla sin vender humo: el grafo no te garantiza encontrar la referencia indirecta. Te da una categoría donde esa relación puede existir y quedar marcada. Grep ni siquiera tiene esa categoría. Esa es toda la diferencia, y es suficiente.

    Cómo usar un grafo de código con un agente de coding, en 3 pasos

    Tres piezas.

    Uno: construyes el grafo y lo dejas en el repo. graphify-out/graph.json más un reporte en markdown. Es un artefacto de tu proyecto, como el lockfile.

    uv tool install graphifyy   # doble "y" mientras reclaman el nombre en PyPI;
                                # el comando y el skill siguen siendo graphify
    graphify install            # registra el skill en tu agente
    graphify update .           # re-extrae solo lo que cambió, sin LLM
    

    Dos: le das al agente una regla de precedencia. Sin esto no sirve de nada, porque el modelo tira de grep por costumbre. En el CLAUDE.md del proyecto:

    - Para preguntas sobre el código, ejecuta primero `graphify query "<pregunta>"`.
      Usa `graphify path "<A>" "<B>"` para relaciones, `graphify explain "<X>"`
      para un concepto concreto y `graphify affected "<X>"` antes de modificar o
      borrar algo. Devuelven un subgrafo acotado, mucho más pequeño que el reporte
      completo o la salida cruda de grep.
    - Después de modificar código, ejecuta `graphify update .`.
    

    Esa regla es la diferencia entre tener un grafo y usarlo. Es la misma idea de fondo que trabajo en el curso de Construye con IA: el agente no es más listo por tener más herramientas, sino por tener reglas claras de cuándo usar cuál.

    Tres: el grafo entra en la ventana como subgrafo, no como volcado. Un explain devuelve seis líneas donde un grep te vuelca cada aparición del término y tú decides después: recuperas menos tokens y mejores, que es el objetivo del context engineering.

    Ojo con una cosa: graphify query no devuelve una respuesta en prosa. Devuelve un recorrido BFS con los nodos encontrados. Es una herramienta de recuperación dentro del harness, no un chatbot. Quien interpreta el subgrafo sigue siendo el modelo.

    Y un apunte de higiene: el proyecto publica cifras de benchmark en su README. Son autoreportadas. Trátalas como lo que son y mide en tu repo.

    Cuándo NO merece la pena montar un grafo de código

    No todo proyecto necesita esto. Cuatro casos donde el grafo estorba más de lo que ayuda.

    Proyectos pequeños. Si el código entra entero en la ventana, el agente ya tiene el grafo en la cabeza y mejor resuelto. Montar recuperación para veinte archivos es sobreingeniería.

    Código muy dinámico. Metaprogramación intensa, inyección de dependencias por string, event buses, decoradores que reescriben comportamiento en runtime. El AST no puede ver lo que solo existe cuando el proceso arranca. El grafo saldrá con más aristas INFERRED que EXTRACTED, o directamente con huecos. Sigue siendo mejor que grep, pero baja mucho el techo.

    Y sí: el bug con el que abrí este post vive justo en esta frontera. Un nombre viajando dentro de un JSON no está en ningún AST. Lo que cambia es que el grafo marca ese hueco como INFERRED o lo deja sin arista, y eso es una señal que puedes leer. Grep te devuelve siete referencias con la misma cara de seguridad que si fueran las doce.

    Si no puedes mantenerlo actualizado. Un grafo obsoleto es peor que no tener grafo, porque el agente confía en él. Necesitas graphify update en un hook de pre-commit, en CI o con graphify watch. Si esto no está automatizado, no lo montes: en dos semanas tienes un mapa de un territorio que ya no existe.

    Si lo que buscas es "qué debería hacer este sistema". El grafo describe el código que existe, no la intención. Para eso el artefacto es la spec — que es, por cierto, otra forma de estructura explícita, y la razón por la que escribí el libro de Spec-Driven Development. El grafo cuenta el presente. La spec define el futuro.

    Y un apunte de madurez: graphify va por la 0.9.x. No es 1.0 todavía, y se nota. Herramienta útil, no infraestructura estable.

    Cómo empezar con graph engineering hoy

    Coge tu repo más feo. El que da miedo tocar.

    Construye el grafo, ejecuta un explain sobre la función que más te intimida y mira su grado. Si el número te sorprende, acabas de descubrir por qué ese refactor lleva meses aplazado.

    El mismo razonamiento sale del código y funciona con lo que sabes: si tus notas son un grafo en vez de una carpeta, se recuperan igual de bien. Lo conté en Zettelkasten para developers.

    Si quieres ver cómo encaja esto con el resto del stack —agentes, MCP, memoria, specs— lo trabajamos a fondo en Dominicode Labs, con proyectos reales y no con ejemplos de juguete.

    Preguntas frecuentes

    ¿Graph engineering es lo mismo que GraphRAG?

    No exactamente. GraphRAG es la implementación de Microsoft que usa un LLM para extraer entidades y relaciones de texto no estructurado, detectar comunidades y resumirlas. Está pensado para corpus documentales.

    Graph engineering es el concepto general de estructurar conocimiento como grafo. Aplicado a código, el grafo se extrae del AST de forma determinista, sin LLM y sin coste por token. GraphRAG es una implementación posible, no la única ni la más barata para código.

    ¿Qué diferencia hay entre graph engineering y loop engineering?

    El loop engineering diseña el bucle de ejecución del agente: qué hace, cómo verifica el resultado y cuándo vuelve a intentarlo. El graph engineering, en la acepción de este post, diseña lo que el agente sabe antes de entrar en ese bucle: un mapa de relaciones de tu código en vez de una búsqueda de texto.

    No compiten. Un agente con un buen bucle y sin mapa repite el mismo error más rápido. Si tus fallos vienen de contexto estructural incompleto, el grafo rinde antes que otra iteración del loop.

    ¿Funciona con TypeScript o solo con Python?

    Los ejemplos de este post salen de un proyecto en Python, pero la extracción es por AST con tree-sitter y las gramáticas que trae cubren los lenguajes habituales: Python, TypeScript, JavaScript, Go, Rust, Java, C, C++, Ruby, C#, Kotlin, Scala y PHP.

    Con TypeScript hay un matiz: cuanto más tira el proyecto de inyección por token, decoradores y factories, más aristas caen en INFERRED. El grafo sigue siendo mejor que grep, pero léelo sabiendo qué parte es hipótesis.

    ¿Necesito una base de datos de grafos como Neo4j?

    Para un repo, no. El grafo de un proyecto normal cabe en un JSON en disco y se recorre con un BFS en memoria. Herramientas como graphify funcionan así, sin servidor y sin dependencias externas.

    Neo4j tiene sentido cuando el grafo es un producto en sí mismo, se consulta desde varios servicios o supera lo que quieres cargar en memoria. Para dar contexto estructural a un agente en tu máquina, es infraestructura que no necesitas.

    ¿El grafo sustituye a los embeddings y a la búsqueda semántica?

    No, y montarlo como sustituto es un error. Responden preguntas distintas.

    Los embeddings responden "¿dónde hay algo parecido a esto?" y toleran que no sepas los nombres exactos. El grafo responde "¿con qué está conectado esto?" y exige que el símbolo exista. Lo razonable es tener las dos vías y una regla de precedencia: para preguntas de estructura, grafo; para exploración difusa, semántica; para strings literales, grep.

    ¿Cada cuánto hay que reconstruir el grafo?

    En cada cambio de código relevante, y automatizado. La re-extracción incremental de código no necesita LLM, así que el coste es tiempo de CPU, no dinero.

    Lo práctico es un hook de pre-commit, un paso en CI o un proceso en watch mientras trabajas. Reconstruirlo a mano cuando te acuerdas es la vía rápida a un grafo obsoleto, y un grafo obsoleto le miente al agente con toda la confianza del mundo.

    ¿Sirve en monorepos grandes?

    Es donde más rinde, precisamente porque el código ya no cabe en la ventana de contexto y grep devuelve ruido. La pega es operativa: la visualización HTML se vuelve pesada por encima de unos miles de nodos, y para eso está la opción de saltarla y quedarte solo con el JSON consultable, que es lo que consume el agente.

    Y si tu organización tiene varios repos en vez de uno solo, puedes fusionar sus grafos en uno para cruzar dependencias entre paquetes.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Programación defensiva en TypeScript: casi todos la hacen mal

    Programación defensiva en TypeScript: casi todos la hacen mal

    El bug tardó dos días en encontrarse y quince segundos en arreglarse.

    El panel de facturación de un cliente mostraba 0 € de descuento a gente que sí lo tenía. Solo a veces, sin patrón. Nadie había tocado ese módulo en meses.

    La causa estaba en tres líneas: un as UserProfile sobre la respuesta del fetch, un ?? 0 sobre el descuento y, tres capas más arriba, un catch que logueaba y seguía. Tres líneas escritas para proteger el código.

    Esa es la trampa de la programación defensiva tal y como la practica casi todo el mundo: no hace el sistema más robusto, hace los fallos más silenciosos.

    El culpable de fondo era una caché que bajo carga devolvía un 200 con el perfil incompleto. Eso no llegó a ningún log.

    Qué es la programación defensiva: decidir dónde desconfías

    La programación defensiva en TypeScript es escribir código que sigue comportándose de forma predecible cuando recibe datos o condiciones que no esperaba. Se concreta en tres decisiones: validar de forma exhaustiva en las fronteras del sistema, fallar de inmediato y con contexto cuando algo no cuadra, y modelar los tipos para que los estados inválidos no se puedan ni construir.

    La versión mala la conoces: try/catch envolviendo todo, comprobar null en cada función interna, copias defensivas por si acaso, validar los argumentos de tus propios métodos privados. Mucho código de más que no atrapa nada.

    La versión que funciona son tres decisiones:

    1. Valida en las fronteras y confía en el interior.
    2. Falla rápido y ruidoso.
    3. Haz que los estados inválidos no se puedan representar.

    No es desconfiar de tu propio código. Es elegir con precisión los sitios donde desconfías —pocos, explícitos, en el borde— para poder confiar en todo lo demás.

    Guard clauses: la programación defensiva que se nota al leer

    Una guard clause es una salida temprana que valida una precondición y aborta la función antes de entrar en la lógica principal. Empieza por aquí, que es lo más barato. Esto lo he visto con nombres distintos en muchos repos:

    async function publicarPost(userId: string, draftId: string) {
      const user = await repo.findUser(userId)
      if (user) {
        if (user.plan !== 'free') {
          const draft = await repo.findDraft(draftId)
          if (draft && draft.ownerId === user.id) {
            if (draft.body.length > 0) {
              return repo.publish(draft.id)
            }
          }
        }
      }
      throw new Error('No se pudo publicar el post')
    }
    

    Cuatro niveles de indentación y un error final que no dice nada. Cuando salte en producción no sabrás si el usuario no existe, si el borrador es de otro o si venía vacío.

    Dale la vuelta:

    async function publicarPost(userId: string, draftId: string) {
      const user = await repo.findUser(userId)
      if (!user) throw new NotFoundError(`user ${userId}`)
      if (user.plan === 'free') throw new ForbiddenError(`plan free no publica: user ${user.id}`)
    
      const draft = await repo.findDraft(draftId)
      if (!draft) throw new NotFoundError(`draft ${draftId}`)
      if (draft.ownerId !== user.id) throw new ForbiddenError(`draft ${draftId} no es de ${user.id}`)
      if (draft.body.length === 0) throw new ValidationError(`draft ${draftId} sin contenido`)
    
      return repo.publish(draft.id)
    }
    

    El camino feliz queda al final, sin indentar, y cada salida lleva su motivo. No has añadido lógica: has sacado las excepciones del flujo.

    (NotFoundError, ForbiddenError y ValidationError son tres clases propias que extienden Error. El tipo del error es lo que luego mapeas a un 404, un 403 o un 422 en un único sitio.)

    Valida en las fronteras, confía en el interior

    Una frontera es cualquier sitio donde entran datos que no controlas: input de usuario, respuesta de una API externa, un fichero, un mensaje de una cola, process.env, los params de una URL.

    Ahí toca ser exhaustivo, y ahí casi nadie lo es porque TypeScript da una falsa sensación de seguridad:

    const res = await fetch(`/api/invoices/${id}`)
    const invoice = (await res.json()) as Invoice   // cero validaciones en runtime
    total += invoice.amount * invoice.rate           // ¿y si amount llega como "1250"?
    

    Ese as es una mentira que el compilador se cree: no comprueba nada, solo le prometes al type checker que confíe. Si el backend cambia amount de número a string, TypeScript sigue verde y el bug aparece dos pantallas más allá.

    Y aquí JavaScript te hace un favor envenenado. "1250" * 1.21 da 1512.5, no da error: la coerción silenciosa produce un número plausible y todo sigue funcionando. Un NaN sería una suerte, porque se ve. Lo que rompe de verdad son los casos que casi funcionan: "1.250,00" sí da NaN, y una cadena vacía da 0 — el mismo cero fantasma del principio de este post, entrando ahora por otra puerta.

    La frontera se valida con un esquema. Zod encaja bien porque el tipo sale del esquema, no al lado del esquema:

    import { z } from 'zod'
    
    const Invoice = z.object({
      id: z.uuid(),
      amount: z.number().int().nonnegative(),   // céntimos
      rate: z.number().positive(),
      status: z.enum(['draft', 'sent', 'paid']),
    })
    type Invoice = z.infer<typeof Invoice>
    
    async function fetchInvoice(id: string): Promise<Invoice> {
      const res = await fetch(`/api/invoices/${id}`)
      if (!res.ok) throw new Error(`GET /invoices/${id} devolvió ${res.status}`)
    
      try {
        return Invoice.parse(await res.json())
      } catch (cause) {
        throw new Error(`respuesta inválida de GET /invoices/${id}`, { cause })
      }
    }
    

    (Los formatos de string van al primer nivel desde Zod 4: si sigues en la 3, z.uuid() es z.string().uuid().)

    A partir de ese parse, Invoice es verdad. No un deseo. Por eso ninguna función interna vuelve a preguntar si amount es un número: revalidar lo ya validado en el borde es ruido que te hace creer que estás cubierto donde no lo estás. Para exprimir la herramienta, el curso de Zod.

    Pero el interior tiene bordes propios, y son los que nadie mira: la base de datos —una columna JSON, una migración corrida a mano, un campo que el ORM jura que no es nulo y en producción tiene nulos de 2023—, un módulo legacy sin strict en medio de tu app, y lo que devuelve una librería de terceros cuyo .d.ts solo opina. La regla corta: si el tipo no lo produjo un parse tuyo, es frontera aunque esté dentro.

    La frontera más rentable y la más ignorada es la configuración: un esquema de process.env en el arranque convierte "la app lleva dos horas fallando raro" en "la app no arranca y te dice qué falta".

    Parse, don't validate: que el tipo cargue con la prueba

    Parse, don't validate —el principio que Alexis King formuló en 2019— dice que una comprobación no debe devolver un booleano, sino un dato con un tipo más estrecho que demuestre que la comprobación ocurrió.

    Una función de validación clásica devuelve un boolean y tira la información a la basura:

    declare function isEmail(value: string): boolean
    
    if (isEmail(input)) {
      await sendWelcome(input)   // input sigue siendo string
    }
    
    // 200 líneas después, en otro fichero
    await sendWelcome(req.body.email)   // compila igual, nadie validó nada
    

    El problema no es la regex. Es que después del if no queda rastro en el sistema de tipos de que la comprobación ocurrió.

    Devuelve el dato convertido a un tipo más estrecho:

    type Email = string & { readonly __brand: 'Email' }
    
    const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    
    export function parseEmail(value: string): Email {
      const normalizado = value.trim().toLowerCase()
      if (!EMAIL_RE.test(normalizado)) throw new ValidationError(`email inválido: ${value}`)
      return normalizado as Email
    }
    
    async function sendWelcome(to: Email) { /* ... */ }
    
    const email: string = req.body.email
    
    sendWelcome(email)              // ❌ error de compilación: string no es Email
    sendWelcome(parseEmail(email))  // ✅ única forma de entrar
    

    Ya es imposible escribir a una dirección sin validar: no porque te acuerdes, sino porque no compila.

    Con una excepción que conviene saber: si el dato sale de un req.body que es any —lo que te da Express por defecto—, el any se cuela y compila igual. El brand te protege del interior; del exterior te protege el esquema de la frontera. Los dos, no uno.

    El único as que me permito es el de dentro del parseo, encerrado en cuatro líneas auditables. Con Zod tienes el atajo, aunque el tipo hay que extraerlo: const Email = z.email().brand<'Email'>() y type Email = z.infer<typeof Email>.

    Y si lo que quieres es decidir cuándo merece la pena montar un validador de esquemas, lo comparé en detalle en cuándo usar Zod en lugar de TypeScript para validar en runtime.

    Falla rápido y ruidoso: el fail fast que sí protege

    Un error que explota donde se produjo cuesta minutos de depuración. El mismo error tragado cuesta días. Los sospechosos habituales:

    try {
      applyConfig(await loadConfig())
    } catch (e) {
      console.error('error cargando config', e)   // y la app arranca con los defaults
    }
    
    const descuento = user.discount ?? 0
    const items = res.data?.items || []
    

    El catch que loguea y sigue es peor que no tener catch: además de no arreglar nada, deja la conciencia tranquila en la revisión de código. Cuando el que ejecuta el código es un agente el problema se multiplica, y de eso va cómo manejar errores en agentes de IA con TypeScript.

    Y los valores por defecto silenciosos merecen párrafo propio. ?? 0 no significa "no hay descuento", significa "no sé si hay descuento". Al escribirlo conviertes no sé en sí sé, y vale cero. Eso no es un fallback, es el bug. Con || [] igual: nadie distingue un carrito vacío de una petición que falló.

    Un valor por defecto es legítimo cuando la ausencia del dato es un estado real del dominio, no cuando es el síntoma de que algo se rompió antes.

    El compilador de TypeScript como primera línea de defensa

    Cada comprobación que mueves a tiempo de compilación es una que no escribes, ni mantienes, ni testeas.

    Lo obvio primero: strict activado, any prohibido y noUncheckedIndexedAccess si te atreves. Si arrastras un proyecto sin strict, migrar a TypeScript 6.0 con strict activado es lo primero que haría, antes de tocar nada más.

    Después, modela para que el estado imposible no exista. Este tipo permite { status: 'paid' } sin fecha, { status: 'pagado' } con typo y un pendiente con fecha de pago:

    type Pago = { status: string; paidAt?: Date; receiptUrl?: string }
    

    Este otro no permite ninguno de los tres:

    type Pago =
      | { status: 'pending' }
      | { status: 'paid'; paidAt: Date; receiptUrl: string }
    

    Y para lo que debe ser cierto siempre, el patrón assertNever:

    function assertNever(x: never): never {
      throw new Error(`caso no manejado: ${JSON.stringify(x)}`)
    }
    
    function colorDeEstado(pago: Pago): string {
      switch (pago.status) {
        case 'pending': return 'gray'
        case 'paid':    return 'green'
        default:        return assertNever(pago)
      }
    }
    

    Añade 'refunded' al union y el build se rompe señalando cada switch pendiente. Sin esa línea, el caso nuevo devuelve undefined un jueves por la tarde.

    Qué NO es programación defensiva

    Esto separa la técnica del dogma. Ninguna de estas cosas te protege:

    • try/catch global que traga. Convierte un fallo localizado en un misterio distribuido.
    • Comprobar null en funciones privadas que solo llamas tú. Si ya validaste arriba, no puede saltar nunca: código muerto que aparenta cuidado.
    • Revalidar en cada capa la forma de lo ya validado en la frontera. Si no confías en tu tipo Invoice, el problema es el tipo, no la capa. Los permisos son otra historia: eso sí se comprueba lo más cerca posible del dato, aunque ya lo hayas comprobado arriba.
    • Copias defensivas por defecto. Clonar todo lo que entra y sale cuesta, y resuelve un problema que casi nunca tienes —si publicas una librería, la copia en el borde de tu API pública sí se paga sola.
    • Programar para requisitos hipotéticos. El parámetro opcional "por si algún día" es una rama sin testear.
    Parece defensivo Qué hace en realidad Qué hacer en su lugar
    try/catch global que loguea y sigue Convierte un fallo localizado en un misterio distribuido Relanzar con cause, o manejarlo con una acción concreta
    Comprobar null en funciones privadas Código muerto que aparenta cuidado Confiar en el tipo parseado en la frontera
    Revalidar la forma en cada capa Ruido que sugiere que el tipo miente Arreglar el tipo, no añadir capas
    as sobre res.json() Silencia al compilador sin comprobar nada Esquema.parse(await res.json())
    ?? 0 sobre un dato ausente Convierte "no sé" en "sí sé, y vale cero" Fallar, o modelar la ausencia como estado del dominio

    El coste no es rendimiento, es atención. Cada comprobación de más grita "aquí puede llegar un null" cuando no puede llegar. El lector acaba ignorándolas todas, y ese es el día en que se ignora la que sí importaba.

    Es el mismo mecanismo que conté en cuándo evitar los principios SOLID: un principio aplicado por dogma, sin medir el contexto, produce peor código que no aplicarlo.

    Por qué la programación defensiva importa más con código de IA

    Nada de lo anterior es nuevo. Lo que ha cambiado es quién escribe el código.

    Una parte creciente de lo que entra en tus repos no lo has teclado tú, lo ha generado un agente. No te voy a dar un porcentaje: abre el último PR que mergeaste y cuéntalo.

    El código generado es sintácticamente impecable y plausible: se lee bien, pasa el linter, convence en diez minutos de revisión. Falla en los casos límite y en las suposiciones sobre la forma de los datos —que el endpoint siempre devuelve el campo, que el array nunca viene vacío— y reparte ?? 0 y catch silenciosos, porque ha aprendido del código defensivo mal escrito de internet.

    Eso lo detectas leyendo despacio, no en una revisión rápida. Y vas a hacer revisiones rápidas, porque el volumen ha subido — un problema que merece su propio protocolo, y del que hablé en cómo gestionar PRs generadas por agentes en la revisión de código.

    Las fronteras validadas y el fallo ruidoso son la red que atrapa eso sin depender de que revises cada línea: si el esquema está en el borde, el dato con la forma equivocada muere en el parse, lo escriba quien lo escriba. Cuanto más código generes, más vale la red. En esa dirección va cómo garantizar la confiabilidad del código generado por IA.

    Hay un segundo movimiento, de proceso: la forma de los datos es lo que la spec fija antes de que el agente escriba una línea. Es el núcleo del libro de Spec-Driven Development.

    Tres cambios que puedes hacer hoy en 30 minutos

    Tres cosas, en este orden.

    1. Escribe el esquema de process.env y párselo en el arranque. Es la frontera más tonta de tu app y la que más tiempo te devuelve.
    2. Busca catch seguido de console. Cada uno es una decisión que alguien no tomó: o lo manejas, o lo relanzas con contexto usando cause.
    3. Añade assertNever al switch más grande que tengas sobre un union de estados. Tres líneas que convierten una clase entera de bugs de runtime en errores de compilación.

    Después lleva esas reglas a tu CLAUDE.md o AGENTS.md: esquema en las fronteras, prohibido as sobre respuestas externas, prohibido catch que solo loguea. Configurar así al agente antes de que escriba una línea es el flujo que enseño en Construye con IA.

    La programación defensiva no es desconfiar de tu código. Es decidir dónde desconfías para poder confiar en el resto. Elige tres fronteras esta semana y déjalas cerradas.

    Si quieres ver estos patrones sobre proyectos reales, con el código completo, es una de las conversaciones habituales en Dominicode Labs.

    Preguntas frecuentes

    ¿Qué es la programación defensiva?

    La programación defensiva es escribir código que sigue comportándose de forma predecible cuando recibe datos o condiciones que no esperaba. En su versión útil son tres decisiones: validar de forma exhaustiva en las fronteras del sistema, fallar de inmediato y con contexto cuando algo no cuadra, y modelar los tipos para que los estados inválidos no se puedan ni construir. No consiste en llenar el código de comprobaciones por si acaso: eso esconde los bugs.

    ¿La programación defensiva es lo mismo que envolver todo en try/catch?

    No. La programación defensiva y el try/catch global son estrategias opuestas: un try/catch que captura un error, lo loguea y continúa deja el programa corriendo con datos en estado desconocido, y el fallo aparece más tarde, en otro sitio y sin rastro de su causa.

    Captura un error solo cuando puedes hacer algo concreto: reintentar, devolver un 4xx, activar un fallback que sea un estado legítimo del dominio, o relanzarlo con new Error(mensaje, { cause }) — que necesita lib: ES2022 en tu tsconfig.

    ¿Dónde están las fronteras de mi aplicación?

    Las fronteras de una aplicación son los puntos por donde entran datos cuya forma no controlas: los handlers HTTP (body, query, params, headers), las respuestas de APIs de terceros, process.env, los ficheros que lees o te suben, los mensajes de una cola o un webhook, y localStorage.

    Añade dos que casi nunca se cuentan: la base de datos, porque una columna JSON o una migración corrida a mano te devuelven cualquier cosa; y las colas, porque el payload lo escribió la versión anterior de tu propio código. La regla corta: si el tipo no lo produjo un parse tuyo, es frontera aunque esté dentro.

    ¿Los tipos de TypeScript me protegen en producción?

    No, y es el malentendido más caro. Los tipos de TypeScript desaparecen al compilar, así que en ejecución no existe ninguna comprobación. Cuando escribes const data = await res.json() as MiTipo no validas nada: silencias al compilador con una promesa que el runtime nunca verifica.

    La frontera necesita un validador de esquemas —Zod es el que uso— que compruebe la forma real del dato y devuelva un tipo. Si quieres el criterio para decidir cuándo montarlo, lo comparé en cuándo usar Zod en lugar de TypeScript para validar en runtime.

    ¿Cuánto código defensivo es demasiado?

    Una comprobación es demasiada cuando no puede saltar nunca. La regla verificable: si no puedes nombrar el caller concreto que la haría fallar, bórrala. Y si la respuesta es "ninguno, porque el dato ya viene validado de la frontera", con más razón.

    La señal de alarma es un fichero con más líneas de defensa que de lógica de negocio.

    ¿Cómo aplico la programación defensiva al código que genera un agente de IA?

    La programación defensiva se aplica al código generado fijando las fronteras antes de generar y dejándolas por escrito en las instrucciones del agente. Tres reglas en tu CLAUDE.md o AGENTS.md cubren la mayor parte: toda entrada externa se valida con un esquema, prohibido as sobre datos sin parsear, y prohibido capturar un error solo para loguearlo.

    Funciona porque no depende de que detectes el fallo leyendo: si el esquema está en el borde, el dato con la forma equivocada muere ahí, lo escriba quien lo escriba.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Tests unitarios lentos: el número de Vitest que casi nadie mira

    Tests unitarios lentos: el número de Vitest que casi nadie mira

    Nuestro job de tests en CI tardaba doce minutos clavados. Setecientos siete ficheros, cuatro mil cuatrocientos tests. Nadie lo cuestionaba: una suite grande tarda, y punto.

    Hoy lo hemos dejado en seis minutos y quince segundos. Sin borrar un solo test, sin runners más caros, sin paralelizar nada. Solo cambiando qué entorno arranca cada fichero.

    Y lo interesante no es el 48 % que nos ahorramos. Es que llevábamos meses con tests unitarios lentos mirando el número equivocado.

    El número equivocado es el total. El total te dice que tienes un problema, pero no te dice dónde se va el tiempo. Y sin el dónde, optimizar es tirar cosas a la pared: cambias el entorno, subes los threads, añades runners, y a veces sale bien y a veces sale peor y nunca sabes por qué.

    Vitest te da el dónde al final de cada ejecución. Lo tienes impreso en tu terminal ahora mismo.


    Resumen rápido

    • El total del Duration te dice que tienes tests unitarios lentos, no dónde se va el tiempo. El desglose sí.
    • environment es la suma del arranque de cada fichero entre todos los workers, no el wall-clock del run. Compáralo contra tests, nunca contra Duration.
    • En nuestra suite: 803,6 s de environment contra 156 s de tests. Cinco veces más en montar el escenario que en ejecutarlo.
    • La causa: environment: 'jsdom' global para 707 ficheros, de los que solo 208 tocan el DOM.
    • El fix: dos proyectos de Vitest, la extensión decide el entorno. .test.ts a node, .test.tsx a DOM. 12m → 6m 15s en CI.
    • Shardear no arregla esto: algo más de la mitad del tiempo es coste fijo de arranque, y cada shard lo vuelve a pagar entero.

    Tests unitarios lentos: el desglose que Vitest imprime y nadie lee

    Debajo del Duration hay un paréntesis con seis campos: transform, setup, collect, tests, environment y prepare.

    En nuestro caso, los dos que importan salían así:

    Duration  106.31s (transform …, setup …, collect …, tests 156.00s, environment 803.60s, prepare …)
    

    Recorto los campos que no vienen al caso. Fíjate en la contradicción aparente: el run entero duró 106 segundos, pero dice que gastó 803 en environment.

    No es un bug. environment es la suma del arranque de cada fichero entre todos los workers, no el wall-clock del run. La suite corre en dieciséis workers y cada uno monta su propio entorno por fichero. La cifra que ves es la suma de todos ellos, así que puede ser más de siete veces mayor que el reloj de pared.

    La regla de lectura del desglose de Vitest es comparar acumulado contra acumulado: environment contra tests, nunca contra Duration. Duration es wall-clock; los otros dos son tiempos sumados entre ficheros y workers.

    Y ahí el número deja de ser abstracto: 803,6 segundos montando el escenario contra 156 ejecutando los tests. Cinco veces más en preparar que en actuar.

    Cuando environment multiplica varias veces a tests, el problema no son los tests lentos: es el arranque del entorno.


    707 ficheros arrancando un navegador, 208 usándolo

    El origen estaba en una línea del config: environment: 'jsdom', global, para los 707 ficheros.

    Conté los que tocaban el DOM de verdad. Eran 208. Los 499 restantes —parsers, cálculo de precios, mapeo de rutas de API, validadores— montaban un navegador falso entero para no usarlo jamás.

    Ese es el gasto que estábamos pagando cinco veces sobre el trabajo real. No era un problema de rendimiento del emulador. Era que la mitad larga de la suite no necesitaba emulador ninguno.

    La solución fue partir la suite en dos proyectos de Vitest con una regla que cabe en una frase: la extensión decide el entorno.

    El config, con Vitest 4.1.10 (julio de 2026). La clave test.projects sustituyó a workspace en Vitest 3.2, así que en versiones anteriores esto no aplica:

    // vitest.config.ts
    import { defineConfig } from 'vitest/config'
    import react from '@vitejs/plugin-react'
    
    export default defineConfig({
      test: {
        projects: [
          {
            // Lógica pura: node pelado. Sin plugins, sin setup, sin DOM.
            test: {
              name: 'unit',
              environment: 'node',
              include: ['src/**/*.test.ts'],
            },
          },
          {
            // Componentes: DOM emulado + testing-library.
            plugins: [react()],
            test: {
              name: 'dom',
              environment: 'jsdom',
              include: ['src/**/*.test.tsx'],
              setupFiles: ['./src/test/setup-dom.ts'],
            },
          },
        ],
      },
    })
    

    Elegir la extensión como criterio no es cosmético. Con globs por carpeta o por sufijo (*.spec.ts y *.component.spec.ts) te toca mantener un exclude, porque el primer patrón se traga los ficheros del segundo y esos tests se ejecutan dos veces, una de ellas en el entorno equivocado y fallando por un motivo que parece un bug de tu código.

    .test.ts y .test.tsx no se solapan nunca. Cero exclude, cero ambigüedad, y una regla que un compañero nuevo entiende sin preguntar: si tu test importa JSX, es .tsx y tiene DOM.

    Si trabajas en Angular la idea es idéntica desde que Vitest es el runner por defecto. Cambian los globs, no el razonamiento.

    Después dimos el segundo paso: cambiar una palabra en el proyecto dom, de jsdom a happy-dom. En un A/B aislado sobre esos 208 ficheros, 48,9 s → 34,9 s. Unos catorce segundos. Útil, pero un orden de magnitud por debajo de lo que dio separar los entornos.

    La comparativa completa entre los dos emuladores, con benchmark y las APIs que le faltan a cada uno, la tengo aparte en el post sobre happy-dom o jsdom.

    El resultado de las dos cosas juntas:

    Ámbito Antes Después Δ
    Job Test en CI 12m 00s 6m 15s −48 %
    Suite local (16 cores) 106,3 s 46,0 s −57 %
    environment (acumulado entre ficheros) 803,6 s 137 s −83 %
    Ficheros / tests 707 / 4.400 707 / 4.400 sin cambios

    Mismos tests. Mismas aserciones. Misma cobertura.


    El efecto secundario: dos tests que llevaban meses mintiendo

    Al cambiar el entorno, dos tests empezaron a fallar.

    Y tenían razón.

    El patrón era este, y lo he visto en todos los proyectos en los que he entrado:

    vi.spyOn(global, 'fetch')
      .mockResolvedValueOnce(ok(productos))
      .mockResolvedValueOnce(ok(stock))
    

    Parece un mock. No lo es.

    vi.spyOn(global, 'fetch') sin implementación envuelve la función original y sigue llamándola. Lo único que intercepta son las respuestas que has encolado con mockResolvedValueOnce. Y esa cola se agota: la primera llamada recibe productos, la segunda stock, y la tercera sale a la red de verdad.

    Nuestro componente hacía tres llamadas.

    Llevaba meses pidiendo datos a localhost:3000 desde el runner de CI. jsdom se lo tragaba en silencio y el test seguía en verde. Con happy-dom la petición real quedó a la vista, y ahí aparecieron los 401.

    El arreglo son cuatro líneas, y es la clase de cosa que debería estar en el setup de cualquier suite:

    // setup-dom.ts
    import { beforeEach, vi } from 'vitest'
    
    beforeEach(() => {
      vi.spyOn(global, 'fetch').mockRejectedValue(new Error('fetch sin mockear'))
    })
    

    Un default que revienta. Si un test necesita una respuesta, la encola encima; si se le olvida una llamada, el test falla con un mensaje que dice exactamente qué pasó, en vez de irse a internet a buscar suerte.

    Añade también restoreMocks: true en el config: mockRejectedValue en un beforeEach no vacía la cola de ...Once que haya dejado el test anterior, y esa cola sobrante es una fuga entre tests igual de silenciosa que la que acabas de tapar.

    Yo esto ya no lo discuto: un test que llega a la red no es un test unitario, es una apuesta. Es lento, es flaky, y depende del firewall del runner. Diseñar los mocks para que el hueco falle ruidosamente en vez de degradar en silencio es la mitad del trabajo de testear bien, y es la parte que más tiempo dedico a explicar en el curso de Testing en Angular.

    Nadie planea encontrar estos bugs. Aparecen cuando tocas los cimientos.


    ¿Merece la pena shardear los tests unitarios? Los números dijeron que no

    Nos dio un 27 % a cambio de cuatro runners, cuando el cálculo ingenuo prometía un 60 %. El motivo es que en unitarios la mayor parte del tiempo es arranque compartido, y repartirlo no lo divide: lo multiplica. Así llegamos ahí.

    Semanas antes habíamos partido la suite de E2E en shards concurrentes y el wall-clock se había desplomado. Fue de esas victorias que te dejan con ganas de repetir.

    Así que la pregunta era obvia: si funcionó con E2E, ¿por qué no con los unitarios?

    Los números decían que sí. De los 375 segundos del job, solo 31 eran setup —checkout, pnpm install, build de las librerías internas—, un 8 %. Con un overhead fijo tan bajo, repartir en tres debería habernos dejado en torno a los dos minutos y medio. Una mejora del orden del 60 %.

    Abrimos el PR, lo lanzamos, y esto es lo que salió:

    Job Tiempo
    Test (1) 4m 19s
    Test (2) 4m 32s ← wall-clock
    Test (3) 3m 57s
    Test (otros paquetes) 1m 38s

    375 s → 272 s. Un 27 %, a cambio de cuatro runners en vez de uno.

    Volvimos al desglose, que es lo que había que haber hecho antes de escribir el PR:

    Ámbito Ficheros Tiempo de tests
    Job completo 707 333 s
    Un shard 236 226 s

    Léelo despacio. Un tercio de los ficheros tarda el 68 % de lo que tardan todos.

    Si ajustas una recta T(n) = F + n·v con esos dos puntos, sale un coste fijo F de unos 172 segundos y una pendiente de 0,23 segundos por fichero. Traducido: de los 333 segundos, algo más de la mitad es peaje que pagas antes de ejecutar un solo test. Solo unos 160 dependen de cuántos ficheros tengas.

    Y aquí toca ser honesto con el método: dos puntos y dos incógnitas significa que la recta pasa por ambos por construcción. Es aritmética, no un perfilado. Te da el orden de magnitud del reparto entre lo fijo y lo variable, que es justo lo que necesitas para decidir, pero no lo cites como si fuera una medida.

    Ese coste fijo es transform más importación del grafo de dependencias, en frío, sin caché de Vite en el runner. Y cada shard lo paga entero, otra vez, desde cero.

    Con 3 shards pagas ese peaje tres veces. Con 6, seis. No hace falta el modelo para verlo: el shard más rápido de los tres, con 236 ficheros en vez de 707, todavía tardó 3m 57s. Por muchos runners que enchufes, el suelo se queda en unos cuatro minutos.

    La lección de E2E no transfería, y visto desde aquí es evidente. En Playwright cada test es trabajo independiente de navegador: repartir divide de verdad. En unitarios, la mayor parte del tiempo es arranque compartido, y repartir trabajo compartido no lo divide, lo multiplica.

    El sharding no era la palanca. La palanca era eliminar los ~172 segundos de coste fijo que cada shard vuelve a pagar entero.


    El PR sigue abierto, y creo que así está bien

    El PR #36 no está mergeado ni cerrado. Un 27 % por 4x runners es un trade flojo, y las tres opciones siguen sobre la mesa:

    • Cerrarlo. Los cien segundos no compensan cuadruplicar el consumo de CI ni la complejidad de un job matricial.
    • Bajarlo a 2 shards. Menos ganancia, la mitad de coste, y sospecho que el punto donde la curva todavía compensa.
    • Aparcarlo y atacar el coste fijo. Cachear node_modules/.vite entre runs, si esa caché existe en tu setup, porque hoy se reconstruye en frío cada vez. Si funciona, mejora los tres escenarios a la vez, incluido el de un solo runner.

    La tercera es la que tiene mejor pinta, y precisamente por eso no quiero decidirla con la misma prisa con la que abrimos el PR. Primero medir el arranque en caliente, después decidir.


    Qué hacer hoy si tienes tests unitarios lentos

    1. Lanza tus tests y mira el paréntesis del final. Solo eso.
    2. Compara environment con tests. Los dos son sumas acumuladas entre ficheros, así que la división tiene sentido. Si environment es el doble de tests, ya sabes dónde está tu problema, y no es donde llevas semanas buscándolo.
    3. Cuenta cuántos ficheros importan JSX o tocan document de verdad. En nuestro caso eran 208 de 707. En el tuyo probablemente sea una proporción parecida, porque un catálogo, un carrito o un dashboard tienen mucha más lógica que pintura.
    4. Separa por extensión. .test.ts a node, .test.tsx a DOM. Veinte minutos de trabajo, cero riesgo, ningún test tocado.

    La tesis de todo esto no es "usa happy-dom" ni "shardea tus tests". Es que medir el coste correcto —no el tiempo total, sino en qué se va— convierte una optimización a ciegas en un cambio de config de veinte líneas. El mismo desglose que nos quitó seis minutos nos evitó después tirar cuatro runners a un problema que no era de paralelismo.

    Y que de vez en cuando, al levantar los cimientos, encuentras un test que llevaba meses saliendo a internet sin que nadie se enterara.

    Si quieres ver este tipo de decisiones tomadas sobre proyectos reales, con los runs y los números delante en vez de con opiniones, es lo que hacemos en Dominicode Labs.


    Preguntas frecuentes sobre tests unitarios lentos

    ¿Por qué mis tests unitarios son lentos si cada test tarda milisegundos?

    Casi siempre porque el tiempo no se va en ejecutar los tests, sino en preparar el entorno de cada fichero. En nuestra suite, el desglose de Vitest daba 803,6 segundos acumulados en environment frente a 156 en tests: cinco veces más en montar el escenario que en actuar. Mientras esa proporción esté desequilibrada, optimizar aserciones o subir el número de threads no te va a dar nada: estarías acelerando la parte pequeña.

    ¿Qué significa environment en el resumen de Vitest?

    Es el tiempo dedicado a instanciar el entorno de test (jsdom, happy-dom o node) para cada fichero, sumado entre todos los workers. Es tiempo acumulado entre ficheros y workers, no wall-clock, y por eso puede ser mucho mayor que el Duration total: nosotros teníamos 803,6 segundos de environment en un run de 106,3 segundos corriendo sobre dieciséis workers. La comparación que tiene sentido es environment contra tests, porque ambas cifras están acumuladas de la misma forma.

    ¿Cómo separo los tests que necesitan DOM de los que no en Vitest?

    Con test.projects en vitest.config.ts: un proyecto con environment: 'node' para la lógica pura y otro con DOM emulado, plugin del framework y setupFiles para los tests de componente. Lo que mejor nos ha funcionado es decidir por extensión, .test.ts contra .test.tsx, porque son globs que no se solapan y no necesitas exclude. Si separas por carpeta o por sufijo compuesto, un mismo fichero puede caer en los dos proyectos y ejecutarse dos veces, una de ellas en el entorno equivocado.

    ¿Merece la pena shardear los tests unitarios en CI?

    Depende de qué proporción de tu tiempo sea coste fijo de arranque, y hay que medirlo antes de abrir el PR. En nuestro caso, tres shards dieron un 27 % de mejora a cambio de cuatro runners, cuando el cálculo ingenuo prometía un 60 %. El motivo es que cada shard vuelve a pagar entero el transform y la importación del grafo de dependencias, así que ese coste no se reparte, se multiplica. Con E2E la historia es distinta porque cada test es trabajo independiente de navegador y repartir sí divide.

    ¿Por qué vi.spyOn(global, 'fetch') no mockea mis llamadas?

    Porque spyOn sin implementación envuelve la función original y sigue llamándola. Solo intercepta las respuestas que hayas encolado con mockResolvedValueOnce, y esa cola se agota: en cuanto tu código hace una llamada más de las que encolaste, esa petición sale a la red de verdad. El arreglo es poner siempre un default que falle, con vi.spyOn(global, 'fetch').mockRejectedValue(new Error('fetch sin mockear')) en el setup, y encolar las respuestas concretas encima en cada test.

    ¿Esto aplica igual en Angular?

    Sí, y desde Angular 21 y 22 aún más, porque Vitest pasó a ser el runner por defecto y el entorno DOM dejó de ser una decisión implícita del builder de Karma. Cambian los globs, que serán .spec.ts con algún criterio propio para distinguir tests de componente de tests de servicio, pero el diagnóstico es idéntico: mira el desglose de environment, cuenta cuántos specs necesitan document y manda el resto a node.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • happy-dom o jsdom: qué entorno DOM elegir en tests unitarios

    happy-dom o jsdom: qué entorno DOM elegir en tests unitarios

    Cambié a happy-dom la mitad de la suite que toca el DOM, un martes por la mañana. Era la última pieza de un cambio que dejó el job de CI en 6m 15s, desde los doce minutos que tardaba esa misma mañana. Me sentí muy listo.

    Dos semanas después, un componente de lazy loading llegó roto a producción. El test seguía en verde. Lo ejecuté cincuenta veces y cincuenta veces me dijo que todo estaba bien.

    El problema no era el test. Era el suelo sobre el que corría. Elegir entre happy-dom o jsdom no es una micro-optimización de CI: es decidir qué mentiras está autorizada a contarte tu suite.

    Con jsdom, ResizeObserver no existe. El test revienta con un error escandaloso, instalas un mock, el mock dispara el callback y el test comprueba algo de verdad. Con happy-dom, ResizeObserver sí existe: es una clase que se instancia sin quejarse y cuyos tres métodos están vacíos por dentro. El callback no se llama jamás.

    Mi setup tenía una guarda del tipo if (typeof window.ResizeObserver === 'undefined') para instalar el mock. Con happy-dom esa condición no se cumplía nunca. El mock no se instalaba. El test verificaba el vacío.


    Resumen rápido

    • En tests unitarios, el runner (Vitest, Jest) no es el entorno. El entorno es la librería que emula el navegador debajo: jsdom, happy-dom o ninguna.
    • Vitest arranca por defecto en node, sin DOM. Jest también. El DOM siempre lo pides tú.
    • En mi benchmark, happy-dom resultó 1,77x más rápido que jsdom (mediana de 5 pares A/B, rango 1,45x–1,96x), no las 5x-10x que circulan por ahí.
    • Ninguno de los dos tiene motor de layout. getBoundingClientRect() devuelve ceros en ambos. Cambiar de entorno no arregla eso.
    • happy-dom cubre más superficie de API moderna que jsdom (matchMedia, showModal, scrollIntoView), pero incluye stubs mudos que fingen existir.
    • Regla: node por defecto, happy-dom para tests de componente, jsdom fichero a fichero cuando algo se rompa. Se mezclan en el mismo proyecto.

    El runner no es el entorno

    Esta confusión cuesta tardes enteras.

    Cuando escribes environment: 'jsdom', Vitest instancia un window completo por cada fichero de test y lo inyecta en el contexto global antes de importar tu código. El runner orquesta. El entorno es quien finge ser un navegador.

    Y por defecto no hay ninguno: Vitest arranca en node por defecto, sin document ni window. Jest hace lo mismo desde la versión 27, y desde la 28 ni siquiera trae jsdom — hay que instalar jest-environment-jsdom a mano.

    Angular es el caso más traicionero. Desde que Vitest se convirtió en el test runner por defecto en Angular 21, el builder @angular/build:unit-test detecta qué tienes instalado: si encuentra happy-dom lo usa, y si no, cae a jsdom. Basta con que alguien añada happy-dom al package.json por cualquier motivo para que toda tu suite cambie de suelo sin que nadie toque un fichero de configuración.

    Si vienes de Karma, ese cambio de suelo es la parte que menos se cuenta y más duele. Lo desarrollé en Vitest en Angular 22: por qué Karma ya no es el default.


    Qué es jsdom y qué es happy-dom, sin marketing

    jsdom es la implementación de referencia. Se publicó por primera vez en noviembre de 2011: casi quince años de historia y la base sobre la que se ha testeado medio ecosistema JavaScript. Su norma es la fidelidad a la especificación: si algo está implementado, se comporta como en el navegador; si no puede implementarlo bien, prefiere no implementarlo. Su documentación deja layout y navegación explícitamente fuera de alcance. Versión actual: 29.1.1, del 30 de abril de 2026. Nada nuevo desde entonces.

    happy-dom es un emulador con otra prioridad: arrancar rápido y cubrir lo que los frameworks modernos usan de verdad. Versión 20.11.1, del 22 de julio de 2026, con nueve versiones publicadas entre el 3 de junio y esa fecha.

    En disco: jsdom instala 25 MB y 21 dependencias directas; happy-dom, 19 MB y 7. La diferencia real es mucho menor de lo que sugieren las comparativas que verás por ahí.


    Benchmark happy-dom vs jsdom: lo medí en vez de citarlo

    Circulan cifras de "5x-10x más rápido" que nadie respalda. Monté la prueba, y publico los datos crudos para que puedas comprobar cada división.

    Metodología — benchmark ejecutado por Bezael Pérez (Dominicode) el 24 de julio de 2026:

    Carga 50 ficheros × 3 tests con DOM real: listas de 100 nodos, eventos con dispatchEvent, 200 mutaciones de clases y atributos
    Protocolo 5 pares de ejecuciones alternando A/B (jsdom, happy-dom, jsdom, happy-dom…) para anular la deriva de carga de la máquina
    CPU Intel i7-11700K, 16 hilos, 32 GB RAM, Windows 11
    Runtime Node 24.16.0
    Runner Vitest 4.1.10
    Entornos jsdom 29.1.1 · happy-dom 20.11.1

    Datos crudos. Tiempo total de suite, par a par:

    Par jsdom happy-dom Ventaja
    1 26,47 s 14,93 s 1,77x
    2 21,94 s 15,11 s 1,45x
    3 26,90 s 15,77 s 1,71x
    4 15,70 s 8,56 s 1,83x
    5 16,27 s 8,31 s 1,96x

    La ventaja de happy-dom es 1,77x, la mediana de esos cinco ratios.

    Ojo con un detalle que despista, porque yo mismo tropecé con él: la mediana de los tiempos de jsdom (21,94 s) dividida entre la mediana de los de happy-dom (14,93 s) da 1,47x. Pero esas dos medianas salen de pares distintos —la primera del par 2, la segunda del par 1— y dividirlas mezcla ejecuciones que no compartieron condiciones de máquina. En un diseño A/B emparejado, el estimador correcto es el ratio dentro de cada par, y su mediana es 1,77x.

    Con ese mismo criterio, el resto de métricas:

    Métrica jsdom (mediana) happy-dom (mediana) Ventaja por par
    Tiempo total de suite 21,94 s 14,93 s 1,77x
    Ejecución pura de los tests 4,29 s 1,74 s 2,5x
    Arranque del entorno (acumulado) 235,6 s 119,0 s 2,2x

    Y ahora el dato que de verdad cambia decisiones. Segunda suite, 50 ficheros con un único expect(1 + 1).toBe(2), que aísla el coste de levantar el entorno:

    Entorno Tiempo total Arranque por fichero
    node 1,55 s ~0,2 ms
    happy-dom 4,20 s ~0,75 s
    jsdom 7,71 s ~1,55 s

    Las dos tablas no miden lo mismo y no debes cruzarlas: el arranque acumulado de la primera incluye montar y desmontar un documento con cientos de nodos por fichero, con los 16 hilos saturados; la segunda mide levantar un DOM vacío. Compara cada tabla consigo misma.

    Dicho eso, léelo dos veces. Pasar de jsdom a happy-dom te da 1,8x. Pasar de jsdom a ningún DOM te da 5x.

    La optimización más rentable de tu suite no es cambiar de emulador. Es dejar de cargar un emulador en los tests que no tocan el DOM: reducers, servicios, validadores, utilidades puras. Esos no necesitan window, y probablemente son el 70% de tu suite.


    Dónde te rompe cada uno: qué APIs faltan en jsdom y en happy-dom

    Ejecuté el mismo fichero de sondeo en los dos entornos. Esto es lo que devolvió, no lo que dice la documentación:

    API jsdom 29.1.1 happy-dom 20.11.1
    getBoundingClientRect() todo a 0 todo a 0
    offsetWidth / offsetTop 0 0
    getComputedStyle() con estilos inline correcto correcto
    window.matchMedia no existe sí
    Element.scrollIntoView no existe sí
    document.elementFromPoint no existe sí
    dialog.showModal() no existe sí
    CSS.supports no existe sí
    navigator.clipboard no existe sí
    ResizeObserver no existe stub que nunca dispara
    IntersectionObserver no existe stub que nunca dispara
    canvas.getContext('2d') null, o real con el paquete canvas null, sin alternativa
    Element.animate (WAAPI) no existe no existe
    Custom elements y Shadow DOM sí sí

    Un matiz sobre dialog: jsdom sí define el constructor HTMLDialogElement, pero showModal, show y close no están en el prototipo. No es que lancen una excepción propia: es que 'showModal' in dialog devuelve false.

    Tres conclusiones incómodas.

    Una: el relato de "jsdom es más completo" es falso tal y como se cuenta. En superficie de API moderna gana happy-dom. jsdom sigue sin matchMedia en 2026, probablemente el mock más copiado y pegado de la historia del frontend.

    Dos: ninguno tiene layout. Si tu test necesita que getBoundingClientRect() devuelva algo distinto de cero, cambiar de entorno no te salva.

    Tres, la que me costó el susto: happy-dom prefiere un stub silencioso a un fallo ruidoso. Este es su ResizeObserver real, tal cual está en el repositorio:

    export default class ResizeObserver {
      public observe(): void {
        // TODO: Not implemented
      }
      public unobserve(): void {
        // TODO: Not implemented
      }
      public disconnect(): void {
        // TODO: Not implemented
      }
    }
    

    IntersectionObserver sigue el mismo patrón: guarda el callback en el constructor y expone un takeRecords() que devuelve siempre un array vacío, pero observe() tiene el cuerpo igual de hueco. Tu código lo instancia, llama a observe(), no pasa nada y el test sigue adelante. Un fallo ruidoso cuesta diez minutos. Uno silencioso cuesta un incidente.

    En lo fundamental son gemelos: probé validación de formularios, sanitización de input[type=number], ciclo de vida de custom elements, <template>, orden de propagación capture/bubble, resolución de URLs relativas y parseo de HTML mal formado. Resultado idéntico en ambos. Para el 95% de los tests de componente da exactamente igual cuál uses.


    Cómo se configuran, y cómo se mezclan

    Lo que casi nadie cuenta: no tienes que elegir uno para todo el proyecto. Con Vitest 4 defines proyectos por glob.

    // vitest.config.ts
    import { defineConfig } from 'vitest/config'
    
    export default defineConfig({
      test: {
        projects: [
          {
            test: {
              name: 'unit',
              environment: 'node',
              include: ['src/**/*.spec.ts'],
              exclude: ['src/**/*.component.spec.ts'],
            },
          },
          {
            test: {
              name: 'dom',
              environment: 'happy-dom',
              include: ['src/**/*.component.spec.ts'],
            },
          },
        ],
      },
    })
    

    Ese exclude no es decorativo. Sin él, src/**/*.spec.ts también captura los *.component.spec.ts, cada test de componente se ejecuta dos veces —una en node y otra en happy-dom— y la ejecución en node falla con un expected 'undefined' to be 'object' que parece un bug de tu componente y no lo es.

    Y cuando un fichero suelto necesite jsdom, lo declaras en la primera línea. Ese comentario gana a la configuración del proyecto:

    // @vitest-environment jsdom
    import { it, expect } from 'vitest'
    
    it('corre en jsdom aunque el proyecto use happy-dom', () => {
      expect(window.navigator.userAgent).toContain('jsdom')
    })
    

    Lo he verificado ejecutándolo: ese fichero arranca en jsdom mientras el resto de la suite sigue en happy-dom. Un solo test lento no justifica frenar los otros mil.

    En Angular la palanca es distinta, porque el builder elige por ti según lo que esté instalado. Lo robusto es no depender de esa autodetección: apunta la opción runnerConfig del builder a un vitest.config.ts con environment fijado explícitamente, y así da igual lo que aparezca en el package.json. Si prefieres la vía rápida, deja instalado solo uno de los dos:

    # alternativa: fuerza jsdom eliminando la otra opción
    npm uninstall happy-dom && npm install -D jsdom
    

    Y añade esto a tu fichero de setup para que los observers dejen de mentirte:

    // test-setup.ts
    import { vi, beforeEach } from 'vitest'
    
    class ResizeObserverMock {
      constructor(private cb: (entries: unknown[], obs: unknown) => void) {}
      observe = vi.fn((target: Element) =>
        this.cb([{ target, contentRect: target.getBoundingClientRect() }], this))
      unobserve = vi.fn()
      disconnect = vi.fn()
    }
    
    class IntersectionObserverMock {
      constructor(private cb: (entries: unknown[], obs: unknown) => void) {}
      observe = vi.fn((target: Element) => this.cb([{ target, isIntersecting: true }], this))
      unobserve = vi.fn()
      disconnect = vi.fn()
      takeRecords = vi.fn(() => [])
    }
    
    beforeEach(() => {
      vi.stubGlobal('ResizeObserver', ResizeObserverMock)
      vi.stubGlobal('IntersectionObserver', IntersectionObserverMock)
    })
    

    Dos clases, no una. Un mock compartido que emite { isIntersecting: true } para ambos revienta en cuanto un componente responsive lee entries[0].contentRect.width, porque esa propiedad no existe en la entry: TypeError: Cannot read properties of undefined. Cada observer tiene su forma de entry y hay que respetarla.

    Y fíjate en el otro detalle: asigno siempre, sin comprobar antes si existe. Esa comprobación es exactamente lo que me llevó a producción con un test verde y un componente roto.


    La regla para elegir entre happy-dom o jsdom

    Cinco pasos, en este orden. Los aplico tal cual.

    1. node por defecto. Si el test no toca document, no cargues DOM. Ahí está el 5x, no en la comparativa de emuladores.
    2. happy-dom para tests de componente. Casi el doble de rápido y con más API moderna cubierta. Es la elección por defecto en 2026.
    3. Nunca uses guardas del tipo if (typeof window.X === 'function') en el setup. Sobrescribe siempre los observers con mocks que disparen.
    4. jsdom fichero a fichero, no suite entera. ¿Un test necesita canvas real o un comportamiento de spec que happy-dom aproxima mal? // @vitest-environment jsdom en la línea 1 y sigues.
    5. Si necesitas layout de verdad, ningún emulador sirve. Posiciones reales, scroll real, capturas visuales: eso es Browser Mode de Vitest, estable desde la 4.0, con Playwright debajo. Más lento, y el único sitio donde esos tests significan algo.

    La excepción que invierte los pasos 2 y 4: si mantienes una librería de componentes que consumen otros, empieza en jsdom. Ahí prefieres un fallo ruidoso a una aproximación cómoda, porque el coste de un falso verde no lo pagas tú.

    Razonar sobre el entorno antes que sobre el aserto es la columna vertebral del curso de Testing en Angular, donde monto la suite desde cero decidiendo qué corre en node, qué en DOM emulado y qué en navegador real. Y si lo que te falta es la base del framework antes de entrar a testearlo, esa parte la cubro en el curso de Angular Moderno.


    Lo que puedes hacer hoy

    Abre tu vitest.config.ts y mira qué environment tienes a nivel global para tus tests unitarios.

    Si es jsdom o happy-dom para toda la suite, acabas de encontrar tu mayor ganancia de tiempo del trimestre: sepáralo en dos proyectos y manda a node todo lo que no toque document. Diez minutos de trabajo.

    Después añade el mock de los observers al setup. Porque el test que más te va a costar en tu carrera no es el que falla: es el que pasa por el motivo equivocado.

    Si quieres seguir tirando del hilo, tengo publicado Testing en Angular con IA: tests que protegen de verdad, donde ataco el mismo problema desde el otro lado. Y si prefieres verlo montado sobre un proyecto real y con gente a la que preguntar, te espero en Dominicode Labs.


    Preguntas frecuentes sobre happy-dom y jsdom

    ¿Qué es más rápido, happy-dom o jsdom?

    happy-dom. En el benchmark que ejecuté en Dominicode en julio de 2026 con Vitest 4.1.10, sobre 50 ficheros con manipulación real de DOM y cinco pares de ejecuciones alternadas, happy-dom resultó 1,77x más rápido que jsdom en mediana, con un rango de 1,45x a 1,96x. En ejecución pura de operaciones DOM la ventaja sube a 2,5x y en arranque del entorno es de 2,2x. Las cifras de "5x o 10x" que circulan no se corresponden con lo que mide una suite real. La ganancia grande está en no cargar ningún DOM: el entorno node fue 5 veces más rápido que jsdom en la misma máquina.

    ¿Merece la pena migrar de jsdom a happy-dom?

    Depende de dónde esté tu cuello de botella, y casi nunca está donde crees. Si tu suite tarda diez minutos, migrar a happy-dom te deja en unos seis: real, pero no transformador. Antes de eso, mira cuántos de tus tests cargan un DOM sin necesitarlo, porque mover esos a environment: 'node' da una mejora del orden de 5x en esa parte de la suite y no tiene ningún riesgo de compatibilidad. Mi recomendación es hacerlo en ese orden: primero separa node de DOM, después cambia el emulador y, si algún fichero se rompe, pásalo a jsdom con el comentario // @vitest-environment jsdom en lugar de revertir la migración entera.

    ¿Cuál usa Vitest por defecto?

    Ninguno de los dos. El valor por defecto de test.environment en Vitest es node, sin window ni document. Para tener DOM debes instalar jsdom o happy-dom y declararlo en vitest.config.ts o con el comentario // @vitest-environment en la cabecera del fichero. Jest se comporta igual: su entorno por defecto es node y desde Jest 28 hay que instalar jest-environment-jsdom como paquete aparte.

    ¿Qué entorno DOM usa Angular con Vitest?

    El builder @angular/build:unit-test detecta automáticamente qué tienes instalado: prefiere happy-dom si está presente y cae a jsdom si no. Conviene saberlo porque implica que añadir happy-dom al package.json por cualquier motivo cambia el entorno de toda la suite sin que nadie modifique la configuración. Si quieres un comportamiento predecible, fija environment de forma explícita en el fichero de configuración al que apunta la opción runnerConfig del builder, en vez de confiar en la autodetección.

    ¿Por qué mi test falla con "ResizeObserver is not defined"?

    Porque estás en jsdom, que no implementa ResizeObserver ni IntersectionObserver: la propiedad no existe en window. La solución es añadir un mock en el fichero de setup, con una clase distinta para cada uno, porque sus entries tienen forma diferente: contentRect en el de resize e isIntersecting en el de intersection. Ojo con el matiz: en happy-dom esas clases sí existen, pero sus métodos están vacíos y el callback no se ejecuta nunca. Si tu mock está protegido por una comprobación de existencia, en happy-dom no se instalará y tu test pasará sin comprobar nada.

    ¿Puedo usar happy-dom y jsdom en el mismo proyecto?

    Sí, y es la mejor estrategia. Con Vitest 4 defines varios proyectos en test.projects, cada uno con su environment y su glob de ficheros. Cuida los globs: si un proyecto incluye src/**/*.spec.ts y otro src/**/*.component.spec.ts, los ficheros de componente caen en los dos y se ejecutan por duplicado, así que necesitas un exclude en el primero. Además, el comentario // @vitest-environment jsdom en la primera línea de un fichero tiene prioridad sobre la configuración del proyecto, así que puedes mantener toda la suite en happy-dom y mover a jsdom solo los ficheros que lo necesiten. No hay que migrar en bloque.

    ¿Con cuál funciona getBoundingClientRect?

    Con ninguno. Ni jsdom ni happy-dom incorporan motor de layout, así que getBoundingClientRect(), offsetWidth y offsetTop devuelven cero en los dos. La documentación de jsdom lo declara explícitamente fuera de alcance. Si tu test depende de posiciones o tamaños reales, la única salida es un navegador de verdad: Browser Mode de Vitest, estable desde la 4.0, con Playwright por debajo.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • ExpressoTS 4.0, el framework TypeScript que planta cara a NestJS

    ExpressoTS 4.0, el framework TypeScript que planta cara a NestJS

    Cada cierto tiempo alguien me escribe con la misma pregunta: "Bezael, tengo que montar una API en Node. ¿Express pelado o NestJS?".

    Y la respuesta honesta durante años ha sido incómoda. Express te deja solo: sin DI, sin estructura, sin ciclo de vida. NestJS te da todo eso, pero a cambio de decoradores por todas partes y una curva que a un junior le cuesta semanas.

    En medio no había casi nada. Ese hueco es exactamente donde vive ExpressoTS 4.0, publicada el 17 de julio de 2026, casi veinte meses después de la v3, y que por primera vez no parece un proyecto experimental.

    Ojo con lo que voy a decir y con lo que no voy a decir. No voy a contarte que esto reemplaza a NestJS. No lo hace. El ecosistema de NestJS es incomparablemente más maduro: integraciones, documentación, gente que ya resolvió tu problema hace dos años. Lo que sí ha cambiado es que ahora existe una alternativa seria para quien quiere estructura sin cargar con todo el peso.

    Qué es ExpressoTS y por qué la 4.0 importa

    ExpressoTS es un framework TypeScript para backend sobre Node.js. Trae inyección de dependencias con contenedor IoC, routing, middleware, hooks de ciclo de vida, manejo de errores y bootstrap de la aplicación.

    Hasta la v3 era, básicamente, "Express con DI y decoradores". Útil, correcto, poco ambicioso.

    La 4.0 es otra cosa. Requiere Node.js >= 20.19.0, según las release notes oficiales de la v4.0.0, y trae bloques que hasta ahora te tocaba construir tú o importar de tres librerías distintas.

    Dónde queda cada uno, para que no tengas que deducirlo:

    Express ExpressoTS 4.0 NestJS
    Inyección de dependencias No Sí, contenedor IoC Sí, contenedor IoC
    Curva de aprendizaje Baja Media Alta
    Boilerplate Mínimo Moderado Alto
    Testing incluido No Sí, createTestApp() Sí
    Observabilidad incluida No Sí, Studio local Vía integraciones
    Madurez del ecosistema Muy alta Baja Muy alta

    Voy a ir a los bloques que de verdad cambian cómo escribes el código.

    Interceptors: AOP sin montarte tu propio framework

    Los interceptors de ExpressoTS 4.0 aplican cross-cutting concerns —caching, reintentos, transformación de respuestas— con ejecución condicional declarativa: la condición vive en el decorador, no dentro del interceptor.

    Este es el titular de la release, y no había visto la idea tan limpia en otros frameworks del ecosistema.

    // PerformanceInterceptor viene incluido. CacheInterceptor lo escribes tú.
    @UseInterceptors(
        PerformanceInterceptor,
        whenInterceptor(
            (ctx) => ctx.request.headers["x-cache"] === "true",
            CacheInterceptor,
        ),
    )
    

    Léelo despacio. El PerformanceInterceptor corre siempre. El CacheInterceptor corre solo si la request trae esa cabecera. No hay un if dentro del interceptor decidiendo si le toca trabajar o no. La condición vive fuera, en la declaración.

    Parece un detalle estético. No lo es. La cantidad de código que he visto en producción donde un interceptor empieza con seis líneas de "¿me toca actuar ahora?" es descorazonadora. Ahí es donde nacen los bugs de "en staging cachea y en prod no".

    Tienes también unlessInterceptor() para el caso inverso, auto-discovery por decoradores y tres interceptors listos de fábrica: LoggingInterceptor, PerformanceInterceptor y TimeoutInterceptor.

    Y para componerlos, dos utilidades que redondean el argumento: pipeInterceptors() los encadena en orden, y combineInterceptors() los lanza en paralelo para trabajo de solo efecto secundario, como logging o métricas.

    Eventos tipados con prioridad

    El sistema de eventos de ExpressoTS 4.0 combina auto-discovery de handlers, routing condicional y ejecución por prioridad, con tipado end-to-end vía IEventHandler<T>:

    @OnEvent(UserCreatedEvent, { priority: 1 })
    export class SendWelcomeEmailHandler implements IEventHandler<UserCreatedEvent> {
        handle(event: UserCreatedEvent) { /* ... */ }
    }
    

    El IEventHandler<UserCreatedEvent> es lo que hace que esto valga la pena. En cuanto alguien cambie la forma del evento, el compilador te avisa en todos los handlers.

    Sin eso, un sistema de eventos es una lista de strings mágicos esperando a romperse en el peor momento.

    Configuración type-safe y validación pluggable

    La configuración se declara con validación y variación por entorno:

    export default defineConfig({
        database: {
            url: Env.string("DATABASE_URL", { required: true }),
        },
    });
    

    Si falta DATABASE_URL, la aplicación no arranca. No revienta a los veinte minutos en la primera query. Falla en el arranque, que es donde debe fallar.

    Y esto conecta con una decisión de diseño que aplaudo: la Smart Validation de v4 usa un registry de adapters pluggable. Soporta class-validator, Zod y Yup. No te casan con una librería.

    Si vas a montar algo nuevo con esto, mi recomendación es Zod. Esquema y tipo en la misma declaración, sin decoradores, sin duplicar la forma del dato en dos sitios. Y si te preocupa el coste de tipado en un proyecto grande, ese problema tiene fecha de caducidad: ya conté cómo el compilador de TypeScript reescrito en Go cambia el juego.

    Si nunca has llevado Zod más allá de z.object(), en el curso de Zod para TypeScript cubro justo la parte que la gente se salta: transforms, refinements y validación en los bordes del sistema.

    ExpressoTS Studio: local, no cloud

    ExpressoTS Studio es una plataforma de desarrollo local: no envía tu tráfico a ningún servidor externo. Es la decisión más valiente de la release y merece sección propia.

    Te da un dashboard de estado, un mapa de arquitectura generado en vivo desde el grafo de dependencias, un request timeline con spans de OpenTelemetry, logs en directo, inspección de errores, replay de tráfico y una auditoría de seguridad con scoring basada en el tráfico real de tu entorno de desarrollo.

    El mapa de arquitectura generado desde el grafo DI es la parte que más me gusta. Documentación de arquitectura que no se queda obsoleta porque nadie la actualiza: se deriva del código.

    Y que sea local en vez de SaaS elimina de golpe la conversación con legal antes de empezar.

    El resto, en corto

    Hay más, y no todo necesita párrafos:

    • Lazy-loading de módulos con rutas auto-detectadas desde @controller() y preload hints (high, medium, low, never). Debería mejorar los cold starts, que en serverless es dinero.
    • Módulo de testing con createTestApp() a cero configuración, API fluida para HTTP, snapshot testing y load testing con métricas de percentiles.
    • Logging de 11 fases: structured logging, transports a fichero, gestión de contexto, consulta de logs y export a Markdown.
    • Guards por rol, por permiso y resource-owner, con utilidades de composición.
    • Health monitoring en tres capas: middleware pipeline, providers IHealthCheck y dashboard agregado.
    • Content negotiation RFC 7231: JSON, XML, CSV y YAML.
    • Scopes DI personalizados: tenant, transaction, workflow, session. Si haces multi-tenant, esto te ahorra un patrón entero.
    • API versioning por URL con el decorador @Version().
    • Errores RFC 7807 (problem details) con exception filters y sugerencias de ruta en los 404.
    • Lifecycle hooks: globalConfiguration(), configureServices(), postServerInitialization(), serverShutdown().

    Si vienes de v3: lo que se rompe

    Migrar de v3 a v4 tiene tres breaking changes obligatorios:

    1. Los patrones de DI cambian. Revísalos uno a uno.
    2. Los lifecycle hooks de app.ts hay que actualizarlos.
    3. Sube el runtime a Node.js >= 20.19.0.

    En soporte, el equipo ha sido razonable: según su política publicada, v4.0.0 recibe 24 meses de bugfixes y parches de seguridad. La v3.x tenía 18 meses y los han extendido hasta diciembre de 2026 para que la migración no sea una carrera.

    Una migración así es un caso de manual para trabajar con especificación antes que con código: describes el estado destino, listas los puntos de cambio y solo entonces dejas que un agente te ayude a ejecutarlo módulo a módulo. Es la metodología que documenté en el libro de Spec-Driven Development, y funciona especialmente bien cuando el cambio es amplio pero mecánico.

    Mi veredicto

    ExpressoTS 4.0 no es un juguete. Interceptors condicionales, eventos tipados, scopes DI de tenant y transaction, Studio local: son decisiones de gente que ha sufrido aplicaciones grandes.

    ¿Lo llevaría a un proyecto crítico, con equipo de quince personas y entrega en tres meses? Todavía no. NestJS tiene ecosistema, integraciones probadas y una comunidad enorme, y eso pesa más que cualquier feature bonita cuando algo te falla un viernes.

    ¿Lo usaría en un servicio nuevo, un side project o una API interna donde el peso y los cold starts importan? Sin dudarlo.

    Instálalo y móntate algo pequeño esta semana:

    npx @expressots/cli new my-app
    

    Levanta Studio, mira el mapa de arquitectura que genera del grafo DI y decide con tu propio código delante. Media hora te basta para saber si te encaja. La documentación oficial está sorprendentemente bien para un proyecto de este tamaño.

    Y si quieres ver cómo integro frameworks nuevos como este en un flujo de trabajo con agentes de IA —specs primero, implementación asistida, tests de verdad— eso es justo lo que describo en mi stack de IA agéntica y lo que practicamos dentro de Dominicode Labs.

    Preguntas frecuentes

    ¿ExpressoTS 4.0 sustituye a NestJS?

    No, y no lo pretende. NestJS tiene un ecosistema mucho más maduro en integraciones, documentación y comunidad. ExpressoTS ocupa el hueco entre Express pelado y NestJS: te da DI, ciclo de vida y estructura con menos boilerplate y una curva más corta. Son opciones distintas, no una sustitución.

    ¿Qué diferencia hay entre ExpressoTS y Express?

    Express es un router HTTP minimalista: no trae inyección de dependencias, ni estructura de proyecto, ni ciclo de vida de aplicación. ExpressoTS se construye sobre esa base y añade contenedor IoC, decoradores para controllers, hooks de ciclo de vida, guards, interceptors y utilidades de testing. Con Express decides tú toda la arquitectura; con ExpressoTS parte ya viene decidida.

    ¿Qué versión de Node necesito para ExpressoTS 4.0?

    Node.js 20.19.0 o superior, según las release notes oficiales de la v4.0.0 (17 de julio de 2026). Es un breaking change respecto a v3, así que verifica el runtime de tu entorno de despliegue antes de migrar.

    ¿ExpressoTS Studio envía mis datos a la nube?

    No. Studio es una plataforma de desarrollo local. El dashboard, el mapa de arquitectura, el request timeline con spans OpenTelemetry, los logs y la auditoría de seguridad funcionan sobre el tráfico de tu entorno de desarrollo, en tu máquina.

    ¿Puedo usar Zod para validar en ExpressoTS 4.0?

    Sí. La Smart Validation de v4 funciona con un registry de adapters pluggable que soporta class-validator, Zod y Yup. Puedes elegir la librería que ya uses en el resto del proyecto.

    ¿Cuánto tiempo tengo para migrar desde v3?

    El soporte de v3.x se ha extendido hasta diciembre de 2026. La v4.0.0 recibe 24 meses de bugfixes y parches de seguridad desde su publicación en julio de 2026. Tienes margen para planificar la migración sin prisas.

    ¿Qué se rompe al migrar de ExpressoTS v3 a v4?

    Tres cosas: los patrones de inyección de dependencias cambian y hay que revisarlos uno a uno, los lifecycle hooks de app.ts necesitan actualizarse, y el runtime debe subir a Node.js 20.19.0 o superior.

    ¿ExpressoTS 4.0 sirve para serverless?

    Es uno de los escenarios donde mejor encaja. El lazy-loading de módulos carga solo lo necesario en cada invocación, con preload hints (high, medium, low, never) para afinar qué se precarga, lo que ayuda con los cold starts. Súmale que el core es ligero comparado con alternativas más pesadas del ecosistema.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Spec-Driven Development (SDD): Evita el caos de la IA

    Spec-Driven Development (SDD): Evita el caos de la IA

    Hace unos meses un cliente me llamó desesperado. Habían decidido usar Cursor y Claude Code para acelerar el desarrollo de su nueva aplicación. El primer día escribieron 5.000 líneas de código y estaban maravillados con la velocidad.

    El segundo día, nada compilaba.

    El tercer día, la IA empezó a sobreescribir funciones previas, a alucinar APIs inexistentes y a meter bugs en bucle. Habían creado un monstruo de código spaghetti en tiempo récord.

    El problema no era la IA. El problema era que nadie le había dicho exactamente qué construir.

    Hoy te quiero explicar qué es Spec-Driven Development (SDD), la metodología de diseño que utilizo a diario para dar directrices claras a las IAs y evitar el caos en el código.


    El peligro de programar por "vibe coding"

    Cuando te sientas ante un editor como Cursor y le tiras prompts rápidos tipo "añade autenticación" o "agrega este formulario", estás haciendo vibe coding. La IA asume la arquitectura por su cuenta, inventa nombres de variables y adivina el modelo de datos.

    Esto funciona para landing pages sencillas, pero en proyectos reales produce tres efectos desastrosos:

    1. Código redundante: La IA vuelve a escribir funciones que ya existían porque no sabe dónde encontrarlas.
    2. APIs rotas: Se inventa endpoints que no coinciden con tu backend.
    3. Pérdida de control: El desarrollador deja de entender cómo funciona el sistema, convirtiéndose en un espectador pasivo.

    La solución no es dar mejores prompts conversacionales. La solución es dar especificaciones estructuradas.


    La regla de oro de SDD: Diseña antes de codificar

    La metodología Spec-Driven Development (SDD) establece que nunca debes dejar que un agente de IA escriba código hasta que haya aprobado un documento de diseño claro.

    Antes de tocar el teclado, debes estructurar tres archivos en la carpeta de especificaciones de tu proyecto:

    1. spec.md (La Especificación)

    Define la visión del producto, las reglas de negocio, los casos de uso y la arquitectura de datos. Responde al QUÉ se va a construir.

    2. plan.md (El Plan Técnico)

    Detalla la estrategia técnica paso a paso. Divide el desarrollo en fases incrementales y lógicas (por ejemplo, definir primero el esquema de base de datos antes de hacer la UI). Responde al CÓMO se va a construir.

    3. tasks.md (La Lista de Tareas)

    Una lista TODO detallada con tareas unitarias y autocontenidas. Cada tarea debe ser tan pequeña que la IA pueda completarla en una sola iteración y validarla con un test.


    El Flujo de Trabajo con tu Copiloto

    Una vez que tienes estos archivos, tu rol cambia de programador interactivo a director técnico:

    1. Le entregas el spec.md y el plan.md al agente de IA (ej: Claude Code).
    2. Le pides que lea las especificaciones y empiece a resolver la primera tarea del tasks.md.
    3. El agente implementa la tarea, corre los tests correspondientes y te avisa cuando está lista.
    4. Marcas la tarea como completada y pasas a la siguiente.

    Con este flujo, la IA no tiene que adivinar nada. Trabaja con un contrato de éxito claro y documentado.

    Este enfoque de ingeniería de software es el que trato en profundidad en mi libro de SDD: Spec-Driven Development, indispensable para cualquier desarrollador que quiera escalar sus desarrollos con IA en bucles agénticos u organizados. Además, es la metodología de base que aplicamos en todas las lecciones del curso de Construye con IA.


    Conclusión: La IA es el ejecutor, tú eres el arquitecto

    Delegar la escritura de código es seguro, pero delegar la arquitectura es un suicidio técnico. Al adoptar Spec-Driven Development, mantienes el control absoluto del diseño de tu software, reduces las alucinaciones de la IA a cero y multiplicas tu velocidad de desarrollo real.

    Si quieres aprender a estructurar tus specs y debatir sobre metodologías de ingeniería agéntica con otros desarrolladores senior, te espero en Dominicode Labs.


    Preguntas Frecuentes (FAQ)

    ¿Qué diferencia hay entre SDD y TDD?

    TDD (Test-Driven Development) se enfoca en escribir los tests unitarios antes que el código para guiar la implementación. SDD (Spec-Driven Development) va un paso más allá y exige redactar la especificación funcional y el plan técnico arquitectónico antes de escribir los tests o el código. Ambas metodologías se complementan perfectamente.

    ¿Por qué las especificaciones reducen las alucinaciones de la IA?

    Los LLMs tienden a alucinar cuando no tienen suficiente contexto o cuando las instrucciones son ambiguas. Un documento spec.md acota el espacio de decisiones que la IA debe tomar, forzándola a ceñirse a las reglas de negocio y arquitecturas declaradas en el archivo.

    ¿Cuánto tiempo toma escribir las especificaciones?

    Escribir un spec.md básico para una nueva feature suele tomar entre 15 y 30 minutos. Aunque parece un paso extra, te ahorra horas de depuración de código spaghetti mal estructurado por la IA en fases posteriores.

    ¿Se puede aplicar SDD a proyectos legacy o ya existentes?

    Sí. Al trabajar con código legacy, el primer paso es documentar el estado actual del componente afectado en un archivo de contexto (context.md) y redactar el spec.md detallando únicamente los cambios y adiciones a realizar, guiando a la IA sobre la base ya existente.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Vitest en Angular 22: por qué Karma ya no es el default

    Vitest en Angular 22: por qué Karma ya no es el default

    Son las 11 de la noche. Hay un commit pendiente de mergear y el pipeline de CI acaba de arrancar.

    Primero levanta el contenedor. Después Chrome headless. Karma detecta los specs, los compila y — casi dos minutos después de tu push — arranca la primera suite.

    Multiplica esos dos minutos por cada PR del día, por cada rebase, por ese "se me olvidó un punto y coma" que te obliga a repetir el ciclo entero.

    No es una exageración. Es el ritual diario de cualquier equipo Angular con Karma en producción. Por eso Vitest en Angular 22 dejó de ser una curiosidad de nicho: es ya el camino que recomienda el propio equipo de Angular.


    Por qué Karma se queda atrás

    Karma no es lento porque esté mal hecho. Es lento porque hace algo que en 2026 ya no tiene sentido: lanzar un navegador real — Chrome, o el que hayas configurado — para ejecutar cada suite de tests.

    Arrancar un navegador tiene un coste. Inicializar el motor de renderizado, cargar las extensiones de test, compilar el bundle con la configuración heredada de karma.conf.js… todo eso pasa antes de que se ejecute el primer expect().

    Y luego está la ejecución. Karma corre las suites de forma secuencial por defecto. Si tienes 40 archivos de spec, esperas a que terminen uno detrás de otro.

    Yo he trabajado en proyectos donde levantar el entorno de Karma tardaba varios minutos, antes de correr un solo test útil. Multiplica eso por cada push a un pipeline que corre veinte veces al día y tienes un cuello de botella silencioso que nadie cuestiona porque "siempre ha sido así".

    Vitest cambia la premisa completa. En lugar de un navegador real, corre en un proceso de Node.js y simula el DOM con una librería de emulación — arranca en milisegundos, no en segundos. Y ejecuta los archivos de test en paralelo por defecto, no de forma secuencial.

    No hace falta inventar un benchmark con un múltiplo llamativo para explicar esto. La diferencia cualitativa ya es suficiente: uno lanza un navegador, el otro no.


    Vitest nativo en Angular 22: lo que es default y lo que no

    Desde Angular 21, Vitest es el framework de testing por defecto para proyectos nuevos creados con ng new. Angular 22 mantiene ese default. Aquí hay que ser preciso, porque el estado real tiene matices que se pierden en los titulares.

    Si generas un proyecto hoy con el CLI — tal y como lo hacemos desde cero en el curso de Angular Moderno —, Vitest ya viene configurado. No instalas nada, no tocas angular.json.

    Karma, por otro lado, sigue soportado oficialmente. No ha sido eliminado ni deprecado. Sigue siendo una opción válida y documentada si tienes un proyecto existente y decides quedarte con él.

    Lo que sí está marcado como experimental es otra cosa distinta: migrar un proyecto existente de Karma a Vitest. La documentación oficial de Angular lo dice sin rodeos: "Migrating an existing project to Vitest is considered experimental".

    Esa distinción importa. Vitest de fábrica en un proyecto nuevo es el camino estándar y recomendado. El proceso de migración de un proyecto legacy con Karma es lo que todavía se etiqueta como experimental. No son lo mismo, y confundirlos te hace tomar decisiones equivocadas sobre cuándo migrar.

    El builder detrás de todo esto se llama @angular/build:unit-test, y se configura en el target test de tu angular.json:

    {
      "projects": {
        "mi-proyecto": {
          "architect": {
            "test": {
              "builder": "@angular/build:unit-test"
            }
          }
        }
      }
    }
    

    Requiere el sistema de compilación application, que ya es el default en cualquier proyecto nuevo. Sus valores por defecto son "tsConfig": "tsconfig.spec.json" y "buildTarget": "::development" — no necesitas escribirlos a mano salvo que quieras cambiarlos.

    ¿Y el DOM? Vitest corre tus tests en un entorno Node.js, no en un navegador. Para simular document, window y el resto de la API del navegador usa una librería de emulación. El Angular CLI detecta automáticamente happy-dom si lo tienes instalado; si no, cae a jsdom como fallback.


    Cómo migrar un proyecto existente

    Si tu proyecto ya existe y corre sobre Karma, migrar no es instantáneo, pero tampoco es una reescritura. Son cinco pasos:

    1. Instala las dependencias: npm install --save-dev vitest jsdom
    2. Cambia el builder del target test en angular.json a @angular/build:unit-test
    3. Revisa tu karma.conf.js en busca de configuraciones custom y trasládalas a un vitest.config.ts
    4. Elimina karma.conf.js y src/test.ts, y desinstala los paquetes de Karma (karma, karma-chrome-launcher, karma-coverage, karma-jasmine, etc.)
    5. Opcional: si necesitas correr tests en un navegador real (modo browser), instala @vitest/browser-playwright y añade "browsers": ["chromium"] en la configuración

    Ahora el gotcha que rompe configuraciones cuando nadie lo espera.

    Con el builder viejo de Karma, podías meter tus opciones de build — polyfills, assets, estilos — directamente dentro del target test. Era cómodo, y casi nadie se paraba a pensar si estaba bien hecho.

    El builder nuevo, @angular/build:unit-test, no soporta eso. Si las opciones de build que necesitas para tus tests son distintas de las de tu configuración normal de desarrollo, tienes que sacarlas de ahí y crear una configuración de build dedicada — normalmente un target development separado que el builder de test referencia.

    Si tu proyecto tenía cualquier personalización de polyfills o assets dentro del target test, este es exactamente el punto donde la migración "automática" deja de serlo.


    El schematic que automatiza parte del trabajo

    Angular no te deja solo con los cinco pasos manuales. Existe un schematic que hace la parte mecánica de convertir sintaxis Jasmine a Vitest:

    ng generate @schematics/angular:refactor-jasmine-vitest --project mi-proyecto --add-imports
    

    Convierte automáticamente patrones como fit/fdescribe a it.only/describe.only, spyOn a vi.spyOn, jasmine.any a expect.any, y otras conversiones de sintaxis equivalentes.

    Opciones útiles: --project <nombre> para apuntar a un proyecto específico del workspace, --include <path> para limitar el alcance, --add-imports para que añada los imports explícitos de Vitest que necesites, y --browser-mode si estás migrando hacia modo browser.

    Ahora la parte honesta, porque prometerte una migración 100% automática sería mentirte.

    El schematic no instala dependencias — eso lo haces tú a mano. No migra polyfills ni assets — ese es el gotcha del punto anterior, y sigue siendo tu responsabilidad. Y en escenarios de spies complejos — mocks anidados, spies sobre spies, configuraciones de retorno encadenadas — hace su mejor esfuerzo, pero necesitas revisar el resultado a mano.

    Trátalo como un primer pase que te ahorra la mayor parte del trabajo mecánico, no como un botón de "migrar y olvidar".

    Si además ya usas IA para generar o revisar tus tests — algo que cubrimos en testing en Angular con IA —, dale el resultado del schematic a tu agente y pídele que revise específicamente los spies antes de dar la migración por terminada.


    Mapa de equivalencias: de Jasmine/Jest a Vitest

    Necesidad Jasmine/Jest Vitest
    Función simulada jest.fn() / jasmine.createSpy vi.fn()
    Espiar método jest.spyOn() vi.spyOn()
    Mockear módulo jest.mock() vi.mock()
    Import real en mock parcial jest.requireActual() vi.importActual()
    Timers falsos jest.useFakeTimers() vi.useFakeTimers()
    Restaurar mocks jest.clearAllMocks() vi.clearAllMocks()
    Matcher jasmine.any jasmine.any(Type) expect.any(Type)

    Fíjate en el patrón: casi todo lo que cambia empieza con jest. o jasmine. y pasa a vi.. Es el mocking y el motor de ejecución lo que cambia, no la forma de pensar tus tests.

    Los matchers de aserciones — toBe, toEqual, toContain, toThrow, resolves, rejects — funcionan prácticamente igual en Vitest. Si ya sabes escribir un expect() en Jasmine o Jest, sabes escribir uno en Vitest. La curva de aprendizaje no está en las aserciones, está en el mocking.

    Esto es justo lo que no cambia con el motor: los patrones de Testing Library (render, screen, userEvent) y la filosofía de testing por comportamiento en lugar de por implementación.

    Eso es exactamente lo que cubrimos en el curso de Testing en Angular con Jest y Testing Library: sea cual sea el motor de tu proyecto — Jest hoy, Vitest mañana —, cómo piensas un test de comportamiento no cambia.


    Testing zoneless con Vitest

    Angular 22 empuja fuerte hacia zoneless. Y eso cambia también cómo escribes tus tests.

    Con Zone.js, después de simular una interacción — un click, un input — a veces tenías que llamar fixture.detectChanges() manualmente para forzar que Angular actualizara la vista antes de tu expect().

    En modo zoneless no hay Zone.js escuchando cada tarea async para disparar la detección de cambios. En su lugar, usas await fixture.whenStable() para esperar a que el ciclo de detección de cambios asíncrono termine:

    it('actualiza el contador al hacer click', async () => {
      const fixture = TestBed.createComponent(ContadorComponent);
      fixture.nativeElement.querySelector('button').click();
    
      await fixture.whenStable();
    
      expect(fixture.nativeElement.textContent).toContain('1');
    });
    

    Es un cambio pequeño en la sintaxis pero grande en la intención: pasas de forzar la detección de cambios a esperar a que el propio sistema te diga que está estable. Es la misma filosofía que estamos viendo en otras piezas de v22, como Signal Forms — otra API que va madurando y sobre la que conviene ser precisos respecto a qué está ya estable y qué sigue en evolución.


    Karma vs Vitest en Angular 22, cara a cara

    Karma Vitest
    Arranque de suite Lanza un navegador real (Chrome u otro) Corre en Node.js, simula el DOM con happy-dom o jsdom
    Ejecución Secuencial por defecto Paralela por defecto
    Configuración karma.conf.js, heredada de webpack vitest.config.ts, integrada con el builder de Angular
    Estado en Angular 22 Soportado oficialmente, sigue siendo válido Default para proyectos nuevos; migrar proyectos existentes es experimental

    La tesis

    Cambiar de Karma a Vitest no es "un test runner más rápido". Es Angular alineando su tooling de testing con el ecosistema Vite y ESM que ya domina el resto del frontend — y quitándose de encima una dependencia que llevaba años siendo el cuello de botella silencioso de cualquier pipeline: un navegador real corriendo en CI.

    Si estás empezando un proyecto hoy, no tienes nada que decidir — Vitest ya viene puesto. Si tienes un proyecto existente con Karma, tienes una decisión real que tomar, y ahora sabes exactamente qué parte de esa migración es estándar y cuál sigue siendo experimental.

    Repasamos el resto de las novedades de v22 — de las que Vitest es solo una pieza — en el post de novedades de Angular v22. Y si quieres ver cómo aplicamos estos patrones en proyectos reales de producción, en Dominicode Labs es donde compartimos ese trabajo con la comunidad.


    Preguntas frecuentes sobre Vitest en Angular 22

    ¿Vitest reemplaza completamente a Karma en Angular 22?

    Reemplaza a Karma como default para proyectos nuevos, pero no lo elimina. Karma sigue soportado oficialmente y sigue siendo una opción documentada y válida si tienes un proyecto existente que prefieres no migrar todavía.

    ¿Necesito instalar plugins de terceros como Analog para usar Vitest en Angular 22?

    No. El soporte de Vitest está integrado directamente en el Angular CLI a través del builder @angular/build:unit-test. No necesitas ningún plugin de terceros para el flujo estándar — solo instalar vitest y jsdom (o happy-dom) como dependencias de desarrollo.

    ¿Cómo migro mis tests de Jasmine a Vitest automáticamente?

    Con el schematic ng generate @schematics/angular:refactor-jasmine-vitest, que convierte automáticamente la sintaxis de spies, matchers y bloques fit/fdescribe. No es una migración 100% automática: no instala dependencias, no migra polyfills ni assets, y los spies complejos necesitan revisión manual.

    ¿Qué le pasa a mis configuraciones de build al migrar de Karma a Vitest?

    Si tu configuración de build para tests (polyfills, assets, estilos) era distinta de tu configuración normal de desarrollo, no puedes moverla dentro del target test como hacías con Karma. El nuevo builder no lo soporta — tienes que crear una configuración de build dedicada, por ejemplo un target development separado.

    ¿Vitest funciona con testing zoneless en Angular 22?

    Sí, y de hecho es donde más se nota el cambio de paradigma: en lugar de llamar fixture.detectChanges() manualmente tras una interacción, usas await fixture.whenStable() para esperar el ciclo de detección de cambios asíncrono.

    ¿Debería migrar mi proyecto existente a Vitest hoy mismo?

    Si tu suite de tests es grande y crítica para producción, pruébalo primero en una rama o en un proyecto secundario antes de tocar el repo principal — la documentación oficial etiqueta esta migración como experimental. Depende, en última instancia, de tu tolerancia al riesgo.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • Método HTTP QUERY: RFC 10008 explicado para developers

    Método HTTP QUERY: RFC 10008 explicado para developers

    Hace un par de años me tocó construir el buscador de un CRM interno. Filtros combinables por nombre, etiqueta, estado, rango de fechas, un par de campos personalizados que el cliente quería poder cruzar entre sí.

    Nada del otro mundo. O eso pensé.

    Me pasé una tarde entera peleando con un problema que HTTP, tal cual lo conocíamos hasta ahora, no resolvía bien. Spoiler: la solución llegó en 2026, se llama método HTTP QUERY, y llevaba más de veinte años de retraso.

    ¿Por qué una tarde entera por un simple buscador? Porque en cuanto diseñas el endpoint te topas con el mismo dilema de siempre.


    El dilema de siempre: GET o POST

    GET es la opción "correcta" semánticamente. No cambia nada en el servidor, se puede repetir sin miedo, y cualquier intermediario puede guardar la respuesta en caché.

    El problema es práctico: en cuanto combinas más de cuatro o cinco filtros —arrays, rangos, objetos anidados— serializarlos en un query string se vuelve una tortura. Y las URIs tienen límites de tamaño reales, que servidores, proxies y CDNs aplican sin pedirte permiso.

    Ahí es donde la mayoría termina en POST. Sin límite de tamaño relevante, acepta cualquier estructura en el body. Pero POST miente.

    Le dice a cualquier intermediario —proxy, CDN, gateway— "esto modifica el estado del servidor, no lo cachees". Aunque tu POST /contacts/search solo esté leyendo datos.

    El resultado: pierdes cacheo, pierdes la garantía de idempotencia que un retry automático podría necesitar, y terminas inventando convenciones como POST /contacts/_query para comunicar, solo con el nombre de la ruta y no con el protocolo, que en realidad es una lectura.

    Yo terminé documentando en el README: "este POST es en realidad una consulta de solo lectura". Un parche humano para un problema que el protocolo debería resolver solo.


    Cómo llegamos hasta aquí

    En corto: HTTP no tuvo, durante veinte años, un método pensado para consultas complejas que fuera a la vez seguro, idempotente y cacheable — y la industria lo parcheó de mil formas distintas hasta que el RFC 10008 lo resolvió en 2026.

    Este problema no es nuevo. Es viejo.

    Las URIs de GET siempre tuvieron un techo práctico. No existe un límite en el estándar HTTP, pero servidores, proxies y navegadores lo imponen igual —y con diez o quince filtros combinables, lo tocas rápido.

    La industria hizo lo que hace siempre ante un vacío del protocolo: usar POST para todo lo que GET no aguantaba. Búsquedas complejas, filtros anidados, exportaciones con parámetros, todo empaquetado en un body, aunque la operación fuera, en esencia, una lectura.

    El coste de ese abuso semántico es real. Rompe el cacheo, porque los intermediarios no cachean POST por defecto. Rompe la idempotencia garantizada, porque un cliente no puede asumir que reintentar un POST es seguro. Y confunde a cualquier proxy o CDN que tome decisiones basadas en el método HTTP.

    Hubo intentos de arreglarlo antes de 2026. WebDAV definió su propio método, SEARCH (RFC 5323), pensado para consultas complejas sobre colecciones de recursos. Nunca salió de su nicho.

    Mientras tanto, herramientas que viven de resolver búsquedas complejas todos los días —Elasticsearch es el ejemplo obvio— no adoptaron SEARCH. Optaron por su propia convención, POST /_search, aceptando el mismo trade-off semántico que cualquiera de nosotros.

    Veinte años de parches, cada uno resolviendo el síntoma, ninguno el problema de fondo: HTTP no tenía un método pensado para "quiero enviarte una consulta compleja, y quiero que sepas que es segura, idempotente y cacheable".

    En 2026 el IETF lo estandarizó. El RFC 10008 —de Julian Reschke, James M. Snell y Mike Bishop, publicado como Proposed Standard— define el método QUERY exactamente para esto.


    Qué es el método HTTP QUERY y cómo funciona

    El RFC lo resume mejor que yo (traducción propia del original en inglés):

    "El input de la operación query se pasa como contenido de la petición en vez de como parte de la URI de la petición. A diferencia de POST, sin embargo, el método es explícitamente seguro e idempotente."

    QUERY toma la ventaja práctica de POST —el body, sin límites de tamaño relevantes, con soporte para estructuras complejas— y le devuelve las tres garantías semánticas que POST no ofrece:

    • Seguro. No modifica el estado del recurso; un intermediario puede asumir que ejecutar la petición no tiene efectos secundarios.
    • Idempotente. Puedes reintentar la misma petición todas las veces que necesites sin miedo a duplicar nada.
    • Cacheable. La respuesta puede cachearse siguiendo las reglas estándar de HTTP caching, igual que un GET.

    No es un detalle cosmético. Es lo que le permite a un proxy o una CDN cachear agresivamente sin arriesgarse a servir datos corruptos, porque el propio protocolo garantiza que la operación es de solo lectura.

    Así se ve una petición QUERY, tal cual la define el RFC:

    QUERY /contacts HTTP/1.1
    Host: example.org
    Content-Type: application/x-www-form-urlencoded
    Accept: application/json
    
    select=surname,givenname&limit=10&match="email=*@example.*"
    

    Línea por línea

    • QUERY /contacts — el verbo nuevo, apuntando al recurso de colección, igual que harías con GET.
    • Content-Type — obligatorio. El servidor DEBE fallar la petición si el header falta o es inconsistente con el contenido real del body.
    • Accept — content negotiation estándar para la respuesta.
    • El body —select, limit, match— es donde vive la complejidad real de tu consulta, sin límites de URI ni arrays serializados en un query string.

    (El body de este ejemplo está simplificado por legibilidad — en una petición application/x-www-form-urlencoded real, esos valores llevarían percent-encoding.)

    El manejo de errores no deja ambigüedad. Si la petición no trae información suficiente sobre el media type, el servidor responde 400 Bad Request. Si el media type está identificado pero no es soportado, responde 415 Unsupported Media Type.

    QUERY también soporta los condicionales HTTP que ya conoces —If-Modified-Since, If-None-Match— para re-consultar de forma eficiente sin traer de vuelta una respuesta que no cambió.

    Si en tu backend ya validas y tipas los bodies de entrada, la lógica no cambia con QUERY: sigues necesitando un schema que valide select, limit y match antes de tocar tu capa de datos. QUERY no te libra de validar el input, solo te da un protocolo que comunica correctamente la intención. En el curso de Zod cubrimos justo este tipo de validación de bodies complejos con schemas tipados en TypeScript.


    Cacheo y content negotiation: la parte que cambia las reglas

    Con GET, la cache key es la URL. Punto.

    Con QUERY no puede serlo, porque la consulta vive en el body. El RFC lo resuelve así: la cache key DEBE incorporar el contenido de la petición y su metadata relacionada. Las caches, eso sí, pueden normalizar diferencias semánticamente insignificantes —encoding, formato JSON con espacios distintos— para no fragmentar el cacheo por diferencias triviales.

    Para que un recurso anuncie qué formatos de consulta soporta existe el header de respuesta Accept-Query, con sintaxis de Structured Fields. Es el equivalente a un Accept, pero para las capacidades de consulta del propio recurso.

    Hay un detalle elegante más. Una respuesta 2xx a una QUERY puede incluir los headers Location o Content-Location apuntando a una URI equivalente:

    Location: /contacts/stored-queries/42
    Content-Location: /contacts/stored-results/17
    

    Eso te permite guardar esa consulta —o su resultado— como un recurso direccionable por GET, sin reenviar el body completo cada vez que alguien quiera acceder al mismo resultado. El RFC es explícito en que esas URIs deberían elegirse de forma que no incluyan partes sensibles del contenido original de la petición.


    El detalle que se te va a escapar: CORS

    Esto es lo que casi nadie menciona cuando lee sobre QUERY por encima, y es justo lo que te va a morder si construyes APIs consumidas desde un frontend.

    GET, POST y HEAD están en la lista de métodos "CORS-safelisted": el navegador puede dispararlos cross-origin sin pedir permiso primero. QUERY no está en esa lista.

    Cualquier petición QUERY cross-origin dispara automáticamente un preflight: una petición OPTIONS previa donde el navegador le pregunta al servidor "¿me dejas hacer esto?" antes de ejecutar la petición real.

    No es un bug ni una limitación del RFC. Es una decisión de seguridad del propio modelo CORS. Si vas a exponer un endpoint QUERY consumido desde un dominio distinto al de tu API, necesitas tener el preflight resuelto en tu configuración de CORS, o vas a ver peticiones fallando sin entender por qué — el día que tu stack te deje disparar una petición QUERY real desde el navegador. Ese nivel de soporte todavía no lo puedo confirmar, como explico un poco más abajo.


    Qué significa esto en la práctica, hoy

    En corto: sí puedes diseñar tu API con la semántica de QUERY desde ya, aunque el transporte real siga siendo POST mientras el soporte nativo del ecosistema madura.

    Aquí toca ser honesto.

    El RFC 10008 se publicó en 2026. Es un Proposed Standard del IETF —el sello más alto para un método nuevo— pero eso no significa que el ecosistema ya lo soporte de forma nativa en todas partes.

    No tengo forma de confirmar, a la fecha de este post, qué nivel de soporte real tienen ya el fetch() de los navegadores o los frameworks de backend en Node —Express, NestJS, Hono— para este método. Es un estándar muy reciente y ese tipo de soporte cambia semana a semana. No me voy a inventar un dato que no puedo verificar.

    Lo que sí puedes hacer hoy, con certeza, es diseñar tus endpoints con el modelo semántico correcto. Aunque el transporte real siga siendo POST por compatibilidad, puedes:

    1. Documentar que tu endpoint de búsqueda es una operación segura e idempotente, aunque use el verbo POST.
    2. Construir la cache key de tu capa de caché —Redis, CDN, lo que uses— incorporando el body completo, el mismo principio que usa QUERY.
    3. Exponer resultados reutilizables vía una URI propia, tu propio Content-Location casero, para que un cliente pueda hacer GET después sin repetir la consulta.

    Ese diseño no caduca. El día que tu framework soporte QUERY de forma nativa, migrar es un cambio de un verbo, porque la arquitectura ya estaba pensada correctamente.

    Si tu backend está en NestJS, esta es exactamente el tipo de decisión de diseño de API que vale la pena resolver bien desde el controller. En el post sobre streaming con NestJS y el AI SDK de Vercel hablo de cómo estructurar endpoints que respetan la semántica HTTP correcta en vez de forzar todo por POST.

    Del lado del frontend, si consumes estos endpoints desde Angular, la resource API introducida en v22 encaja con este modelo: una consulta segura y cacheable es exactamente el tipo de dato que quieres modelar como un resource reactivo, no como un efecto secundario disparado a mano. Lo cubro en el post sobre la resource API en Angular 22, y trabajamos el consumo de APIs con el Angular moderno —signals, resource, control flow— en el curso de Angular Moderno.

    Hay un ángulo más que me parece el más interesante, y casi nadie lo está conectando todavía. Los agentes de IA que hacen tool-calling —vía MCP o cualquier otro protocolo— tienen el mismo problema que resolvimos hace veinte años con las APIs REST: un agente necesita saber, con certeza protocolar, si una tool que va a invocar es segura de reintentar o no.

    QUERY le da a ese tipo de arquitecturas una semántica formal para "esto es una consulta, puedes cachearla, puedes reintentarla sin miedo". Es exactamente el tipo de diseño de herramientas que trabajamos en el curso de Construye con IA: que cada tool que expones a un agente tenga una semántica clara sobre sus efectos.


    GET vs QUERY vs POST, en una tabla

    Aspecto GET QUERY POST
    Seguro Sí Sí Potencialmente no
    Idempotente Sí Sí Potencialmente no
    Query en la URI Sí Opcional* No
    Cacheable Sí Sí Sí (limitado)**

    * Que el protocolo lo permita no significa que sea buena práctica: si vuelves a meter toda la consulta en la URI, pierdes la ventaja que motivó usar QUERY en primer lugar.

    ** Solo con headers de cache explícitos configurados a mano — no por defecto, como sí ocurre con GET y QUERY.


    La tesis: esto no es una feature exótica

    Llevábamos más de veinte años sin una respuesta oficial en el protocolo HTTP a una pregunta simple: ¿cómo hago una consulta compleja de forma segura, cacheable e idempotente?

    No es que nadie lo necesitara. Es que cada quien lo parcheaba a su manera —convenciones de nombres, métodos no estándar, documentación humana explicando lo que el protocolo no podía comunicar solo.

    QUERY no es HTTP inventando una feature exótica. Es HTTP poniéndose al día con un patrón que la industria ya necesitaba y ya estaba resolviendo, mal, de mil formas distintas.

    Y esa es la parte que importa para tu trabajo diario: entender bien la semántica HTTP —qué es seguro, qué es idempotente, qué es cacheable— es una habilidad de arquitectura que trasciende cualquier framework. Angular, NestJS, Express, Hono van a cambiar. Los verbos y garantías de HTTP, no tanto.


    Preguntas frecuentes sobre el método HTTP QUERY

    ¿Qué es el método HTTP QUERY?

    Es un método HTTP nuevo, estandarizado en el RFC 10008 (IETF, Proposed Standard, 2026) por Julian Reschke, James M. Snell y Mike Bishop. Permite enviar el input de una consulta como contenido de la petición en vez de codificarlo en la URI, y a diferencia de POST, es explícitamente seguro, idempotente y cacheable.

    ¿QUERY reemplaza a POST para hacer búsquedas?

    Reemplaza el uso de POST para operaciones de lectura que necesitan un body complejo: búsquedas, filtros combinados, consultas estructuradas. POST sigue siendo correcto para operaciones que sí modifican estado. El problema que QUERY resuelve es el abuso semántico de usar POST para leer datos, no el uso legítimo de POST para escribir.

    ¿Cuál es la diferencia entre el método QUERY y POST en HTTP?

    La diferencia no es de capacidad —ambos aceptan un body con estructuras complejas— sino de las garantías que cada método comunica al resto de la infraestructura HTTP. POST no promete que la operación sea segura ni idempotente, así que ningún proxy o CDN puede asumirlo ni cachearla por defecto. QUERY sí lo garantiza explícitamente: es seguro, idempotente y cacheable, igual que GET, pero sin los límites de una URI.

    ¿Ya puedo usar el método QUERY en producción hoy?

    Con cautela. El RFC se publicó en 2026 y es muy reciente —no hay forma de confirmar en este momento qué nivel de soporte nativo tienen ya los navegadores (fetch()) o los frameworks de backend más usados en Node. Lo prudente es diseñar tus endpoints con la semántica correcta de QUERY aunque sigas transportándolos con POST mientras el soporte nativo del ecosistema madura.

    ¿Cómo se cachea una petición QUERY si la consulta no está en la URL?

    La cache key deja de basarse solo en la URL, como con GET, y debe incorporar el contenido completo de la petición y su metadata relacionada. Las caches pueden normalizar diferencias semánticamente insignificantes —como el encoding o el formato del JSON— para no fragmentar el cacheo innecesariamente.

    ¿Qué diferencia hay entre QUERY y el método SEARCH de WebDAV?

    SEARCH (RFC 5323) fue un intento anterior, específico de WebDAV, para consultas complejas sobre colecciones de recursos, y nunca tuvo adopción fuera de ese nicho. QUERY es un método de propósito general, estandarizado en el núcleo de HTTP —no atado a una extensión como WebDAV—, con reglas explícitas de content negotiation, cacheo y manejo de errores que SEARCH nunca definió con ese nivel de detalle.

    ¿Por qué una petición QUERY cross-origin necesita un preflight?

    Porque QUERY no está en la lista de métodos "CORS-safelisted", a diferencia de GET, POST y HEAD. Cualquier método fuera de esa lista obliga al navegador a enviar una petición OPTIONS previa —el preflight— para confirmar que el servidor permite esa petición antes de ejecutarla.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • De callbacks a Signals: la reactividad real del frontend

    De callbacks a Signals: la reactividad real del frontend

    Un excliente me escribió hace años, angustiado. Su carrito de compras mostraba 3 artículos en el header, pero el checkout decía que había 5. Los clientes se quejaban en soporte y algunos abandonaban la compra.

    Revisé el código. Puro estilo jQuery: DOM manipulado a mano, evento por evento. Un event listener actualizaba el contador del header. Otro, completamente separado, actualizaba el resumen del checkout. Nadie los había conectado entre sí — y ahí estaba el problema real: cero programación reactiva, cero garantía de que el estado y la interfaz dijeran la misma verdad.

    Cuando alguien hacía clic dos veces seguidas y rápido, un listener terminaba antes que el otro. El total quedaba repartido entre cuatro variables sueltas, cada una con su propia versión de la verdad. Pasé tres horas arreglando algo que debería haberme tomado diez minutos. No porque el bug fuera complejo — porque nada en el código garantizaba que la interfaz reflejara el estado real.

    Llevamos veinte años resolviendo ese mismo problema con herramientas distintas. Primero fueron callbacks manuales sobre el DOM. Luego llegó el Virtual DOM. Ahora, señales. Cada era resolvió lo que la anterior no pudo — y entender por qué importa más que memorizar sintaxis nueva cada dos años.


    Era 1: callbacks manuales y el DOM que se te olvida sincronizar

    En los tiempos de jQuery — y del DOM vanilla antes de eso — la única forma de reaccionar a un evento era escucharlo y mutar el DOM a mano. Tú decidías qué elemento tocar, cuándo y con qué valor.

    Toma el ejemplo clásico: un contador de carrito con tres elementos que dependen del mismo dato.

    let count = 0;
    
    const counterEl = document.querySelector('#counter');
    const totalEl = document.querySelector('#total');
    const shippingMsgEl = document.querySelector('#shipping-msg');
    
    document.querySelector('#add-btn').addEventListener('click', () => {
      count++;
      counterEl.textContent = count;
      totalEl.textContent = `$${(count * 19.99).toFixed(2)}`;
      shippingMsgEl.textContent = count >= 5
        ? '¡Envío gratis!'
        : `Añade ${5 - count} más para envío gratis`;
    });
    
    document.querySelector('#remove-btn').addEventListener('click', () => {
      count = Math.max(0, count - 1);
      counterEl.textContent = count;
      totalEl.textContent = `$${(count * 19.99).toFixed(2)}`;
      // shippingMsgEl no se actualiza aquí. Nadie lo notó en code review.
    });
    

    Mira el comentario en la última línea. Ese es, casi literal, el bug que revisé en el carrito de mi excliente.

    No es un error de sintaxis — el código compila, pasa QA si nadie prueba el camino de "quitar un producto cuando ya tenías envío gratis". El bug vive en la cabeza del developer: hay que acordarse de tocar los tres elementos en cada handler que mueva ese estado.

    La ventaja de este modelo es real: control total, cero abstracciones, cero curva de aprendizaje. Para un widget aislado — un acordeón, un modal, un tooltip — sigue siendo la opción correcta hoy mismo.

    El problema aparece en cuanto el estado deja de ser trivial:

    • Cada elemento dependiente necesita su propia línea de sincronización, repetida en cada handler que toque ese estado.
    • El estado vive disperso: a veces en el DOM (el.textContent), a veces en variables sueltas, a veces en atributos data-*.
    • Los listeners no se limpian solos. En una SPA que monta y desmonta vistas, cada addEventListener sin su removeEventListener es un memory leak esperando a pasar factura.

    Esto nunca fue un problema de jQuery. Fue un problema de arquitectura: nada en el modelo te obligaba a centralizar el estado ni a declarar sus dependencias. Cada developer inventaba su propia disciplina — y la disciplina, a escala de equipo, no escala.

    Era 2: Virtual DOM y el modelo declarativo

    React cambió la pregunta. En lugar de "¿qué elemento del DOM tengo que tocar?", pasó a ser "¿cómo se ve la UI dado este estado?". Tú describes el resultado final; el framework decide cómo llegar ahí.

    function Counter() {
      const [count, setCount] = useState(0);
      const total = (count * 19.99).toFixed(2);
      const shippingMsg = count >= 5
        ? '¡Envío gratis!'
        : `Añade ${5 - count} más para envío gratis`;
    
      return (
        <div>
          <p>{count}</p>
          <p>${total}</p>
          <p>{shippingMsg}</p>
          <button onClick={() => setCount(c => c + 1)}>Añadir</button>
          <button onClick={() => setCount(c => Math.max(0, c - 1))}>Quitar</button>
        </div>
      );
    }
    

    El bug del carrito es estructuralmente imposible aquí. total y shippingMsg se calculan en la misma función, a partir del mismo count, cada vez que el componente se ejecuta. No hay "actualizar" — hay "recalcular todo desde cero", así que no hay forma de que uno se sincronice y el otro se olvide.

    Ahí está la clave del Virtual DOM. React no toca el DOM real en cada cambio. Construye un árbol en memoria — objetos JavaScript planos que describen cómo debería verse la UI — y lo compara contra el árbol anterior. Ese proceso se llama reconciliation, y el algoritmo de comparación es el diffing: detecta qué nodos cambiaron, cuáles se reutilizan, y calcula el mínimo de operaciones para que el DOM real refleje el nuevo árbol. Solo entonces toca el DOM — y solo donde hace falta.

    Es un modelo declarativo y predecible. Pero el coste real no es gratis, y es lo que casi nadie menciona en los tutoriales de introducción: cada cambio de estado re-ejecuta la función completa del componente y, por defecto, la de sus hijos.

    En un árbol de cuarenta componentes anidados, un solo tecleo puede disparar cuarenta re-renders y cuarenta diffs — la mayoría comparando nodos que ni siquiera cambiaron.

    La respuesta del ecosistema fue la memoization: memo(), useMemo(), useCallback(). Son parches necesarios para un problema que el propio modelo introduce: no sabes qué cambió hasta que recalculas y comparas. Memoizar es responsabilidad manual otra vez — la misma que el Virtual DOM prometía eliminar, solo que movida un nivel más arriba en el árbol.

    Era 3: reactividad fina — el grafo en vez del árbol

    Los signals no comparan nada. No hay árbol virtual, no hay diffing, no hay re-render de una función completa. Un signal es una caja que guarda un valor y sabe, con precisión, quién depende de él.

    import { Component, signal, computed, effect } from '@angular/core';
    
    @Component({
      selector: 'app-cart-counter',
      template: `
        <p>{{ count() }}</p>
        <p>${{ total() }}</p>
        <p>{{ shippingMsg() }}</p>
        <button (click)="count.set(count() + 1)">Añadir</button>
        <button (click)="count.set(count() - 1)">Quitar</button>
      `,
    })
    export class CartCounterComponent {
      count = signal(0);
    
      total = computed(() => (this.count() * 19.99).toFixed(2));
    
      shippingMsg = computed(() =>
        this.count() >= 5
          ? '¡Envío gratis!'
          : `Añade ${5 - this.count()} más para envío gratis`
      );
    
      constructor() {
        effect(() => {
          console.log(`Carrito: ${this.count()} items — $${this.total()}`);
        });
      }
    }
    

    Cuando count cambia, Angular no re-ejecuta el componente entero ni reconstruye ningún árbol para comparar. total y shippingMsg ya saben que dependen de count — lo registraron la primera vez que se ejecutaron, al construirse el grafo reactivo. Angular actualiza exactamente el nodo del DOM ligado a cada binding. Nada más se mueve.

    Esto es reactividad fina (fine-grained reactivity): la granularidad de la actualización no es el componente, ni el subárbol — es el binding individual. Angular v22 lleva esto hasta el final siendo zoneless por defecto: ya no depende de Zone.js interceptando cada setTimeout o evento del navegador para saber cuándo revisar cambios. El grafo de signals es la única fuente de verdad sobre qué actualizar y cuándo.

    Angular no inventó este modelo — lo adoptó y lo llevó a producción a escala. Solid.js lo demostró primero, sin Virtual DOM desde el diseño inicial. Svelte llega a un resultado parecido compilando la reactividad en tiempo de build. Los tres coinciden en el mismo diagnóstico: comparar árboles es trabajo evitable si sabes de antemano quién depende de quién.

    Si quieres ver cada primitiva documentada en detalle, la guía oficial de Angular Signals cubre signal(), computed() y effect() con más profundidad de la que cabe en un post.

    Si quieres ver cómo se construye ese grafo de dependencias paso a paso — incluyendo los casos raros donde un effect() se dispara más veces de las que esperas — lo cubrí a fondo en el post sobre el grafo reactivo de Angular Signals.

    En el curso de Angular Moderno construimos este modelo mental desde cero, con proyectos reales donde pasar de Zone.js a zoneless cambia decisiones de arquitectura, no solo de sintaxis.

    Los tres paradigmas, uno al lado del otro

    Modelo mental Cómo detecta cambios Granularidad de la actualización Coste computacional Dónde brilla
    Callbacks (jQuery / DOM imperativo) Tú mutas el DOM a mano, evento por evento No detecta nada — el developer decide cuándo actualizar La que tú programes, elemento por elemento Bajo por operación, alto en mantenimiento y bugs de sincronización Widgets aislados, prototipos, páginas sin estado compartido
    Virtual DOM (React) La UI es una función pura del estado Diffing — compara árbol virtual anterior vs. nuevo Por componente/subárbol, tras re-ejecutar y comparar Re-ejecuta la función de render completa y diffea en cada cambio Apps con estado complejo, equipos grandes, ecosistema maduro
    Signals (Angular, Solid, Svelte) Grafo de dependencias reactivas Suscripción directa — el signal sabe quién lo consume El binding o nodo exacto del DOM que depende del valor Solo se ejecuta lo que realmente cambió UI de alta frecuencia de actualización, listas grandes, apps sensibles a rendimiento

    Por qué la reactividad fina no es una moda

    Cada era resolvió el cuello de botella real de la anterior — no la anterior en abstracto, la anterior en producción.

    Los callbacks resolvieron "cómo reacciono a un evento del usuario". Fue suficiente mientras la UI tenía poco estado compartido. Dejó de serlo en cuanto una sola acción tenía que actualizar cinco sitios distintos de la pantalla.

    El Virtual DOM resolvió "cómo mantengo la UI declarativa sin perder la cordura sincronizando elementos a mano". A cambio, aceptó un coste: recalcular y comparar árboles que, la mayoría de las veces, apenas habían cambiado.

    Signals resuelve el cuello de botella que el Virtual DOM introdujo: cómo evitar recalcular y comparar lo que ya sabías que no había cambiado. No es una versión "más rápida" de React. Es una respuesta distinta a la misma pregunta de fondo: ¿qué es lo mínimo que tengo que actualizar para que la UI diga la verdad?

    Esto no significa que el Virtual DOM esté acabado, ni que debas reescribir tu app de React mañana.

    Significa que si estás arrancando un proyecto hoy, entender este modelo ya no es opcional — es la diferencia entre construir sobre un patrón que resuelve el problema en su raíz o sobre uno que lo parchea con memoization.

    Esta decisión de arquitectura — dónde vive el estado, cómo fluye, qué parte del sistema es responsable de mantenerlo sincronizado con la UI — es exactamente el tipo de decisión que trato en el post sobre Clean Architecture para frontend con IA: la reactividad que elijas no es un detalle de implementación, es una decisión que carga con consecuencias durante años.

    Si vas a construir con signals en producción, en algún momento necesitarás verificar que esos computed() y effect() se comportan como esperas bajo distintos escenarios — eso es justo lo que trabajamos con casos reales en el curso de Testing en Angular con Jest y Testing Library.

    Y si quieres discutir esto con otros developers que están tomando las mismas decisiones ahora mismo, en Dominicode Labs es donde pasa esa conversación cada semana.

    Preguntas frecuentes sobre programación reactiva en el frontend

    ¿Qué es la programación reactiva?

    Es el paradigma en el que la interfaz se actualiza automáticamente cuando cambia el estado del que depende, sin que el desarrollador tenga que sincronizarla a mano evento por evento. Los tres modelos de este post — callbacks, Virtual DOM y signals — son formas distintas de resolver ese mismo problema, con más o menos reactividad real incorporada al framework.

    ¿El Virtual DOM está muerto?

    No. Sigue siendo el modelo dominante en producción — React tiene el ecosistema, el talento disponible y millones de líneas de código funcionando con él hoy. Lo que cambió es que ya no es la única opción seria para UI compleja: Signals, Solid.js y Svelte demuestran que el diffing es una solución al problema, no la única posible.

    ¿Los Signals reemplazan a React?

    No en el sentido de que React vaya a desaparecer. Angular con Signals, Solid.js y Svelte son alternativas con un modelo distinto, no reemplazos del ecosistema React. Sí es cierto que la presión competitiva ya empujó a React hacia herramientas como React Compiler, que intenta automatizar la memoization que antes hacías a mano.

    ¿Qué es la reactividad fina (fine-grained reactivity)?

    Es un modelo donde cada pieza de estado (signal) mantiene una lista explícita de quién depende de ella — otros signals derivados (computed) o efectos secundarios (effect). Cuando el valor cambia, solo se re-ejecuta lo que está suscrito a ese valor específico, sin comparar árboles ni recalcular lo que no depende de ese dato.

    ¿Angular usa Virtual DOM?

    No, y nunca lo usó. Angular usaba Zone.js y un mecanismo de change detection basado en recorrer el árbol de componentes buscando cambios. Con Signals y el modo zoneless, por defecto desde Angular v22, Angular elimina también ese recorrido: el grafo de signals le dice exactamente qué actualizar, sin Zone.js y sin diffing.

    ¿Debo migrar mi app de React a Signals?

    No si tu app funciona bien y el equipo domina React. La reactividad fina brilla en escenarios concretos: dashboards con actualizaciones muy frecuentes, listas grandes, apps donde el rendimiento de render es un cuello de botella medido, no sospechado. Si estás empezando un proyecto nuevo, sí vale la pena evaluar Angular v22 con Signals como opción seria.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

  • SDD 2026: por qué el spec define tu ventaja competitiva

    SDD 2026: por qué el spec define tu ventaja competitiva

    Un cliente me mandó su proyecto hace tres semanas. Llevaba dos meses usando Claude Code todos los días. El repositorio tenía 340 archivos. Tenía features. Tenía tests. El código compilaba.

    Y no tenía ni idea de qué hacía el sistema.

    Me preguntó: “¿Por qué cada vez que añado algo nuevo, rompo tres cosas que ya funcionaban?” La respuesta era visible desde el primer git log: llevaba dos meses pidiéndole a la IA que generara código sin decirle nunca qué estaba construyendo realmente. Cada prompt era una instrucción táctica. Nunca había una visión. Nunca un mapa.

    Eso es Spec-Driven Development (SDD) al revés. Y en 2026, con agentes que pueden escribir mil líneas en minutos, la diferencia entre los dos modos es la diferencia entre un producto y un desastre con tests.


    La IA no necesita que seas más rápido. Necesita que seas más claro.

    La narrativa que se vende sobre el desarrollo con IA es esta: “ahora puedes construir el doble de rápido”. Es verdad. El problema es que construir el doble de rápido sin dirección no te lleva antes a destino — te lleva el doble de lejos en la dirección equivocada.

    Los agentes de IA son ejecutores extraordinariamente potentes con cero criterio arquitectónico propio. Claude Code, GitHub Copilot, Cursor, cualquiera — siguen instrucciones. Si las instrucciones son vagas, el output es coherente localmente e incoherente globalmente. Cada archivo tiene sentido en sí mismo. El sistema entero no tiene sentido como conjunto.

    El spec no es documentación. No es burocracia. Es la única forma de darle a un agente de IA el contexto suficiente para que sus decisiones locales sean coherentes con la visión global.

    Sin spec, el agente está adivinando constantemente. Y adivina bien, frase a frase. Pero adivinar bien frase a frase no produce un párrafo con sentido — produce contenido que parece correcto y no lleva a ningún lado.


    Qué es SDD y por qué no es lo que crees

    Spec-Driven Development no es escribir documentación antes de programar. Eso es lo que la mayoría imagina y por lo que lo descartan: “ya tengo suficiente trabajo sin añadir Word docs al proceso”.

    SDD es una metodología de tres artefactos que define qué construyes, cómo lo construyes y en qué orden lo construyes — antes de que un solo agente escriba una sola línea de código.

    Los tres artefactos son:

    spec.md — el qué. La especificación estructurada del sistema. Tiene seis secciones fijas: Visión, Usuarios, Funcionalidades, Flujos, Arquitectura, NFRs. En total, tres o cuatro páginas que responden a la pregunta que ningún agente puede responder por ti: qué problema resuelves exactamente, para quién, y qué significa “hecho” en este proyecto.

    plan.md — el cómo. El plan técnico por fases. No divide el trabajo en tareas sueltas — divide el trabajo en capas que tienen sentido en secuencia. Primero el dominio, después la infraestructura, después la UI. No al revés. El plan.md es el documento que evita que empieces por la pantalla de login cuando el sistema de autenticación aún no existe.

    tasks.md — el orden. La lista de tareas ordenada para TDD. Cada tarea define qué test escribes primero y qué código lo hace pasar. El tasks.md convierte el plan en commits atómicos verificables. Cuando un agente ejecuta una tarea del tasks.md, el resultado es predecible: un test verde y un incremento de funcionalidad real.

    Estos tres documentos no tardan tres días en escribirse. Con el skill /dominicode-sdd-spec-creator en Claude Code (disponible para miembros de Dominicode Labs), la estructura completa se genera en minutos a partir de una descripción del proyecto. Lo que tarda tiempo es pensar — y ese tiempo es exactamente el que te ahorra deuda técnica después.


    Antes vs después: el mismo proyecto, dos formas de empezar

    Hace unos meses construí un sistema de gestión de contenido para automatizar la publicación en múltiples canales. El proyecto tenía integraciones con tres APIs externas, lógica de colas, transformaciones de formato y un dashboard de seguimiento.

    Sin SDD (como lo hubiera hecho en 2022): Habría abierto el editor, creado una carpeta src/, y empezado por la parte que más me apetecía — probablemente el dashboard. A las dos semanas tendría un dashboard bonito conectado a datos hardcodeados, una integración con una API que funcionaba en happy path, y ninguna certeza de cómo conectar las piezas. Cada decisión técnica habría sido local, sin visión del sistema completo.

    Con SDD: Antes de escribir código, escribí el spec.md. La sección de Flujos me forzó a pensar en qué pasa cuando una API falla en mitad de una publicación — algo que no habría considerado hasta toparme con el bug en producción. La sección de NFRs me hizo definir qué latencia máxima era aceptable para el sistema de colas. La sección de Arquitectura me hizo elegir entre evento-driven y polling antes de escribir nada — no a mitad del proyecto cuando cambiar de dirección cuesta semanas.

    El spec.md tardó dos horas. El plan.md, una hora más. El tasks.md, otra hora.

    Cuatro horas de especificación que eliminaron tres semanas de refactoring posterior.

    Cuando empecé a usar Claude Code en el proyecto, el agente tenía el spec.md en el contexto. Cada decisión técnica que tomaba era coherente con la arquitectura definida. No porque el LLM sea mágicamente más inteligente con un documento — sino porque el documento le daba información que de otra forma no tenía.


    El spec como brújula del agente

    Este es el cambio de mentalidad que más cuesta hacer: el spec no es para ti. Es para el agente.

    Cuando llevas quince años programando, tu cabeza tiene el contexto del proyecto. Sabes por qué elegiste ese patrón. Sabes qué módulo toca qué. Sabes los trade-offs que hiciste en la semana dos. Ese contexto vive en tu cabeza y lo das por supuesto.

    El agente no tiene nada de eso. Sin contexto explícito, cada sesión empieza desde cero. Cada prompt es una petición descontextualizada si no le das el marco. Sin spec, el agente responde a lo que le preguntas — no a lo que necesitas construir.

    Con el spec.md en contexto, el agente puede hacer preguntas que de otra forma no haría: “esta funcionalidad que me pides entra en conflicto con el flujo de usuario número tres que está en el spec — ¿quieres cambiar el flujo o ajustar la funcionalidad?”. Esa pregunta vale más que mil líneas de código generado sin contexto.

    Esta es exactamente la lógica detrás del libro Spec-Driven Development — no es un manual de documentación, es una metodología diseñada para que el agente tenga suficiente contexto para tomar decisiones correctas sin que tú estés micromanageando cada prompt.


    Por qué el spec te protege del vibe coding

    El vibe coding no es programar con IA. Es programar con IA sin criterio. Hay developers que publican proyectos enteros generados en un fin de semana. Impresionante en superficie. Inutilizable en producción.

    El problema del vibe coding no es la velocidad — es la ausencia de coherencia acumulada. Cada prompt genera código coherente con el prompt anterior, pero nadie garantiza que el sistema resultante sea coherente con la intención original. A las cuatro horas de vibe coding, el proyecto tiene forma de algo pero no tiene diseño. Tiene features pero no tiene arquitectura.

    Lo que se acumula en silencio no es código malo — es deuda técnica agéntica. El tipo de deuda que no se ve en los tests porque los tests también los generó el agente sin un contrato claro de qué probar. El tipo de deuda que explota cuando intentas añadir la feature número veinte sobre una base que asumió implícitamente cosas que nunca se definieron.

    Para entender por qué la arquitectura de tus agentes necesita un spec detrás, te recomiendo el post sobre agentic harness: por qué la spec y la arquitectura no bastan.

    SDD es el antídoto no porque ralentice el desarrollo. Lo acelera — pero acelera el desarrollo en la dirección correcta. La spec es el contrato que el agente respeta en cada iteración. El plan es la secuencia que evita que construyas la décima planta antes de los cimientos. El tasks.md son los commits que puedes revisar, aprobar y revertir si algo no cuadra.

    Con SDD, el vibe coding se convierte en agile coding con contexto — velocidad de agente, criterio de arquitecto.


    Cómo empezar con SDD en Claude Code hoy

    Si tienes Claude Code y quieres aplicar SDD en tu próximo proyecto, el proceso es directo:

    1. Describe tu proyecto en lenguaje natural — qué construyes, para quién, qué problema resuelve.
    2. Ejecuta el skill /dominicode-sdd-creator — genera spec.md, plan.md y tasks.md en pocos minutos (disponible en Dominicode Labs).
    3. Revisa el spec antes de tocar código — es el momento de pensar, no después.
    4. Añade el spec.md al contexto de Claude Code con @spec.md al inicio de cada sesión de desarrollo — la documentación oficial de Claude Code explica cómo gestionar el contexto entre sesiones.
    5. Trabaja el tasks.md en secuencia — un task, un test, un commit.

    El skill no reemplaza tu pensamiento. Te obliga a pensar antes de que sea costoso cambiar de dirección.

    El post sobre SDD Creator, la herramienta CLI muestra exactamente cómo se genera la estructura automáticamente.

    Si quieres ver cómo se aplica esto en un proyecto real de principio a fin — desde la spec inicial hasta el deploy — es exactamente lo que trabajamos en el curso Construye con IA: no tutoriales sueltos de herramientas, sino el proceso completo de construir un producto con IA de forma que funcione en producción.


    El spec como ventaja competitiva real

    Hay algo que nadie dice sobre SDD en 2026 y que merece decirse.

    En un mundo donde cualquier developer puede generar código a gran velocidad con IA, la diferencia competitiva no está en quién genera más rápido. Está en quién sabe exactamente qué construir y por qué.

    El spec es donde vive esa ventaja. No en el prompt. No en la elección del modelo. En la claridad con la que defines el problema antes de que empiece la ejecución.

    Los developers que entienden esto ya no compiten con los que “usan IA para programar más rápido”. Son una categoría diferente: developers que combinan criterio técnico con capacidad de ejecución agéntica. El spec es la expresión concreta de ese criterio.

    Dentro de doce meses, los equipos que hayan integrado SDD en su workflow tendrán bases de código mantenibles, documentación generada como efecto colateral del proceso, y la capacidad de incorporar nuevos agentes o nuevos developers sin que el proyecto colapse. Los que sigan con vibe coding habrán reescrito el proyecto tres veces.


    FAQ

    ¿SDD no es simplemente documentación con otro nombre?

    No. La documentación describe lo que existe. El spec define lo que va a existir — antes de que exista. La diferencia no es semántica: la documentación se escribe después y siempre está desactualizada. El spec se escribe antes y guía la implementación. Si el spec y el código divergen durante el desarrollo, es señal de que hay una decisión técnica que tomar conscientemente — no de que el documento esté equivocado.

    ¿Cuánto tiempo tarda escribir el spec de un proyecto real?

    Depende del proyecto. Para un MVP de funcionalidad acotada, entre dos y cuatro horas. Para un sistema con múltiples integraciones y flujos complejos, un día. El punto de referencia útil: si el spec tarda más de un día en escribirse, es señal de que el proyecto no está suficientemente definido para empezar a construirlo — y ese es el momento exacto en que el spec te está salvando, no ralentizando.

    ¿Se puede aplicar SDD a proyectos que ya existen?

    Sí, pero el proceso es diferente. En proyectos existentes, el spec se usa para nuevas features o para refactorizaciones significativas. El ejercicio de escribir el spec de un módulo existente es también un audit implícito: si no puedes escribir el spec del módulo, es porque el módulo no tiene diseño coherente. El spec revela la deuda técnica que el código oculta.

    ¿SDD funciona con cualquier agente de IA o solo con Claude Code?

    La metodología es agnóstica al agente. Spec.md, plan.md y tasks.md son documentos markdown que cualquier LLM puede usar como contexto. El skill /dominicode-sdd-spec-creator está diseñado para Claude Code y disponible en Dominicode Labs, pero los artefactos que genera son compatibles con cualquier entorno. Lo importante no es la herramienta — es el hábito de definir antes de ejecutar.

    ¿Qué pasa cuando el spec cambia durante el desarrollo? ¿No es todo ese trabajo en vano?

    El spec cambia. Siempre cambia. Y eso es una funcionalidad, no un fallo. Cuando el spec cambia, tienes un documento que actualizar — y esa actualización fuerza una decisión consciente sobre el impacto del cambio en la arquitectura, los flujos y las tareas pendientes. Sin spec, el cambio ocurre de forma invisible: alguien pide algo diferente, el agente lo implementa, y nadie sabe qué asunciones antiguas quedan rotas. Con spec, el cambio es visible y gestionable.

    ¿Es SDD compatible con metodologías ágiles?

    Completamente. SDD no impone un ciclo de desarrollo — impone un hábito de especificación antes de ejecución. Dentro de un sprint de dos semanas, el spec de las features del sprint se escribe al inicio. El plan.md define el orden de implementación dentro del sprint. El tasks.md genera los tickets concretos. SDD convierte el backlog en artefactos ejecutables para agentes, no en listas de deseos sin criterio técnico.


    Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.