Tag: TypeScript

  • Context Drift: por qué tu agente se vuelve tonto en la iteración 15

    Context Drift: por qué tu agente se vuelve tonto en la iteración 15

    El agente empieza como un ingeniero senior.

    Analiza el problema con precisión quirúrgica. Propone una arquitectura impecable. Modifica los primeros módulos con código limpio y bien tipado.

    Pero llega la iteración 14.

    De repente olvida la regla que le pusiste en el primer mensaje. Inventa funciones auxiliares que ya existen. Borra código que él mismo escribió hace cinco minutos. Y para resolver un fallo de compilación, entra en pánico y sugiere reescribir la mitad del proyecto.

    No se ha vuelto tonto de golpe. Está sufriendo Context Drift: el mismo modelo, con las mismas instrucciones, deja de prestarles atención porque el historial las ha sepultado bajo miles de tokens de basura.

    Y aquí va lo importante: esto no se arregla con un modelo mejor ni con una ventana más grande. Se arregla en el bucle.


    Por qué el fallo aparece en la iteración 15 y no en la 3

    Que un modelo reparte su atención de forma desigual —mucha al principio del prompt, mucha al final, poca en el medio— ya lo conoces. Y si no, lo tienes explicado con detalle en Context Engineering: cómo estructurar la memoria de tus agentes de IA. Este post no va de eso.

    Va de lo que pasa cuando ese efecto se combina con un bucle que se ejecuta solo.

    En una conversación normal tú controlas lo que entra. En un agentic loop no: cada iteración inyecta la salida de una herramienta sin que nadie la lea. Y esas salidas son enormes. Un npm test, un git diff, un grep sobre un monorepo o un schema de base de datos rondan fácil los 4.000 tokens cada uno.

    Supón un agente con 2.000 tokens de system prompt y 3.000 de especificación. Eso es lo que de verdad tiene que respetar: 5.000 tokens fijos de instrucciones.

    Iteración Salidas de herramientas acumuladas Total en ventana Peso de tus instrucciones
    3 12.000 tokens 17.000 29 %
    8 32.000 tokens 37.000 14 %
    15 60.000 tokens 65.000 8 %

    Tus reglas no han desaparecido: siguen ahí, íntegras, en el token 1. Lo que ha cambiado es que ahora compiten contra sesenta mil tokens de ruido reciente que el modelo también considera relevante.

    [System prompt + spec: 5.000 tokens] ──► Alta atención
    
    [Log 1: salida de grep]        ──┐
    [Log 2: test runner completo]    ├── Zona de dilución
    [Log 3: schema SQL entero]      ──┘
    
    [Instrucción final] ──────────────────► Alta atención
    

    El 92 % de lo que el modelo está leyendo en la iteración 15 es material que ya no sirve para nada. Ahí es donde empieza a alucinar.


    Las 3 técnicas para eliminar el Context Drift

    1. Poda de salidas de herramientas

    Nunca devuelvas al contexto la salida completa de un comando.

    Si un test runner ejecutó 120 tests y falló uno, el agente necesita saber tres cosas:

    • El estado general (FAILED).
    • El nombre del test que falló.
    • Las 10 líneas relevantes del stack trace.

    Los otros 3.000 tokens de tests en verde son ruido puro: encarecen la factura y compiten por la atención del modelo. Poda en el tool handler, antes de que ese texto llegue al historial. No le pidas al modelo que lo ignore, porque no puede.

    Si quieres ver cuánto te está costando esto de verdad, en medir el consumo de tokens de un agente desgloso en qué se te van realmente.

    2. El patrón scratchpad: memoria en disco, no en el chat

    El peor sitio para guardar el estado de un proyecto es el historial del chat. Es volátil, crece sin control y no puedes consultarlo sin arrastrarlo entero.

    El patrón robusto es obligar al agente a mantener un archivo en disco —scratch/state.md— como única fuente de la verdad. En cada paso clave lo actualiza:

    • Tareas completadas.
    • Tareas pendientes.
    • Decisiones técnicas tomadas y descartadas.

    Cuando el contexto conversacional se ensucia, tiras la conversación entera y arrancas otra pidiéndole que lea ese archivo. El agente recupera todo su foco por unos cientos de tokens en vez de sesenta mil.

    Esta es la versión mínima y sin dependencias del asunto. Si necesitas memoria entre sesiones, perfiles de usuario o recuperación semántica, el terreno está mapeado en implementación de memoria en agentes de IA. Pero empieza por el archivo de texto: resuelve más de lo que parece.

    3. Compactación rodante del historial

    En lugar de acumular treinta mensajes, sustituye el tramo intermedio por un resumen sintético y conserva intactos el system prompt y los últimos turnos.

    Suena trivial, y tiene dos trampas que revientan la petición en producción: cortar un mensaje de resultado de herramienta separándolo de la llamada que lo produjo, y romper la alternancia de roles que exigen algunas APIs.

    export type Role = "system" | "user" | "assistant" | "tool";
    
    export interface AgentMessage {
      role: Role;
      content: string;
    }
    
    /**
     * Sustituye el tramo intermedio del historial por un resumen.
     * Preserva el system prompt y los ultimos `keepLastTurns` mensajes.
     */
    export function compactContext(
      messages: AgentMessage[],
      keepLastTurns = 6,
    ): AgentMessage[] {
      if (keepLastTurns < 1) {
        throw new RangeError("keepLastTurns debe ser >= 1");
      }
    
      const hasSystem = messages[0]?.role === "system";
      const head = hasSystem ? messages.slice(0, 1) : [];
      const body = hasSystem ? messages.slice(1) : messages;
    
      if (body.length <= keepLastTurns) return messages;
    
      // Un mensaje `tool` sin la llamada que lo genero es un 400 en la API.
      // Retrocedemos el corte hasta que deje de apuntar a un resultado huerfano.
      let cut = body.length - keepLastTurns;
      while (cut > 0 && body[cut].role === "tool") cut--;
    
      const middle = body.slice(0, cut);
      if (middle.length === 0) return messages;
    
      const summary: AgentMessage = {
        role: "user",
        content:
          `[RESUMEN DE PASOS ANTERIORES] Se ejecutaron ${middle.length} operaciones ` +
          `de inspeccion y validacion. El estado real esta en scratch/state.md; ` +
          `los mensajes siguientes son la fase activa.`,
      };
    
      return [...head, summary, ...body.slice(cut)];
    }
    

    Dos notas para llevarlo a producción:

    • Si tu proveedor exige alternancia estricta de roles, comprueba que body[cut] no sea otro mensaje de usuario. Si lo es, fusiona el resumen con él en vez de insertarlo aparte.
    • El resumen genérico es un punto de partida, no el final. En cuanto puedas, genera ese texto con una llamada barata a un modelo pequeño que resuma los pasos reales: qué se intentó, qué falló y por qué. Un resumen que dice "se ejecutaron 12 operaciones" evita la saturación, pero no conserva el aprendizaje.

    El coste oculto de compactar (y por qué no debes hacerlo cada turno)

    Aquí es donde mucha gente se pega el tiro en el pie.

    La caché de prompts de los proveedores funciona por prefijo: se reutiliza el principio del prompt mientras siga siendo idéntico. Cuando compactas, reescribes justo esa parte del historial, así que la petición siguiente se paga entera a precio completo, sin descuento de caché.

    Si compactas cada turno, pierdes más de lo que ahorras: tendrás menos tokens, pero pagados todos a tarifa plena y con la caché reconstruyéndose sin parar.

    La regla práctica que uso:

    1. Poda siempre, en cada llamada a herramienta. Eso no toca el prefijo cacheado, porque afecta a lo que todavía no ha entrado.
    2. Compacta por lotes, cuando cruzas un umbral (por ejemplo, el 60 % de la ventana) o cuando termina una fase completa de trabajo.
    3. Nunca compactes a mitad de una subtarea. Espera al cambio de fase: es cuando el resumen sale bien y cuando el corte de caché duele menos.

    Por qué Spec-Driven Development resuelve el 80 % del problema

    El Context Drift no es solo un problema de memoria; es un problema de ambigüedad inicial.

    Si no le das al agente una especificación cerrada en un spec.md, tiene que deducir qué hacer sobre la marcha. Y deducir cuesta tokens: exploración, preguntas, archivos abiertos por si acaso, callejones sin salida. Todo eso acaba en el historial y satura la ventana con material que ni siquiera hacía falta.

    Con Spec-Driven Development el alcance está delimitado desde el primer segundo: el agente sabe qué archivos puede tocar y qué queda fuera. Menos exploración es, literalmente, menos drift.

    Es el método que enseño en profundidad en el curso Construye con IA: de la idea al producto con Claude Code y que tienes explicado paso a paso en el libro de Spec-Driven Development.

    Y si además validas con esquemas de Zod todo lo que entra y sale de tus herramientas, cortas el otro vector de degradación: datos deformados que el agente arrastra durante veinte iteraciones sin que nadie los detecte. Cómo blindar esos contratos lo tienes en el curso de Zod para TypeScript.


    Lo que puedes aplicar hoy

    1. Poda los logs. Limita la salida de cada herramienta y resume los errores antes de inyectarlos.
    2. Externaliza la memoria. Guarda el progreso en un archivo en disco en lugar de confiar en el historial.
    3. Compacta por fases, no por turnos. Y mira el impacto en la caché antes de darlo por bueno.

    En Dominicode Labs diseñamos arquitecturas de agentes que ejecutan tareas de horas sin desviarse del objetivo.

    Tener una ventana de contexto gigante no es una excusa para ser descuidado con lo que metes dentro. La diferencia entre un prototipo frágil y un sistema agéntico de producción no está en el modelo: está en qué le dejas leer en la iteración 15.


    Preguntas frecuentes

    ¿Cada cuántas iteraciones conviene compactar el historial de un agente?

    No lo ates a un número de iteraciones, átalo a un umbral de ocupación de la ventana y a los cambios de fase. Compactar al cruzar el 60 % de la ventana, o al terminar una subtarea completa, funciona mejor que hacerlo cada N turnos: el resumen sale más limpio y no partes el trabajo por la mitad.

    ¿Compactar el contexto invalida la caché de prompts y encarece la factura?

    Sí. La caché funciona por prefijo idéntico, así que al reescribir el tramo intermedio del historial la siguiente petición se paga completa. Por eso conviene compactar por lotes en lugar de cada turno: podar la salida de las herramientas antes de que entren al historial reduce tokens sin tocar el prefijo ya cacheado.

    ¿Qué diferencia hay entre compactar el historial y reiniciar la conversación?

    Compactar conserva el hilo conversacional y sustituye lo antiguo por un resumen; reiniciar tira todo y arranca de cero leyendo el archivo de estado. Compactar es más fácil de aplicar en mitad de una tarea. Reiniciar limpia mejor, pero solo es viable si el estado real vive en disco y no en el chat.

    ¿Cómo distingo un context drift de un fallo del modelo?

    Por la reproducibilidad. Coge la petición que falló, arranca una conversación nueva con el system prompt, el estado actual y esa única instrucción, y vuelve a lanzarla. Si con el contexto limpio sale bien, no era el modelo: era el historial. Si falla igual, el problema está en tus instrucciones o en la propia tarea.

    ¿Sirve de algo esto si uso un modelo con ventana de un millón de tokens?

    Sirve más, no menos. La ventana grande solo amplía cuánta basura cabe antes de que la petición reviente, pero la atención se sigue diluyendo y el coste por llamada crece con todo lo que arrastras. Una ventana grande es margen de maniobra, no un sustituto de la gestión del contexto.


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

  • Test harness para agentes de IA: el banco de pruebas que te falta en CI

    Test harness para agentes de IA: el banco de pruebas que te falta en CI

    Nadie prueba un motor de avión montándolo en un aparato con pasajeros. Lo amarran a un banco de pruebas, le conectan sensores, le inducen fallos y miden qué aguanta. Si revienta, revienta en tierra.

    Con software tenemos el equivalente desde hace décadas y se llama test harness: el andamiaje que rodea al código bajo prueba, le inyecta entradas controladas y comprueba las salidas.

    Con agentes de IA, en cambio, la mayoría probamos en caliente. Lanzamos el agente contra una API real, miramos si el resultado "parece bien" y lo damos por bueno.

    El problema no es la pereza. Es que un agente rompe los tres supuestos sobre los que se construyó todo tu testing:

    • No es determinista: la misma entrada da salidas distintas.
    • Tiene efectos secundarios reales: escribe archivos, llama a APIs, toca bases de datos.
    • No tiene garantía de terminar: puede quedarse en bucle gastando dinero.

    Ya expliqué por qué un LLM por sí solo no es un producto y qué capas necesita alrededor para funcionar en producción. Este post va de la otra mitad del problema, la que casi nadie monta: el arnés que se ejecuta en CI, antes del deploy. Con código.


    Por qué un test unitario normal no sirve aquí

    Un test clásico es un contrato de tres líneas: preparas la entrada, ejecutas, comparas con el valor esperado.

    Con un agente, ese toEqual no existe. La respuesta correcta no es una cadena concreta, es cualquiera de un conjunto amplio de cadenas aceptables. Y si aun así escribes la aserción exacta, tendrás un test que pasa hoy y falla el martes sin que nadie haya tocado nada.

    De ahí sale la reacción habitual, que es la equivocada: dejar de testear el agente y testear solo las funciones puras que lo rodean. Los parsers, los formateadores, los validadores. Cosas que ya sabías hacer.

    Mientras tanto, lo que de verdad puede costarte dinero —el bucle, las llamadas a herramientas, el gasto— viaja a producción sin una sola comprobación.

    El arnés cambia la pregunta. En lugar de "¿ha respondido lo correcto?", que es un problema de evals, pregunta cosas que sí tienen respuesta binaria:

    • ¿Ha llamado a alguna herramienta que no tenía permitida?
    • ¿Se ha pasado del presupuesto de tokens que le di?
    • ¿Ha terminado dentro del tiempo límite?
    • ¿Ha intentado escribir fuera de su directorio temporal?
    • ¿Ha llamado 14 veces a la misma herramienta con los mismos argumentos?

    Eso son tests de verdad: deterministas, rápidos y rojos cuando algo se rompe.

       caso de prueba              TEST HARNESS                  veredicto
      ┌──────────────┐    ┌──────────────────────────────┐    ┌────────────┐
      │ entrada fija │───►│  tools falsas (sin red)      │───►│ PASS/FAIL  │
      │ estado fijo  │    │  presupuesto de tokens       │    │ trace.json │
      └──────────────┘    │  timeout + AbortSignal       │    └────────────┘
                          │  directorio efimero          │
                          └──────────────────────────────┘
    

    Las 3 piezas que hacen testeable a un agente

    1. Herramientas falsas, no red

    La regla es simple: en modo test, el agente no toca nada real. Ni base de datos, ni API de pagos, ni sistema de archivos fuera de un directorio temporal que destruyes al terminar.

    Y no basta con mockear la implementación. Hay que no exponer las herramientas no autorizadas: si el agente ve deleteUser en su lista de tools, tarde o temprano la llamará, y el error que quieres detectar en CI es precisamente ese. Un arnés que expone la herramienta y luego lanza una excepción llega tarde para razonar sobre el diseño, aunque salve los datos.

    Si necesitas ejecutar código generado de verdad —no simularlo—, ahí el aislamiento sube un nivel y toca contenedor: lo conté en Docker sandboxing para ejecutar código de IA de forma segura.

    2. Presupuesto de tokens y timeout que cortan de verdad

    Esta es la pieza que casi todo el mundo escribe mal.

    He visto docenas de arneses con un campo maxTokens en la configuración que no se comprueba en ningún sitio. Y timeouts implementados con Promise.race que devuelven el control al test pero dejan la ejecución corriendo por detrás, gastando tokens contra la API mientras el test ya ha dado verde.

    Un límite que no corta no es un límite: es un comentario.

    3. Traza reproducible

    El arnés graba cada paso: qué herramienta, con qué argumentos, cuánto tardó, cuánto costó. Un array de objetos serializado a JSON.

    Sirve para dos cosas. Para que un fallo en CI sea depurable sin volver a lanzar el agente. Y para escribir aserciones sobre el proceso, no sobre el texto final, que es donde está la señal útil: si el agente llegó al resultado correcto llamando siete veces a la misma consulta, eso es un bug aunque la salida sea perfecta.


    El arnés en TypeScript

    Vamos al código. Un arnés mínimo con presupuesto real, cancelación real y traza, sin dependencias más allá de Zod para validar los argumentos que el modelo envía a cada herramienta.

    Primero, los tipos y el registro de herramientas:

    import { z } from "zod";
    
    export interface HarnessConfig {
      maxTokens: number;
      timeoutMs: number;
      allowedTools: string[];
    }
    
    export interface TraceEntry {
      tool: string;
      args: unknown;
      durationMs: number;
      tokens: number;
    }
    
    /** Herramienta ya validada: el schema queda encapsulado dentro de `run`. */
    export interface HarnessTool {
      cost: number;
      run: (rawArgs: unknown) => Promise<unknown>;
    }
    
    export class BudgetExceededError extends Error {}
    
    /**
     * En el punto de definicion conservas el tipado completo del schema.
     * En el registro todas las tools comparten la misma firma, que es lo
     * que permite recorrerlas en bucle sin castings.
     */
    export function defineTool<S extends z.ZodType>(
      schema: S,
      cost: number,
      run: (args: z.infer<S>) => Promise<unknown>,
    ): HarnessTool {
      return { cost, run: (rawArgs) => run(schema.parse(rawArgs)) };
    }
    
    // Fixtures: nada de esto sale a la red.
    export const testTools: Record<string, HarnessTool> = {
      queryDatabase: defineTool(
        z.object({ table: z.string(), limit: z.number().max(100) }),
        320,
        async ({ table }) => ({ rows: [{ id: 1, table, name: "Fixture User" }] }),
      ),
      sendEmail: defineTool(
        z.object({ to: z.string().email(), body: z.string() }),
        90,
        async () => ({ delivered: true }),
      ),
    };
    

    Ahora el arnés. Fíjate en tres detalles: solo se construyen las herramientas permitidas, el presupuesto se comprueba antes de ejecutar cada llamada, y el temporizador se limpia siempre.

    export type HarnessStatus = "SUCCESS" | "TIMEOUT" | "BUDGET_EXCEEDED" | "FAILED";
    
    export interface HarnessResult {
      status: HarnessStatus;
      tokensUsed: number;
      durationMs: number;
      output: string | null;
      trace: TraceEntry[];
    }
    
    type ToolBox = Record<string, (args: unknown) => Promise<unknown>>;
    
    export async function runWithHarness(
      task: (tools: ToolBox, signal: AbortSignal) => Promise<string>,
      config: HarnessConfig,
    ): Promise<HarnessResult> {
      const startedAt = performance.now();
      const trace: TraceEntry[] = [];
      let tokensUsed = 0;
    
      // 1. Solo existen las tools autorizadas. El resto no se expone.
      const tools: ToolBox = {};
      for (const name of config.allowedTools) {
        const tool = testTools[name];
        // Un nombre desconocido es un error de configuracion del test: que reviente ya.
        if (!tool) throw new Error(`Tool desconocida en allowedTools: ${name}`);
    
        tools[name] = async (rawArgs: unknown) => {
          // 2. El presupuesto se comprueba ANTES de gastar.
          if (tokensUsed + tool.cost > config.maxTokens) {
            throw new BudgetExceededError(
              `Presupuesto agotado: ${tokensUsed} + ${tool.cost} > ${config.maxTokens}`,
            );
          }
          const t0 = performance.now();
          const result = await tool.run(rawArgs); // Zod valida dentro: si no cuadra, revienta
          tokensUsed += tool.cost;
          trace.push({
            tool: name,
            args: rawArgs,
            durationMs: Math.round(performance.now() - t0),
            tokens: tool.cost,
          });
          return result;
        };
      }
    
      // 3. Cancelacion real: la tarea recibe el signal y debe propagarlo al SDK.
      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort(), config.timeoutMs);
    
      const finish = (status: HarnessStatus, output: string | null): HarnessResult => ({
        status,
        tokensUsed,
        durationMs: Math.round(performance.now() - startedAt),
        output,
        trace,
      });
    
      try {
        const output = await task(tools, controller.signal);
        return finish("SUCCESS", output);
      } catch (error) {
        if (controller.signal.aborted) return finish("TIMEOUT", null);
        if (error instanceof BudgetExceededError) return finish("BUDGET_EXCEEDED", null);
        return finish("FAILED", error instanceof Error ? error.message : String(error));
      } finally {
        clearTimeout(timer); // sin esto, el timer mantiene vivo el proceso al terminar
      }
    }
    

    Un aviso honesto sobre el punto 3: el AbortSignal solo cancela de verdad si tu tarea lo propaga al SDK del modelo y a cada fetch. Si lo ignoras, el arnés dará TIMEOUT y devolverá el control al test, pero la llamada seguirá viva por detrás y te la cobrarán igual. El signal no es decorativo: es el único mecanismo que corta el gasto.

    Y ahora sí, un test

    Con esto, probar el bucle del agente vuelve a ser testing normal:

    import { describe, expect, it } from "vitest";
    import { runWithHarness } from "./harness";
    
    describe("agente de facturación", () => {
      it("corta la ejecución al agotar el presupuesto", async () => {
        const result = await runWithHarness(
          async (tools) => {
            // Un agente en bucle: consulta la misma tabla sin parar.
            for (let i = 0; i < 20; i++) {
              await tools.queryDatabase({ table: "invoices", limit: 10 });
            }
            return "listo";
          },
          { maxTokens: 1_000, timeoutMs: 5_000, allowedTools: ["queryDatabase"] },
        );
    
        expect(result.status).toBe("BUDGET_EXCEEDED");
        expect(result.tokensUsed).toBeLessThanOrEqual(1_000);
        expect(result.trace).toHaveLength(3); // 3 × 320 = 960; la cuarta no cabe
      });
    
      it("no expone las herramientas fuera del allowlist", async () => {
        const result = await runWithHarness(
          async (tools) => {
            if ("sendEmail" in tools) return "PELIGRO: tool disponible";
            return "ok";
          },
          { maxTokens: 5_000, timeoutMs: 5_000, allowedTools: ["queryDatabase"] },
        );
    
        expect(result.output).toBe("ok");
      });
    });
    

    Deterministas, sin red, en milisegundos. Se pueden ejecutar en cada push sin pensar en la factura.

    Ese expect(result.trace).toHaveLength(3) es el tipo de aserción que solo puedes escribir si grabas la traza: comprueba el comportamiento del bucle, no el texto de salida.

    Si quieres afinar el diseño de tests y el aislamiento de dependencias externas —que es exactamente el músculo que necesitas aquí—, lo trabajo a fondo en el curso de Testing en Angular con Jest y Testing Library. Y el uso de Zod para validar los argumentos que envía el modelo, con transformaciones y errores tipados, lo tienes en el curso de Zod para TypeScript.


    Qué encaja arriba y qué encaja abajo

    Tres piezas que se confunden todo el rato y conviene separar:

    Pieza Cuándo corre Qué responde
    Test harness En CI, en cada push ¿Se sale de los límites, del allowlist o del tiempo?
    Evals Por lotes, con casos reales ¿La calidad de las respuestas sube o baja?
    Agentic harness En producción, en cada ejecución ¿Cómo lo mantengo controlado con usuarios reales?

    El arnés de pruebas es el más barato de los tres y el que casi nadie tiene. Cuestión de horas montarlo, y atrapa la clase de fallo que más caro sale.

    Sobre el reparto de trabajo entre los tests que escribes tú y los que genera el agente, ya hay un post entero: adopta TDD para implementar pruebas efectivas con agentes de IA. Y sobre por qué la spec y la arquitectura no bastan sin esta capa debajo, también. Este post es la parte que faltaba: el código.

    Si trabajas con Spec-Driven Development, el encaje es directo. Los límites que escribes en la sección de NFRs del spec.md —presupuesto, latencia, herramientas permitidas— dejan de ser un párrafo y pasan a ser los argumentos de HarnessConfig. La especificación se vuelve ejecutable, que es de lo que va el libro de Spec-Driven Development.


    Lo que puedes montar esta semana

    1. Una lista blanca de herramientas por entorno. Que en test solo existan las que necesita el caso.
    2. Un presupuesto que corte. Comprobado antes de cada llamada, no después. Si tu maxTokens no aparece en ningún if, no existe.
    3. Una traza en JSON por ejecución. Y al menos un test que asierte sobre ella, no sobre el texto de salida.

    En Dominicode Labs montamos este tipo de arneses sobre agentes que corren horas sin supervisión.

    Deja de probar tus motores en pleno vuelo. Amárralos al banco, súbeles la presión hasta que rompan y arréglalos en tierra, que es donde sale barato.


    Preguntas frecuentes

    ¿Cómo se testea algo que no es determinista?

    No asertando sobre el texto de salida, sino sobre el comportamiento observable: qué herramientas llamó, con qué argumentos, cuántas veces, cuánto gastó y si terminó a tiempo. Todo eso sí es determinista y da un rojo claro cuando se rompe. La calidad de la respuesta es otra disciplina y se mide por lotes, no en cada push.

    ¿El test harness sustituye a los mocks de toda la vida?

    No, los usa. La diferencia es el alcance: un mock reemplaza una dependencia concreta, mientras que el arnés controla el entorno completo de la ejecución —qué herramientas existen, cuánto puede gastar, cuánto puede tardar y qué queda grabado—. Un mock por sí solo no impide que el agente entre en bucle.

    ¿Hay que llamar al modelo real en estos tests?

    No en los que corren en cada push: se ejecuta el bucle del agente con respuestas fijas, y eso vale para verificar límites, allowlist y control de flujo. Las ejecuciones con modelo real cuestan dinero y tardan, así que van en un job aparte, programado y sobre un conjunto reducido de casos.

    ¿Qué hago si el timeout salta pero el agente sigue gastando dinero?

    Es que estás cortando en el sitio equivocado. Promise.race devuelve el control al test pero no cancela nada: hay que crear un AbortController, pasar su signal a la tarea y propagarlo al SDK del modelo y a cada fetch. Si el SDK que usas no acepta señal de cancelación, el único corte real es aislar la ejecución en un proceso o contenedor aparte y matarlo.

    ¿Merece la pena montarlo si mi agente solo lee datos?

    Sí, por el gasto y por los bucles. Un agente de solo lectura no borra nada, pero puede repetir la misma consulta cuarenta veces y facturarte la broma entera. El presupuesto y la traza detectan ese patrón en CI, que es donde cuesta cero arreglarlo.


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

  • Agentic Loop en TypeScript: cómo evitar el bucle infinito

    Agentic Loop en TypeScript: cómo evitar el bucle infinito

    Un viernes a las 11 de la noche dejé corriendo un script experimental con un agente para refactorizar unos modelos de datos.

    A la mañana siguiente abrí la consola de Anthropic y vi que había ejecutado más de 300 llamadas a herramientas seguidas. ¿El motivo? Intentó leer un archivo que no existía, la herramienta devolvió un error genérico, el modelo interpretó que la ruta estaba mal escrita, probó otra ruta inexistente, y así durante horas.

    Un agente de IA no es un modelo inteligente: es un Agentic Loop, un bucle while en TypeScript que tú controlas (o que te controla a ti). La factura de tokens dolió, pero la lección técnica valió cada céntimo.

    La mayoría de tutoriales te enseñan a pasarle tres herramientas a un LLM y llamar a generateText. Nadie te explica qué pasa cuando el modelo entra en pánico lógico, se niega a terminar o repite la misma llamada una y otra vez.

    Aquí está cómo funciona de verdad el Agentic Loop en producción y cómo implementarlo en TypeScript sin caer en trampas de novato.


    Qué es realmente un Agentic Loop

    La diferencia entre un chatbot clásico y un agente autónomo no es el modelo de lenguaje. Ambos pueden usar Claude Sonnet 5, Opus 5 o el GPT vigente. La diferencia es el patrón de ejecución.

    Un chatbot recibe un mensaje y devuelve texto. Fin del ciclo.

    Un agente recibe una instrucción, razona qué herramienta necesita, la ejecuta, observa el resultado y vuelve a razonar con esa nueva información hasta que considera que la tarea está terminada.

    El flujo básico sigue siempre este ciclo:

    1. Reasoning (Razonamiento): El modelo evalúa el historial y decide si responder o invocar una herramienta (Tool Call).
    2. Action (Acción): Tu servidor ejecuta la función requerida (leer un archivo, consultar una base de datos, llamar a una API).
    3. Observation (Observación): Inyectas el resultado de la función de vuelta al contexto como un mensaje de rol tool.
    4. Evaluation (Evaluación): El modelo analiza el nuevo estado. Si el objetivo está cumplido, entrega la respuesta final; si no, repite desde el paso 1.

    Si este ciclo te suena, es porque es la formalización del patrón ReAct: Thought → Action → Observation. Ahí explico el razonamiento; aquí vamos a lo que nadie cuenta: qué defensas necesita ese bucle para sobrevivir a producción.

    Parece sencillo en un diagrama. En producción, si dejas este bucle sin defensas activas, tienes una bomba de tiempo.


    Los 3 errores fatales que rompen tus agentes en producción

    1. No tener un límite de pasos rígido

    Si no defines un tope máximo de iteraciones, un fallo imprevisto en una API externa convertirá a tu agente en un generador infinito de facturación. En cualquier sistema agéntico serio, cada tarea debe tener un presupuesto máximo de pasos (usualmente entre 5 y 15 para tareas estándar).

    2. Tratar los errores de las herramientas como excepciones no controladas

    Si una tool lanza un throw new Error("File not found") y tu código revienta el proceso de Node/Bun, tu agente se cae. Pero si capturas el error y le devuelves un string vacío, el agente asumirá que el archivo está en blanco y tomará decisiones erróneas.

    Devuelve los errores de herramientas formateados como datos descriptivos para que el modelo sepa exactamente qué falló y pueda autocorregirse.

    3. Falta de detección de estancamiento (Loop Fingerprinting)

    El modelo a veces se obsesiona. Ejecuta readFile({ path: "config.json" }), falla, y en el siguiente paso vuelve a llamar exactamente a readFile({ path: "config.json" }) esperando un milagro.

    Si no comparas la huella de la llamada actual con las anteriores, tu agente se quedará atascado en un ciclo ciego. Y ojo: un límite de pasos no te salva de esto, solo te acota el gasto. El agente sigue quemando el presupuesto entero sin avanzar un milímetro.


    Implementando un Agentic Loop robusto en TypeScript

    Para evitar dependencias pesadas que oscurezcan lo que pasa por debajo, el estándar más limpio hoy es usar el Vercel AI SDK con schemas tipados mediante Zod.

    La clave está en stopWhen: acepta un array de condiciones y detiene el bucle cuando cualquiera de ellas se cumple. Eso te permite combinar el techo de gasto con tu propio detector de bucles.

    Aquí tienes una implementación defensiva lista para producción:

    import { generateText, tool, isStepCount, type StopCondition } from "ai";
    import { anthropic } from "@ai-sdk/anthropic";
    import { z } from "zod";
    
    // 1. Definición estricta de herramientas con Zod
    const tools = {
      readFile: tool({
        description: "Lee el contenido de un archivo del proyecto",
        inputSchema: z.object({
          filePath: z.string().describe("Ruta relativa del archivo a leer"),
        }),
        execute: async ({ filePath }) => {
          try {
            const content = await Bun.file(filePath).text();
            return { success: true, data: content };
          } catch (err: any) {
            // Devolvemos el error como información útil para el agente
            return {
              success: false,
              error: `No se pudo leer el archivo "${filePath}": ${err.message}`,
            };
          }
        },
      }),
    };
    
    // 2. Huella de cada tool call: nombre + argumentos exactos
    const fingerprint = (call: { toolName: string; input: unknown }) =>
      `${call.toolName}:${JSON.stringify(call.input)}`;
    
    // 3. Condición de parada propia: corta a la tercera llamada idéntica
    //    (la segunda es un reintento legítimo; la tercera ya es obsesión)
    const noRepeatedCalls: StopCondition<typeof tools> = ({ steps }) => {
      const calls = steps.flatMap((step) => step.toolCalls.map(fingerprint));
    
      return calls.some(
        (f) => calls.filter((other) => other === f).length >= 3
      );
    };
    
    // 4. Ejecutor agéntico con las dos defensas activas
    export async function runAgentTask(prompt: string, maxIterations = 10) {
      const result = await generateText({
        model: anthropic("claude-sonnet-5"),
        system: `Eres un asistente de desarrollo autónomo.
        Usa las herramientas disponibles para inspeccionar el entorno.
        Si una herramienta falla, analiza el motivo antes de reintentar.
        Cuando termines la tarea, responde con el resumen final sin invocar más tools.`,
        prompt,
        tools,
        // Para en cuanto se cumpla una: presupuesto agotado o bucle detectado
        stopWhen: [isStepCount(maxIterations), noRepeatedCalls],
        onStepFinish: ({ toolCalls }) => {
          for (const call of toolCalls) {
            console.log(`[STEP] ${fingerprint(call)}`);
          }
        },
      });
    
      return { text: result.text, steps: result.steps.length };
    }
    

    Fíjate en lo que hace este patrón:

    1. Schemas Zod estrictos: Si el LLM intenta inventarse un parámetro que no existe, la librería lo rechaza antes de tocar el sistema de archivos. Si quieres profundizar en cómo validar estructuras complejas, en nuestro curso de Zod para TypeScript vemos cómo blindar estas entradas.
    2. Resultados estructurados: Las herramientas devuelven { success: boolean, data?: string, error?: string }. El modelo sabe interpretar este formato a la primera.
    3. Dos condiciones de parada independientes: isStepCount es el techo de gasto; noRepeatedCalls es el que de verdad mata el bucle infinito, porque detecta el estancamiento aunque queden pasos de presupuesto.
    4. Trazabilidad paso a paso: onStepFinish te da el log de cada decisión del agente. No puede abortar el bucle —eso es trabajo de stopWhen—, pero es lo que te permite reconstruir después por qué se atascó.

    Un detalle que se pasa por alto: la parada devuelve el control a tu código, no lanza una excepción. Comprueba steps al recibir el resultado — si el agente terminó en el paso 10 de 10, probablemente no completó la tarea y toca escalarla, no darla por buena.

    Es el mismo cambio de mentalidad del que hablo en Loop Engineering: el valor ya no está en el prompt, está en el bucle que lo envuelve.


    La regla de oro: Antes de escribir el loop, define el Spec

    El mejor código de loop no compensa una instrucción ambigua. Si le dices a tu agente "arregla el bug del login", gastará 8 iteraciones solo descubriendo dónde está el archivo de login.

    Esta es la razón por la que en Dominicode insistimos tanto en la metodología Spec-First. Antes de soltar a un agente a modificar código, generamos una especificación técnica clara que acota el alcance exacto de la tarea. Es exactamente el flujo que enseñamos en el curso Construye con IA: de la idea al producto y en el libro de Spec-Driven Development.


    Qué puedes cambiar hoy en tu arquitectura

    Si ya tienes agentes corriendo en tus proyectos locales o en servidores de staging, haz esta revisión técnica hoy mismo:

    1. Revisa tu límite de pasos (stopWhen): Ninguna llamada agéntica debería correr sin un límite superior finito.
    2. Audita el retorno de tus tools: Asegúrate de que las excepciones devuelvan mensajes de error útiles en lugar de romper el runtime o devolver respuestas vacías.
    3. Implementa fingerprinting: Añade una condición de parada propia que compare la huella de cada tool call con las anteriores y corte a la tercera repetición exacta.
    4. Comprueba en qué paso terminó: Si el agente agota el presupuesto, trátalo como fallo y escala la tarea. Un resultado entregado en el último paso casi nunca es un resultado bueno.

    En Dominicode Labs estamos probando patrones avanzados de orquestación agéntica con subagentes independientes y guardrails de seguridad en proyectos reales.

    Construir con IA no consiste en maravillarse con lo que genera el modelo en el primer prompt, sino en diseñar la arquitectura que hace que el sistema sea predecible y seguro cuando nadie está mirando la pantalla.


    Preguntas frecuentes

    ¿Cuántos pasos como máximo debe tener un Agentic Loop?

    Entre 5 y 15 para tareas estándar. Con menos de 5 el agente no llega a explorar el entorno; por encima de 15 el problema casi nunca es el presupuesto, sino que la instrucción era ambigua. Si necesitas 30 pasos, parte la tarea en dos.

    ¿Qué diferencia hay entre un Agentic Loop y el patrón ReAct?

    ReAct describe el razonamiento del modelo: pensar, actuar, observar. El Agentic Loop es la implementación real de ese ciclo en tu código, con sus límites, su manejo de errores y sus condiciones de parada. ReAct es el qué; el Agentic Loop es el cómo lo ejecutas sin que se te vaya la factura.

    ¿Debo lanzar una excepción cuando falla una tool?

    No. Captura el error dentro de execute y devuélvelo como dato estructurado ({ success: false, error: "..." }). Si lanzas la excepción, rompes el proceso; si devuelves vacío, el modelo asume que el resultado era vacío y decide mal. El error descriptivo es lo único que le permite autocorregirse.

    ¿No basta con poner un límite de pasos para evitar el bucle infinito?

    El límite evita que el gasto sea infinito, pero no evita el bucle. Un agente atascado consumirá los 10 pasos repitiendo la misma llamada y te devolverá un resultado inútil habiendo pagado el presupuesto completo. El límite de pasos es el airbag; el fingerprinting es el freno.

    ¿Necesito un framework de orquestación para implementar esto?

    No para un bucle de una sola tarea: el Vercel AI SDK con stopWhen te da control de pasos y parada personalizada en unas 40 líneas. Los frameworks de grafos empiezan a compensar cuando necesitas ramificaciones, estado persistente entre sesiones o varios agentes coordinados.


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

  • Fetching de Datos en Paralelo en Next.js con Suspense

    Fetching de Datos en Paralelo en Next.js con Suspense

    Hace unos meses audité el dashboard de un SaaS construido con el App Router de Next.js. El usuario iniciaba sesión y la pantalla tardaba 4,2 segundos en mostrar el primer píxel interactivo.

    El equipo pensaba que el cuello de botella estaba en los índices de PostgreSQL o en la memoria de la máquina en Vercel.

    Abrí el archivo page.tsx del dashboard. Había cuatro llamadas con await consecutivas:

    // ❌ El clásico waterfall en Server Components
    const user = await getUser();
    const stats = await getStats(user.id);
    const notifications = await getNotifications(user.id);
    const recommendations = await getExternalRecommendations();
    

    Cada petición esperaba a que la anterior terminara: 400ms + 1.200ms + 600ms + 2.000ms. Un waterfall secuencial de 4,2 segundos. Y peor aún: si el microservicio de recomendaciones caía con un error 504, toda la página devolvía un error 500 al cliente.

    Next.js te ofrece React Server Components por defecto, pero si no estructuras el fetching de datos en paralelo en Next.js, conviertes tu servidor en una fila india de bloqueos innecesarios.

    Aquí te muestro la arquitectura en tres capas para paralelizar llamadas, tolerar caídas de microservicios y hacer streaming instantáneo hacia el navegador.


    1. El antipatrón del Waterfall secuencial

    Cuando colocas llamadas asíncronas consecutivas en el cuerpo de una función de Server Component, la ejecución en Node.js se detiene en cada línea.

    TIEMPO (ms)  0ms       400ms                1600ms          2200ms                    4200ms
                 ├─────────┼────────────────────┼───────────────┼─────────────────────────┤
    Llamada 1:   [getUser]
    Llamada 2:             [getStats]
    Llamada 3:                                  [getNotifs]
    Llamada 4:                                                  [getRecommendations]
                                                                                          ▲
                                                                               Primer render (4.2s)
    

    Las peticiones no arrancan a la vez; arrancan en cascada. Salvo que una llamada requiera obligatoriamente el resultado de la anterior para construir su consulta, ejecutar esto en serie es desperdiciar los hilos de red del servidor.


    2. Nivel 1: fetching de datos en paralelo con Promise.all

    Si necesitas varios bloques de datos indispensables para renderizar la vista y ninguno depende de otro, la primera optimización es disparar todas las promesas al mismo tiempo con Promise.all:

    // app/dashboard/page.tsx
    interface DashboardData {
      user: User;
      stats: UserStats;
      notifications: Notification[];
    }
    
    export default async function DashboardPage() {
      // Inicia todas las promesas en paralelo
      const [user, stats, notifications] = await Promise.all([
        getUser(),
        getStats(),
        getNotifications(),
      ]);
    
      return (
        <main className="p-6">
          <UserProfile user={user} />
          <StatsOverview stats={stats} />
          <NotificationList items={notifications} />
        </main>
      );
    }
    

    La ganancia:

    El tiempo total de espera ya no es la suma de todas las llamadas, sino el tiempo de la más lenta. Si la más lenta tarda 1.200ms, la página resuelve en 1.200ms en lugar de 2.200ms.

    TIEMPO (ms)  0ms             1200ms
                 ├───────────────┤
    getUser:     [==== 400ms ====]
    getStats:    [======== 1200ms =======]
    getNotifs:   [====== 600ms ======]
                                 ▲
                      Resuelve en paralelo (1.2s)
    

    La trampa de Promise.all:

    Promise.all tiene comportamiento de rechazo rápido (fail-fast). Si dos promesas resuelven con éxito pero una falla, toda la llamada lanza una excepción. Úsalo exclusivamente para datos que son 100% obligatorios para la vista.


    3. Nivel 2: Promise.allSettled para tolerancia a fallos

    ¿Qué ocurre cuando una página incluye datos secundarios, como recomendaciones de productos, widgets del clima o analíticas de terceros?

    No tiene sentido romper el perfil del usuario porque una API externa esté caída. Para peticiones secundarias o no bloqueantes, la solución nativa es Promise.allSettled:

    // app/dashboard/page.tsx
    export default async function DashboardPage() {
      const [profileResult, recommendationsResult] = await Promise.allSettled([
        getUserProfile(),
        getThirdPartyRecommendations(),
      ]);
    
      // Si el perfil falla, cortamos porque es crítico
      if (profileResult.status === "rejected") {
        throw new Error("No se pudo cargar el perfil del usuario.");
      }
    
      const profile = profileResult.value;
    
      // Si las recomendaciones fallan, degradamos elegantemente sin romper la UI
      const recommendations =
        recommendationsResult.status === "fulfilled"
          ? recommendationsResult.value
          : [];
    
      return (
        <section>
          <UserProfile user={profile} />
          {recommendations.length > 0 ? (
            <RecommendationCarousel items={recommendations} />
          ) : (
            <p className="text-sm text-gray-500">Recomendaciones no disponibles hoy.</p>
          )}
        </section>
      );
    }
    

    Promise.allSettled garantiza que Node.js esperará a que todas las promesas finalicen, devolviendo un objeto con { status: 'fulfilled', value } o { status: 'rejected', reason }. Tu interfaz resiste caídas parciales sin tirar el servidor.


    4. Nivel 3: React <Suspense> y Streaming para pulverizar el TTFB

    Incluso con Promise.all, si un componente tarda 2,5 segundos, el usuario mirará una pantalla en blanco durante 2,5 segundos antes de ver el primer byte HTML.

    Con React Suspense y Streaming en Next.js, desacoplas la carga de la página del componente más lento.

    La regla de oro: mueve el await dentro del componente que realmente consume los datos.

    // app/dashboard/page.tsx
    import { Suspense } from "react";
    import { UserHeader } from "./components/UserHeader";
    import { HeavyAnalyticsWidget } from "./components/HeavyAnalyticsWidget";
    import { SkeletonWidget } from "./components/SkeletonWidget";
    
    export default function DashboardPage() {
      return (
        <div className="space-y-6">
          {/* Carga inmediata (rápido) */}
          <Suspense fallback={<p>Cargando cabecera...</p>}>
            <UserHeader />
          </Suspense>
    
          {/* Widget pesado: no bloquea el resto de la página */}
          <Suspense fallback={<SkeletonWidget />}>
            <HeavyAnalyticsWidget />
          </Suspense>
        </div>
      );
    }
    
    // app/dashboard/components/HeavyAnalyticsWidget.tsx
    // Este Server Component hace su propio fetching asíncrono
    export async function HeavyAnalyticsWidget() {
      const data = await getHeavyMetrics(); // Tarda 2.5s
      return <MetricsChart data={data} />;
    }
    

    Qué experimenta el usuario:

    1. En 80 milisegundos, el servidor envía la estructura HTML principal, el menú, la cabecera y el skeleton del gráfico.
    2. La página es interactiva de inmediato.
    3. A los 2,5 segundos, Next.js envía por streaming el fragmento HTML del gráfico y React lo reemplaza en el DOM sin recargar la página.

    Si este patrón te suena a fricción con Server Components mal migrados, cubrimos los fallos más comunes en Errores comunes al migrar a React Server Components en producción.

    Esta mentalidad de arquitectura modular y control de estados asíncronos es exactamente lo que desarrollamos en el curso Construye con IA: De la Idea al Producto con Claude y Specs, donde conectamos interfaces modernas en Next.js con servicios backend de alta concurrencia.


    Comparativa: ¿Cuándo usar cada técnica?

    Escenario Técnica recomendada Beneficio clave
    Múltiples datos obligatorios para el layout principal Promise.all Máxima velocidad paralela (tiempo = el de la llamada más lenta)
    Widgets externos o servicios propensos a timeouts Promise.allSettled Resiliencia y degradación elegante
    Componentes lentos o dashboards con múltiples secciones React <Suspense> + Streaming TTFB mínimo y UX instantánea
    Datos con dependencias en cadena (A depende de B) await secuencial estricto Integridad en la cadena de datos

    Arquitectura de fetching de datos en paralelo en producción

    El patrón profesional más sólido en aplicaciones Next.js de gran escala combina los tres enfoques:

    1. El layout principal y la vista estructural no bloquean con await globales innecesarios.
    2. Cada bloque funcional vive en su propio Server Component envuelto en <Suspense>.
    3. Dentro de cada bloque funcional, si se requieren múltiples recursos, se utiliza Promise.all (si son obligatorios) o Promise.allSettled (si toleran fallo).

    Si además necesitas exprimir memoria y builds en producción, ya cubrimos ese terreno en Optimización extrema de rendimiento y consumo de memoria en Next.js 16.

    Para profundizar en optimizaciones avanzadas de memoria, caching y patrones de Server Components en producción, en Dominicode Labs revisamos arquitecturas reales y analizamos benchmarks de rendimiento semana a semana.


    Preguntas frecuentes

    ¿Promise.all en Next.js Server Components ejecuta en el cliente o en el servidor?

    En Server Components (archivos sin 'use client'), Promise.all se ejecuta íntegramente en el entorno de Node.js o Edge del servidor antes de generar o transmitir el HTML al cliente.

    ¿Qué diferencia hay entre Promise.all y Promise.allSettled?

    Promise.all rechaza inmediatamente si cualquiera de las promesas falla (comportamiento todo o nada), mientras que Promise.allSettled espera a que todas concluyan y entrega el estado individual (fulfilled o rejected) de cada una.

    ¿Suspense sustituye por completo a Promise.all?

    No. Se complementan. <Suspense> gestiona el streaming y la interfaz de carga progresiva entre diferentes componentes, mientras que Promise.all gestiona la concurrencia de datos dentro de un mismo componente.

    ¿El uso de Suspense afecta al SEO en Google?

    No. Los rastreadores web modernos de Google esperan la resolución del stream de Server Components y reciben el HTML final renderizado con el contenido completo.


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

  • TDD con IA: valida el código autogenerado antes de mergear

    TDD con IA: valida el código autogenerado antes de mergear

    Revisé una Pull Request generada por un asistente de IA hace un par de semanas.

    El autor de la PR estaba fascinado: "Mira qué limpio quedó el algoritmo de descuentos por volumen. La IA lo escribió en 15 segundos".

    El código tenía nombres impecables, comentarios en JSDoc y tipado de TypeScript sin un solo error. Le faltaba lo único que sostiene el TDD con IA: tests.

    Escribí una prueba unitaria pasando una compra con descuento de cliente VIP combinado con un cupón del 100%. El sistema devolvió un saldo negativo donde la tienda terminaba debiéndole dinero al comprador.

    El modelo de IA no tenía mala intención: simplemente no sabía qué reglas de negocio proteger porque nadie se las había formulado como una prueba ejecutable.

    En la era de los asistentes de código, Test-Driven Development (TDD) no está muerto; es más indispensable que nunca. Aquí tienes el flujo exacto para combinar TDD con IA.


    El nuevo ciclo Red-Green-Refactor con Agentes de Código

    El ciclo clásico de TDD se transforma radicalmente cuando tienes un agente a tu lado:

    ┌─────────────────────────────────────────────────────────────┐
    │                 FLUJO TDD POTENCIADO POR IA                 │
    │                                                             │
    │  1. HUMANO (Diseño)  ──>  Escribe el Test Unitario (ROJO)   │
    │                                   │                         │
    │                                   ▼                         │
    │  2. AGENTE (Código)  ──>  Genera la Implementación (VERDE)  │
    │                                   │                         │
    │                                   ▼                         │
    │  3. DÚO (Calidad)    ──>  Refactoriza con Seguridad         │
    └─────────────────────────────────────────────────────────────┘
    

    En lugar de delegar el diseño a ciegas, el desarrollador asume el rol de arquitecto: define el contrato y los casos de borde en una prueba. El agente asume el trabajo pesado de implementar la sintaxis.


    Ejemplo Práctico: Implementando una lógica de negocio paso a paso

    Paso 1: Escribe el test en fallo (Rojo con Vitest)

    Antes de crear el archivo de lógica, defines el comportamiento esperado:

    // src/pricing/discount-calculator.spec.ts
    import { describe, it, expect } from 'vitest';
    import { calculateTotalWithDiscounts } from './discount-calculator';
    
    describe('calculateTotalWithDiscounts', () => {
      it('aplica descuento por volumen del 10% en compras mayores a $100', () => {
        const total = calculateTotalWithDiscounts({ subtotal: 150, isVip: false, couponPercent: 0 });
        expect(total).toBe(135);
      });
    
      it('nunca devuelve un total negativo incluso con cupones acumulados', () => {
        const total = calculateTotalWithDiscounts({ subtotal: 50, isVip: true, couponPercent: 120 });
        expect(total).toBe(0); // Regla de negocio crítica
      });
    });
    

    Al ejecutar bun test, el test falla inmediatamente porque la función ni siquiera existe.

    Paso 2: Pasa el test al agente como contrato ejecutable

    Invocas a tu agente de IA en la terminal con una instrucción cerrada:

    claude "Lee discount-calculator.spec.ts. Crea el archivo discount-calculator.ts con la implementación mínima necesaria para que los tests pasen en verde. Prohibido modificar el archivo de tests."
    

    Paso 3: El agente genera el código para poner el test en verde

    El agente analiza la firma de tipos esperada y las aserciones, generando la lógica requerida:

    // src/pricing/discount-calculator.ts
    export interface PricingOptions {
      subtotal: number;
      isVip: boolean;
      couponPercent: number;
    }
    
    export function calculateTotalWithDiscounts(options: PricingOptions): number {
      const { subtotal, isVip, couponPercent } = options;
      
      let discount = 0;
      if (subtotal > 100) discount += subtotal * 0.10;
      if (isVip) discount += subtotal * 0.05;
      if (couponPercent > 0) discount += subtotal * (couponPercent / 100);
    
      const finalTotal = subtotal - discount;
      return Math.max(0, finalTotal); // Respeta el caso de borde
    }
    

    El agente ejecuta el test runner de forma autónoma y confirma que la suite está en verde.


    Por qué este flujo recorta los bugs que llegan a producción

    1. Elimina la alucinación de requisitos: El modelo no tiene margen para inventar parámetros porque el test ya definió la interfaz y los valores esperados.
    2. Aislamiento de contexto: No necesitas explicar la arquitectura completa de tu empresa; solo entregas el archivo de prueba.
    3. Refactorización sin miedo: Si mañana quieres optimizar el rendimiento del algoritmo, puedes pedirle a la IA que lo refactorice sabiendo que cualquier regresión encenderá una alarma roja de inmediato.

    Que quede claro: esto no lleva los bugs a cero. Ningún flujo lo hace. Lo que hace es mover el error de "se descubre en producción tres semanas después" a "se descubre en el segundo en que el agente ejecuta la suite". Los fallos que se te escapan siguen siendo los casos que no se te ocurrió escribir.

    Para que el ciclo funcione, la suite tiene que correr en milisegundos, no en minutos: aquí tienes cómo montar pruebas unitarias ultrarrápidas con Vitest. Si el agente tarda 90 segundos en saber si acertó, el bucle rojo-verde deja de ser un bucle.

    En el curso de Testing en Angular con Jest y Testing Library enseñamos a estructurar suites de pruebas profesionales para frontend y backend preparadas para integrarse con flujos automatizados de CI/CD.

    Este enfoque de validación es también uno de los pilares centrales de nuestro libro de Spec-Driven Development (SDD).

    Para descargar pipelines de automatización con Vitest y plantillas de pruebas para agentes, visita Dominicode Labs.


    Qué hacer hoy con esto

    Para la próxima función o endpoint que vayas a programar:

    1. No escribas la implementación.
    2. Escribe primero dos tests unitarios en Vitest: uno para el caso feliz y otro para el caso borde más peligroso.
    3. Pásaselo a tu asistente de IA y pídele que escriba la función que los cumpla.

    Comprobarás dos cosas: que el primer diff llega mucho más cerca de lo que querías, y que las rondas de corrección se reducen a una o dos.

    Si además quieres que el agente derive los tests de una especificación en vez de escribirlos tú a mano, ese es el siguiente escalón: TDD y Spec-First aplicados al desarrollo con IA.


    Preguntas frecuentes

    ¿Por qué TDD es especialmente útil al programar con IA?

    Porque un test unitario actúa como una especificación matemática ejecutable. Los modelos de lenguaje responden con muchísima mayor precisión cuando tienen un criterio binario de éxito (el test pasa o falla) que cuando reciben instrucciones en lenguaje natural ambiguo.

    ¿Se debe permitir que la IA modifique los tests unitarios?

    No. Los tests unitarios deben ser diseñados y aprobados por el desarrollador. Si permites que la IA modifique los tests para que "pasen en verde", corres el riesgo de que relaje las aserciones y oculte errores de negocio.

    ¿Qué framework de tests es más rápido para iterar con agentes de IA?

    Vitest es actualmente la opción más recomendada en el ecosistema TypeScript por su velocidad de arranque instantánea, compatibilidad nativa con ESM y excelente integración en terminales CLI.

    ¿Puede la IA escribir también los tests en lugar del desarrollador?

    Puede escribir el andamiaje y los casos evidentes, pero no debe decidir qué se protege. Si el modelo escribe los tests y la implementación, ambos comparten el mismo malentendido y la suite en verde no demuestra nada. El desarrollador define los casos de borde; la IA rellena el resto.

    ¿Cuántos tests hacen falta antes de pasarle la tarea al agente?

    Dos suelen bastar para arrancar: el caso feliz y el caso de borde más caro si falla. Con eso el agente ya tiene una interfaz cerrada y un criterio binario de éxito. Ampliar la cobertura tiene más sentido después, cuando ya sabes por dónde se rompe la implementación real.


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

  • 5 errores fatales al refactorizar código legacy con IA

    5 errores fatales al refactorizar código legacy con IA

    Hace unos meses me contrataron para modernizar un módulo de facturación escrito en 2019.

    Eran cerca de 3.000 líneas de TypeScript sin tipar, callbacks anidados y lógica de negocio repartida entre controladores y servicios. Pensé: "Le paso esto a un modelo de lenguaje moderno y en 10 minutos lo tengo convertido a funciones puras y tipadas".

    Refactorizar código legacy con IA parecía trivial. Le pedí al modelo que reescribiera el archivo y el resultado parecía una obra de arte: código limpio, nombres elegantes y cero warnings en el editor.

    Desplegamos en staging. A las dos horas saltó la primera alerta: los clientes con direcciones fiscales internacionales no podían facturar. El modelo había considerado que una comprobación con == null de un campo antiguo era "código redundante" y la había borrado, rompiendo seis años de retrocompatibilidad silenciosa.

    Si vas a meter agentes de IA en proyectos legacy, aquí tienes los 5 errores fatales que no puedes permitirte.


    Error 1: Alucinación de versiones y APIs incompatibles

    Los modelos de IA fueron entrenados con millones de repositorios que mezclan código de 2020 con código de 2026.

    Cuando le pides a una IA que modifique un proyecto antiguo de Node.js o Angular:

    • Asume que puedes usar métodos modernos de JavaScript (Array.prototype.toSorted(), Object.groupBy()) en entornos que corren en runtimes sin soporte.
    • Intenta importar métodos de librerías modernas (como rxjs/operators reubicados o versiones incompatibles de Axios).
    // ❌ Código sugerido por IA para un proyecto en Node 18
    const groupedOrders = Object.groupBy(orders, (item) => item.status);
    // En runtime: TypeError: Object.groupBy is not a function
    

    Cómo evitarlo: Especifica siempre el target exacto en tus prompts y configuraciones: "Target Node 18 LTS, ECMAScript 2022. Prohibido usar APIs de ECMAScript 2024+".

    El mecanismo de fondo lo desgrané en por qué la IA se inventa cosas: el modelo no miente, completa el patrón más probable. Y en un repo de 2019, el patrón más probable es el de 2026.


    Error 2: Pérdida silenciosa de contratos y el peligro del any encubierto

    El código legacy suele tener tipos implícitos o estructuras heterogéneas. Cuando la IA intenta "limpiar" esos tipos, con frecuencia toma atajos peligrosos:

    // Antes: código legacy feo, pero con un caso borde que lleva años en producción
    function parseUser(data: Record<string, unknown>) {
      return data.legacy_id ?? data.id;
    }
    
    // ❌ Refactor 'limpio' de la IA que destruye ese caso borde
    interface User { id: string; }
    function parseUser(data: User): User {
      return { id: data.id }; // Se perdió el soporte de legacy_id
    }
    

    La IA optimiza para la legibilidad del código presente, no para la historia oculta de los bugs pasados.


    Error 3: Refactorizar sin Tests de Caracterización previos

    El error más destructivo es pedirle a la IA que reescriba código antes de tener una red de seguridad.

    Si el código no tiene tests, no puedes refactorizar con IA. Punto.

    El protocolo correcto exige crear primero Characterization Tests (Tests de Caja Negra):

    // test/billing.characterization.spec.ts
    import { calculateInvoice } from '../src/legacy/billing';
    
    describe('Billing Legacy Characterization Tests', () => {
      it('preserva el comportamiento exacto para clientes extranjeros', () => {
        const input = { amount: 100, country: 'DE', taxExempt: true };
        const result = calculateInvoice(input);
        expect(result).toMatchSnapshot(); // Congela el comportamiento real antes de tocar nada
      });
    });
    

    Si la suite tarda minutos en correr, nadie la ejecutará antes de cada refactor. Aquí tienes cómo dejar los tests unitarios en milisegundos con Vitest: con IA de por medio, la velocidad del test runner deja de ser comodidad y pasa a ser el límite de tu ciclo de trabajo.

    En el curso de Testing en Angular con Jest y Testing Library dedicamos un módulo completo a blindar código histórico mediante tests de regresión antes de aplicar cualquier modernización.


    Error 4: Saturación y degradación de la ventana de contexto

    En repositorios con cientos de archivos interconectados, pasarle al agente archivos gigantes (1.000+ líneas) provoca pérdida de atención (lost in the middle).

    El agente empieza a ignorar imports cruciales o inventa interfaces auxiliares en lugar de reutilizar las del proyecto.

    Regla de oro: No pidas "refactoriza el módulo de pagos". Pide "extrae el cálculo de impuestos de este archivo a una función pura aislada y valida que el test adjunto siga en verde".


    Error 5: Aceptar Diffs extensos sin revisión granular

    Aceptar un diff de 400 líneas generado por IA sin revisarlo línea a línea es una negligencia profesional.

    ┌─────────────────────────────────────────────────────────────┐
    │                 PROTOCOLO DE REFACTOR CON IA                │
    │                                                             │
    │  1. Test de Caracterización (Fija el comportamiento)       │
    │  2. Spec Técnica (Define lo que se puede y no se puede tocar)│
    │  3. Refactorización atómica (Menos de 80 líneas por paso)  │
    │  4. Verificación de Test Runner en verde                   │
    └─────────────────────────────────────────────────────────────┘
    

    Este es exactamente el enfoque que explicamos en el libro de Spec-Driven Development (SDD): tratar las modificaciones de código como contratos medibles con límites inquebrantables.


    Qué hacer hoy con esto

    Si tienes que tocar un módulo legacy esta semana:

    1. No abras la IA todavía.
    2. Escribe tres tests que cubran los casos de uso principales y los casos de borde más raros que conozcas.
    3. Ejecuta los tests y asegúrate de que pasan.
    4. Solo entonces, entrega el código y los tests a tu agente de IA con la instrucción explícita de no romper la suite.

    Para acceder a checklists de refactorización segura y scripts de validación automática para proyectos empresariales, únete a Dominicode Labs.


    Preguntas frecuentes

    ¿Por qué la IA rompe código legacy que antes funcionaba?

    Porque los modelos de lenguaje intentan simplificar lo que parece "código redundante" sin entender los parches históricos o edge cases que ese código resolvía en producción.

    ¿Qué es un Characterization Test y por qué es indispensable?

    Es una prueba automatizada que captura el comportamiento actual del sistema (con sus virtudes y sus defectos) para garantizar que una refactorización no altere inadvertidamente el resultado final.

    ¿Cómo evitar que la IA use versiones incompatibles de librerías?

    Configurando un archivo de contexto claro (como CLAUDE.md o reglas de proyecto) donde se especifique la versión exacta de Node.js, TypeScript y el target ECMAScript soportado.

    ¿Cuánto código conviene pasarle a la IA en cada refactorización?

    Menos de lo que crees. Por debajo de 80 líneas por paso el diff se revisa entero en un vistazo y cualquier regresión se localiza de inmediato. Con diffs de 300 o 400 líneas nadie revisa de verdad: se aprueba por cansancio.

    ¿Se puede refactorizar código legacy con IA sin tests de ningún tipo?

    No de forma responsable. Si no hay tests, el primer trabajo del agente no es refactorizar sino generar tests de caracterización que congelen el comportamiento actual. Solo cuando esa red está en verde tiene sentido tocar la implementación.


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

  • Migrar de RxJS a Angular Signals: patrones de refactorización

    Migrar de RxJS a Angular Signals: patrones de refactorización

    En 2022 audité un componente de catálogo en Angular.

    Tenía 350 líneas de TypeScript. Doce BehaviorSubject, siete combineLatest, cuatro operadores switchMap anidados y tres pipes async duplicados en la plantilla HTML.

    El equipo se quejaba de dos problemas: primero, la aplicación se ralentizaba cada vez que el usuario tecleaba en el buscador por la sobrecarga de Zone.js. Segundo, cada tres semanas aparecía un bug de sincronización porque alguien olvidaba desuscribirse de un stream y causaba un memory leak.

    El problema no era RxJS, y migrar de RxJS a Angular Signals tampoco significa borrarlo del package.json. RxJS es una librería excelente para flujos asíncronos y eventos complejos. El error fue usarlo como gestor de estado síncrono en la interfaz.

    Migrar bien consiste en devolverle a cada herramienta el trabajo que sabe hacer: Signals para el estado síncrono, RxJS para los flujos asíncronos de verdad.

    Aquí tienes la guía paso a paso, patrón por patrón. Si quieres el contexto de cómo hemos llegado hasta aquí, lo conté en de callbacks a Signals: la reactividad real del frontend.


    Tabla de Equivalencias: De RxJS a Signals

    RxJS (Antiguo para Estado)           Angular Signals (Moderno)
    ─────────────────────────           ─────────────────────────
    BehaviorSubject<T>(value)     ───>  signal<T>(value)
    Observable derivado (map)     ───>  computed(() => ...)
    combineLatest([a$, b$])       ───>  computed(() => a() + b())
    Subscription manual / tap     ───>  effect(() => ...)
    Observable HTTP               ───>  rxResource() / httpResource()
    

    Patrón 1: De BehaviorSubject a signal()

    En lugar de crear un subject privado y exponer un observable público:

    // ❌ Antes (RxJS tradicional)
    @Injectable({ providedIn: 'root' })
    export class CartServiceOld {
      private readonly _items$ = new BehaviorSubject<CartItem[]>([]);
      readonly items$ = this._items$.asObservable();
    
      addItem(item: CartItem): void {
        const current = this._items$.getValue();
        this._items$.next([...current, item]);
      }
    }
    

    La versión moderna con Signals reduce la fricción a una sola línea declarativa:

    // ✅ Ahora (Angular Signals)
    @Injectable({ providedIn: 'root' })
    export class CartService {
      readonly items = signal<CartItem[]>([]);
    
      addItem(item: CartItem): void {
        this.items.update(current => [...current, item]);
      }
    }
    

    Sin necesidad de pipes async, sin desuscripciones en ngOnDestroy y con lectura síncrona inmediata mediante this.items().


    Patrón 2: De combineLatest a computed()

    Calcular valores derivados con RxJS requería combinar flujos y recordar filtrar valores nulos iniciales:

    // ❌ Antes (RxJS)
    readonly totalPrice$ = this.items$.pipe(
      map(items => items.reduce((acc, item) => acc + item.price * item.quantity, 0))
    );
    

    Con Signals, computed() es memoizado por defecto y solo se recalcula cuando sus dependencias cambian:

    // ✅ Ahora (Signals)
    readonly totalPrice = computed(() =>
      this.items().reduce((acc, item) => acc + item.price * item.quantity, 0)
    );
    

    Patrón 3: Efectos colaterales con effect() sin caer en bucles

    Usa effect() únicamente para logging, sincronización con APIs externas del navegador (como localStorage o Canvas) o analytics.

    El peligro común: Modificar un signal dentro de un effect(). Esto genera bucles reactivos infinitos.

    // ⚠️ Si necesitas leer un signal sin suscribirte a sus cambios, usa untracked:
    effect(() => {
      const currentItems = this.items();
      // Leemos el userId sin que este effect se vuelva a disparar si el usuario cambia
      const userId = untracked(() => this.authService.userId());
      
      analytics.track('Cart Updated', { userId, count: currentItems.length });
    });
    

    Patrón 4: Conexión Asíncrona con rxResource y toSignal

    Para llamadas HTTP y servicios asíncronos que devuelven observables, la interoperabilidad es directa:

    import { Component, inject, signal } from '@angular/core';
    import { rxResource } from '@angular/core/rxjs-interop';
    import { ProductService } from './product.service';
    
    @Component({
      selector: 'app-product-list',
      template: `
        @if (productsResource.isLoading()) {
          <p>Cargando productos...</p>
        } @else if (productsResource.error()) {
          <p class="error">Error al cargar datos</p>
        } @else {
          <ul>
            @for (product of productsResource.value(); track product.id) {
              <li>{{ product.name }} — {{ product.price | currency }}</li>
            }
          </ul>
        }
      `
    })
    export class ProductListComponent {
      private readonly productService = inject(ProductService);
    
      readonly categoryId = signal<string | null>(null);
    
      // rxResource gestiona automáticamente estado de carga, valor y error.
      // Ojo con la firma: la clave es `stream` (no `loader`) y devuelve un Observable.
      readonly productsResource = rxResource({
        params: () => ({ category: this.categoryId() }),
        stream: ({ params }) => this.productService.getProducts$(params.category)
      });
    }
    

    Si tu servicio se limita a hacer un GET y devolver el JSON, ni siquiera necesitas rxResource: httpResource() hace el mismo trabajo con la mitad de código. Deja rxResource para cuando necesites operadores de RxJS dentro del loader.

    Este es el estándar que enseñamos en profundidad en el curso de Angular Moderno, donde construimos aplicaciones completas sin Zone.js (Zoneless) preparadas para producción.

    Para arquitecturas de estado avanzadas con Signal Stores y patrones de persistencia, en Dominicode Labs publicamos repositorios con ejemplos listos para clonar.

    También puedes seguir tutoriales en vídeo sobre Signals en el Canal de YouTube de Dominicode.


    Qué hacer hoy con esto

    Abre tu proyecto de Angular e identifica un componente que tenga al menos dos BehaviorSubject para controlar filtros de búsqueda o modales.

    Refactorízalo a signal() y computed(). Elimina los pipes async del HTML.

    Verás cómo el archivo pierde un 40% de líneas de código y el comportamiento del componente se vuelve completamente predecible en milisegundos.


    Preguntas frecuentes

    ¿Signals reemplaza a RxJS por completo en Angular?

    No. Signals reemplaza a RxJS en la gestión del estado y la reactividad síncrona en la UI. RxJS sigue siendo la herramienta ideal para flujos asíncronos complejos, cancelaciones HTTP (switchMap), debounce de inputs de teclado y websockets.

    ¿Qué ventaja tiene rxResource frente a usar toSignal() con HttpClient?

    rxResource ofrece un manejo integral del ciclo de vida asíncrono, exponiendo automáticamente señales para el estado de carga (isLoading()), el valor obtenido (value()) y los posibles errores (error()).

    ¿Por qué está desaconsejado cambiar señales dentro de un effect()?

    Porque desencadena cascadas de re-renderizado impredecibles y bucles infinitos. Los efectos deben utilizarse exclusivamente para sincronizar con sistemas externos (side effects), no para derivar estado interno.

    ¿Se puede migrar a Signals de forma gradual o hay que reescribir la aplicación entera?

    De forma gradual, servicio a servicio. toSignal() y toObservable() permiten que el código nuevo con Signals y el existente con Observables convivan en el mismo componente, así que puedes migrar un feature por sprint sin bloquear al resto del equipo.

    ¿Qué pasa con el pipe async al migrar a Signals?

    Desaparece. Un signal se lee directamente en la plantilla con items(), sin suscripción ni desuscripción, así que en la migración el async se elimina junto con el ngOnDestroy que existía solo para cerrar streams.


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

  • Optimización extrema de rendimiento y consumo de memoria en Next.js 16

    Optimización extrema de rendimiento y consumo de memoria en Next.js 16

    Hace unos meses recibí una llamada de emergencia de un equipo que acababa de desplegar su aplicación de comercio electrónico construida sobre Next.js. El servidor Node.js en producción colapsaba cada 4 horas con el temible mensaje FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory.

    Su solución temporal era programar un reinicio automático del contenedor Docker cada 3 horas. Un parche espantoso para disimular un problema de arquitectura grave.

    El equipo culpaba a Node.js y a los servidores de Vercel. Pero al auditar el perfil de memoria, descubrimos que los desarrolladores estaban reteniendo objetos gigantescos en la caché de Server Components y desbordando la memoria durante la hidratación de datos.

    Next.js 16 introduce avances masivos en la gestión de memoria y compilación, pero si no entiendes cómo funciona su motor bajo el capó, es ridículamente fácil introducir memory leaks en producción.

    El espejismo de los Server Components sin estado

    Existe el mito de que los React Server Components (RSC) son inmunes a las fugas de memoria porque se ejecutan en el servidor y solo envían HTML/JSON al cliente.

    La realidad es que en el servidor, cada petición HTTP mantiene en memoria el árbol de renderizado del componente hasta que se completa la respuesta. Si dentro de un Server Component:

    • Suscribes escuchadores de eventos globales que no se destruyen.
    • Almacenas buffers de imágenes o respuestas API masivas en variables fuera de la función del componente.
    • Abres conexiones de base de datos dentro del render sin un pool reutilizable.

    Estás acumulando megabytes de basura retenida en la memoria Heap de Node.js en cada petición de usuario.

    Como ya explicamos en nuestro análisis detallado sobre la reducción de memoria en builds de Next.js, separar la memoria del compilador de la memoria en tiempo de ejecución es el primer paso para diagnosticar estos fallos.

    3 Estrategias para Optimizar Next.js 16 en Producción

    1. Gestión Inteligente de Caché de Datos (unstable_cache & PPR)

    En Next.js 16, la caché de peticiones debe configurarse explícitamente utilizando etiquetas de revalidación (revalidateTag) en lugar de almacenar respuestas masivas en memoria global:

    import { unstable_cache } from 'next/cache';
    
    export const getProductoDestacado = unstable_cache(
      async (id: string) => {
        // Consulta limpia a la base de datos
        return await db.producto.findUnique({ where: { id } });
      },
      ['producto-destacado-key'],
      {
        revalidate: 3600, // Revalida cada hora en segundo plano
        tags: ['productos']
      }
    );
    

    2. Configurar Límites de Memoria en Turbopack y Node.js

    Para evitar que el proceso de build agote la RAM de tu servidor de integración continua (CI/CD) o contenedor de producción, configura los flags de memoria de forma estricta en tu package.json:

    {
      "scripts": {
        "dev": "next dev --turbo",
        "build": "NODE_OPTIONS='--max-old-space-size=4096' next build"
      }
    }
    

    3. Evitar el "Waterfall" en Renderizado Asíncrono

    Uno de los fallos de rendimiento más comunes en Server Components es ejecutar peticiones await secuenciales cuando podrían resolverse en paralelo:

    // ❌ MAL: Peticiones en cascada (waterfall), triplica el tiempo de respuesta y retención en memoria
    const usuario = await getUsuario(id);
    const pedidos = await getPedidos(id);
    const metricas = await getMetricas(id);
    
    // ✅ BIEN: Ejecución en paralelo con Promise.all
    const [usuario, pedidos, metricas] = await Promise.all([
      getUsuario(id),
      getPedidos(id),
      getMetricas(id)
    ]);
    

    Al aplicar programación defensiva en TypeScript, garantizas que cualquier fallo dentro de Promise.all sea capturado sin dejar promesas colgadas en el event loop.

    Monitoreo y Diagnóstico de Memoria

    Para auditar el consumo real de tu aplicación en desarrollo o staging:

    1. Ejecuta el servidor con el inspector habilitado: node --inspect node_modules/.bin/next start.
    2. Abre Chrome DevTools (chrome://inspect) y toma una instantánea del Heap (Heap Snapshot).
    3. Filtra por clases retenidas (Closure, System / Context) para identificar qué Server Components no están siendo liberados por el recolector de basura (Garbage Collector).

    Como destacamos en nuestras guías de graph engineering, mapear las dependencias entre módulos es la forma más limpia de aislar fugas de memoria.


    Optimizar el rendimiento en Next.js 16 no requiere magia; requiere disciplina en la gestión de datos asíncronos y una configuración adecuada de los límites de memoria.

    Si quieres dominar el desarrollo fullstack moderno con Next.js y arquitecturas de alto rendimiento, descubre los Cursos de Dominicode. Y si buscas resolver desafíos complejos de producción en comunidad con otros desarrolladores senior, te esperamos en Dominicode Labs.

    Preguntas frecuentes

    ¿Por qué mi build de Next.js se queda congelado consumiendo 100% de CPU?

    Suele deberse a la importación masiva de módulos con dependencias circulares o al procesamiento de imágenes gigantescas durante la generación estática (SSG). Limitar el número de páginas pre-renderizadas en build mediante generateStaticParams dinámico soluciona el problema.

    ¿Qué diferencia hay entre revalidatePath y revalidateTag?

    revalidatePath purga toda la caché asociada a una URL específica. revalidateTag es mucho más eficiente porque purga de forma quirúrgica solo los datos que comparten una etiqueta concreta en todo el proyecto, sin invalidar otras secciones de la página.

    ¿Cómo afecta el uso de middleware al rendimiento en Next.js?

    El Middleware se ejecuta en el Edge Runtime antes de cada petición. Si realizas llamadas pesadas a APIs o consultas directas a bases de datos dentro del middleware, añadirás latencia a todas las rutas de tu aplicación. Mantén el middleware ultraligero (solo para redirecciones y lectura de headers/cookies).

    ¿Es recomendable usar next/image para todas las imágenes?

    Sí. El componente next/image optimiza automáticamente el formato (WebP/AVIF), ajusta las dimensiones según la pantalla del cliente y evita desplazamientos de diseño (Cumulative Layout Shift – CLS), reduciendo drásticamente la carga de memoria en el navegador.


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

  • Construyendo sitios web ultrarrápidos con Astro y Server Islands: Cero JS por defecto

    Construyendo sitios web ultrarrápidos con Astro y Server Islands: Cero JS por defecto

    Hace unos meses analicé la landing page de un cliente que ofrecía un producto SaaS. Habían construido la web utilizando un marco de trabajo de aplicación de página única (SPA) completo.

    Para renderizar un titular estático, una lista de precios y tres testimonios de clientes, el navegador del usuario tenía que descargar, descompilar y ejecutar 480 KB de JavaScript. En conexiones móviles 4G, el tiempo hasta que la página se volvía interactiva (Time to Interactive) superaba los 5.5 segundos. El resultado en Google Lighthouse era un doloroso 44/100.

    Perdían el 30% de los visitantes antes de que la página terminara de cargar.

    Al refactorizar el sitio hacia Astro y aprovechar la nueva funcionalidad de Server Islands, redujimos el bundle de JavaScript cliente para la estructura estática a 0 KB, logrando una puntuación de 100/100 en Core Web Vitals en el primer intento.

    La paradoja de enviar JavaScript para renderizar HTML

    Durante la última década, la industria del desarrollo web cometió un error colectivo: asumir que cualquier sitio web moderno debía empaquetarse dentro de una aplicación de React o Angular que se ejecuta íntegramente en el navegador del usuario.

    El resultado ha sido la degradación del rendimiento web:

    • El navegador descarga megabytes de JavaScript para crear nodos de DOM que podrían haber sido enviados directamente como HTML estático.
    • La CPU del dispositivo móvil se satura ejecutando hidratación de estado.
    • Los motores de búsqueda e intenciones de búsqueda sufren retardos de indexación.

    Astro invirtió este modelo con su filosofía "Zero JavaScript by default" (Cero JavaScript por defecto). Astro renderiza todo el componente a HTML estático en el servidor y solo envía JavaScript al cliente si especificas explícitamente una isla interactiva (Islands Architecture).

    ¿Qué son las Server Islands en Astro?

    La arquitectura de islas tradicional permitía incrustar componentes interactivos cliente (React, Vue, Svelte) dentro de una página estática usando directivas como client:load o client:visible.

    Sin embargo, las Server Islands introducen un avance superior: permiten posponer la renderización de un componente dinámico de servidor sin bloquear la carga estática inicial de la página.

    ┌─────────────────────────────────────────────────────────┐
    │ HTML Estático enviado de inmediato (TTFB ultra bajo)     │
    │ ┌─────────────────────────────────────────────────────┐ │
    │ │ Hero Section + Menú + Testimonios (HTML Puro)       │ │
    │ └─────────────────────────────────────────────────────┘ │
    │ ┌─────────────────────────────────────────────────────┐ │
    │ │ <AvatarUsuario server:defer /> (Server Island)      │ │
    │ │  └─► Renderiza fallback estático instantáneo        │ │
    │ │  └─► Se sustituye en segundo plano por HTML del srv  │ │
    │ └─────────────────────────────────────────────────────┘ │
    └─────────────────────────────────────────────────────────┘
    

    Ejemplo de uso de Server Island en Astro

    Imagina un blog de alta velocidad donde la mayor parte del contenido es estático, pero deseas mostrar el avatar personalizado del usuario autenticado en la barra superior.

    ---
    // src/pages/posts/[slug].astro
    import Layout from '../layouts/Layout.astro';
    import AvatarUsuario from '../components/AvatarUsuario.astro';
    import ContenidoPost from '../components/ContenidoPost.astro';
    
    const { slug } = Astro.params;
    ---
    
    <Layout title="Post de Blog Ultrarrápido">
      <header style="display: flex; justify-content: space-between;">
        <Logo />
        <!-- La Server Island no bloquea la carga de la página estática -->
        <AvatarUsuario server:defer>
          <!-- Fallback mientras el servidor procesa la sesión -->
          <div slot="fallback" class="avatar-skeleton"></div>
        </AvatarUsuario>
      </header>
    
      <main>
        <ContenidoPost slug={slug} />
      </main>
    </Layout>
    

    Al cargar la página:

    1. El servidor entrega HTML puro súper rápido (la estructura completa del artículo y la plantilla).
    2. El cliente ve la página cargada de forma instantánea con el skeleton del avatar.
    3. Astro ejecuta en segundo plano el componente <AvatarUsuario /> en el servidor y reemplaza el fallback con el HTML dinámico parseado sin necesidad de descargar una pesada librería cliente.

    Como vimos al comparar el consumo en tiempo de compilación con Next.js y Turbopack, utilizar la arquitectura correcta para cada tipo de proyecto es la decisión de rendimiento más rentable.

    Tipado Defensivo y Colecciones de Contenido

    Astro integra Content Collections, un sistema basado en Zod que valida en tiempo de compilación que todos tus archivos Markdown o MDX cumplan exactamente con la estructura de tipos definida.

    // src/content/config.ts
    import { defineCollection, z } from 'astro:content';
    
    const postsCollection = defineCollection({
      type: 'content',
      schema: z.object({
        title: z.string(),
        description: z.string().max(160),
        pubDate: z.date(),
        author: z.string().default('Bezael Pérez'),
        tags: z.array(z.string()),
      }),
    });
    
    export const collections = { posts: postsCollection };
    

    Al aplicar programación defensiva en TypeScript, garantizas que ningún artículo con metadatos defectuosos rompa la generación estática de tu sitio web.

    Además, mantener aisladas las dependencias de tus componentes siguiendo principios de graph engineering permite reutilizar componentes de React o Vue dentro de Astro de manera impecable.


    Astro y sus Server Islands representan la convergencia perfecta entre la velocidad extrema del HTML estático y la flexibilidad de la web dinámica moderna.

    Si quieres dominar el desarrollo web moderno, optimización de rendimiento y arquitectura frontend, explora los Cursos de Dominicode. Y si buscas construir sitios web y productos de alto impacto junto a desarrolladores senior, súmate a Dominicode Labs.

    Preguntas frecuentes

    ¿En qué se diferencia una Server Island de un Server Component de React?

    Los Server Components de React requieren que toda la aplicación comparta el modelo de hidratación y empaquetado de React. Las Server Islands de Astro son agnósticas al framework: puedes usar componentes en Astro puro, React, Vue, Svelte o Solid, y se reemplazan de forma asíncrona mediante un fragmento de HTML ligero sin cargar el runtime del framework si no es necesario.

    ¿Puedo seguir usando componentes interactivos de React en Astro?

    Sí. Puedes importar cualquier componente de React, Vue o Svelte en Astro. Para habilitar la interactividad cliente en un componente específico, solo añades la directiva de hidratación correspondiente, como client:visible (se hidrata solo cuando el usuario hace scroll hasta él) o client:idle (se hidrata cuando el navegador está inactivo).

    ¿Server Islands requiere una plataforma de despliegue en servidor (SSR)?

    Para que las Server Islands funcionen procesando peticiones dinámicas en segundo plano, tu proyecto Astro debe desplegarse con un adaptador SSR (Server-Side Rendering) en plataformas como Vercel, Netlify, Cloudflare Workers o un contenedor Docker con Node.js/Bun.

    ¿Astro es adecuado para aplicaciones web complejas con paneles de administración?

    Astro es imbatible para sitios web centrados en contenido, blogs, e-commerce, documentación y landing pages. Para paneles de administración interactivos con estado denso en cliente (dashboards complejos), combinar Astro para las páginas públicas con un framework como Next.js, Angular o React para el panel privado es una excelente estrategia de arquitectura.


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

  • Gestión de estado global sin dolor combinando Zod y Signals en aplicaciones modernas

    Gestión de estado global sin dolor combinando Zod y Signals en aplicaciones modernas

    Hace un par de años audité una aplicación enterprise en React y TypeScript que utilizaba Redux Toolkit. Para gestionar el estado de 6 pantallas principales, el equipo había tenido que escribir más de 3.500 líneas de código entre actions, reducers, selectors y Middlewares de Thunk.

    Lo grave no era la cantidad de archivos. Lo grave era que cuando el backend cambiaba un campo opcional de la API sin avisar, el store de Redux aceptaba el objeto corrupto y la aplicación explotaba páginas más tarde con el temible Cannot read properties of undefined.

    Habían creado un sistema complejo que no ofrecía ninguna protección real en tiempo de ejecución.

    La combinación de Zod (validación de esquemas) y Signals (reactividad de grano fino) se ha convertido en el estándar moderno para eliminar el dolor de la gestión de estado global en aplicaciones frontend.

    El problema de las librerías de estado tradicionales

    Durante años creímos que para gestionar el estado de una aplicación web necesitábamos un contenedor monolítico global con patrones de inmutabilidad estrictos.

    Ese enfoque sufría tres defectos estructurales:

    1. Verbosidad extrema: Escribir decenas de funciones de selección y mutación para actualizar una simple propiedad de usuario.
    2. Re-renderizados innecesarios: Si un componente escuchaba un objeto de estado global grande, cualquier cambio menor provocaba el re-renderizado del árbol de UI completo.
    3. Ceguera en la frontera API: Asumir que la respuesta del backend coincide al 100% con los tipos de TypeScript sin validar los datos entrantes.

    Como destacamos en nuestro artículo sobre programación defensiva en TypeScript, las interfaces de TypeScript desaparecen al transpilar, por lo que confiar solo en tipos en tiempo de compilación es una trampa.

    La Arquitectura Zod + Signals

    La solución moderna consiste en aplicar la validación de esquemas en la frontera de entrada (HTTP) y gestionar la reactividad atómica mediante Signals (disponibles de forma nativa en Angular, Preact, SolidJS o mediante librerías ultraligeras como @preact/signals en React).

    ┌─────────────────────────────────────────────────────────┐
    │ Respuesta API HTTP (JSON sin confiar)                   │
    │  └─► Validacion en tiempo de ejecucion con Zod Schema   │
    │     ┌───────────────────────────────────────────────────┐
    │     │ Estado Reactivo Atómico (Signals)                │
    │     │  └─► signal(), computed()                         │
    │     └───────────────────────────────────────────────────┘
    │  ┌──────────────────────────────────────────────────────┐
    │  │ Componentes de UI (Actualización Quirúrgica)         │
    │  └──────────────────────────────────────────────────────┘
    └─────────────────────────────────────────────────────────┘
    

    1. Definición del Esquema Zod y Tipado Automático

    import { z } from 'zod';
    
    // 1. Esquema con validación estricta en tiempo de ejecución
    export const UserStateSchema = z.object({
      id: z.string().uuid(),
      email: z.string().email(),
      nombre: z.string().min(2),
      rol: z.enum(['ADMIN', 'USER', 'GUEST']),
      preferencias: z.object({
        tema: z.enum(['light', 'dark']).default('dark'),
      }),
    });
    
    // Inferir el tipo de TypeScript automáticamente
    export type UserState = z.infer<typeof UserStateSchema>;
    

    2. Store Reactivo basado en Signals

    import { signal, computed } from '@preact/signals-react';
    import { UserStateSchema, UserState } from './user.schema';
    
    // State atómico inicial
    export const usuarioSignal = signal<UserState | null>(null);
    export const estaAutenticadoSignal = computed(() => usuarioSignal.value !== null);
    export const esAdminSignal = computed(() => usuarioSignal.value?.rol === 'ADMIN');
    
    // Acción de actualización con validación Zod defensiva
    export function setUsuarioConValidacion(rawData: unknown) {
      const parseResult = UserStateSchema.safeParse(rawData);
    
      if (!parseResult.success) {
        console.error('Payload de API inválido:', parseResult.error.format());
        // Se evita corromper el estado global con datos inválidos
        return false;
      }
    
      // Se asigna únicamente si la validación es 100% exitosa
      usuarioSignal.value = parseResult.data;
      return true;
    }
    

    Beneficios en Aplicaciones de Producción

    1. Re-renderizados quirúrgicos: Al consumir esAdminSignal en un botón de administración, solo ese botón se re-evalúa cuando el rol cambia. El resto de la UI permanece intacta sin necesidad de memoizaciones manuales (useMemo, React.memo).
    2. Cero corrupción de estado: Si la API devuelve un campo mal formateado, Zod detiene la propagación en la frontera HTTP antes de que afecte a la reactividad de la aplicación.
    3. Escalabilidad de código: Eliminas más del 70% del boilerplate de Redux/MobX, creando un código limpio que tanto los desarrolladores como los asistentes de IA pueden refactorizar sin riesgo.

    Al estructurar los módulos de estado siguiendo los principios de graph engineering, consigues una separación clara entre la lógica de datos y los componentes de presentación.

    Y si estás desarrollando en Angular, ten en cuenta el constante ciclo de releases de Angular donde los Signals y los Signal Forms se han integrado como el estándar nativo del framework.


    Simplificar la gestión de estado combinando la solidez de Zod con la velocidad de los Signals permite construir interfaces mantenibles, reactivas y blindadas ante fallos de producción.

    Si quieres dominar el desarrollo frontend moderno y las mejores prácticas de arquitectura con TypeScript, explora los Cursos de Dominicode. Y si quieres construir aplicaciones reales junto a otros desarrolladores senior, te esperamos en Dominicode Labs.

    Preguntas frecuentes

    ¿Puedo usar Zod con otras librerías de estado como Zustand o Pinia?

    Sí. Zod es una librería de validación agnóstica al framework. Puedes usar ZodSchema.parse() dentro de las acciones de Zustand, Pinia, Redux o cualquier otra librería para validar los datos antes de guardarlos en el store.

    ¿Qué diferencia hay entre la reactividad de Signals y los Observables de RxJS?

    Los Signals están optimizados para la reactividad síncrona de UI con evaluación perezosa y seguimiento automático de dependencias. RxJS está diseñado para la coordinación de eventos asíncronos en el tiempo (peticiones HTTP, WebSockets, timers). En aplicaciones modernas, se usan Signals para el estado del componente y RxJS para streams asíncronos.

    ¿Zod añade demasiado peso al bundle del cliente?

    No. Zod es una librería ultraligera (menos de 12 KB gzippeado) y soporta tree-shaking, por lo que solo se empaquetan en el cliente los métodos y validadores que utilices explícitamente en tu código.

    ¿Cómo persiste el estado basado en Signals entre recargas de página?

    Puedes crear un efecto reactivo que sincronice automáticamente el valor del Signal con localStorage o sessionStorage cada vez que el Signal cambia, parseando los datos con Zod al restaurar la sesión.


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