Category: Arquitectura de Software

  • Ipsum: el tema por defecto de WordPress 7.2 que quizá no verás

    Ipsum: el tema por defecto de WordPress 7.2 que quizá no verás

    El miércoles 16 WordPress anunció su nuevo tema por defecto. Se llama Ipsum y es bonito de verdad.

    Lo leí, me gustó, y entonces hice una cosa tonta: entré al panel de administración de mi propio WordPress para ver qué tema tenía activo.

    Tardé un rato en acordarme de por dónde se entraba.

    Cuando conseguí entrar, el tema activo resultó ser uno que instalé hace no sé cuánto y que jamás ha renderizado una sola página para un lector. El blog que estás leyendo lo sirve un frontend en Next.js desplegado en Vercel. WordPress está detrás, haciendo de base de datos con un editor decente. El tema, ahí dentro, es la decoración de una habitación sin ventanas.

    Y ahí está lo incómodo. El tema por defecto es el escaparate del proyecto WordPress, lo primero que ve alguien el día que instala. Para una parte cada vez más grande de quien usa WordPress hoy, es código muerto.

    En corto: Ipsum es el tema de bloques propuesto como tema por defecto de WordPress 7.2, y rompe 16 años de nomenclatura "Twenty X" — a partir de ahora los temas por defecto tienen nombre propio y cambian cuando el diseño lo pida, no cuando toque por calendario. Si renderizas tu sitio con WordPress, te afecta y deberías probarlo antes de la Beta 1 de 7.2 —del 20 al 22 de octubre—, que es cuando el equipo deja de aceptar cualquier cosa que no sea corrección de bugs. Si usas WordPress headless como API de contenido, Ipsum no cambia nada en tu web.

    ¿Qué es Ipsum, el nuevo tema de WordPress?

    Ipsum es un tema de bloques minimalista —un lienzo en blanco construido alrededor de la experiencia de blogging— propuesto como tema por defecto de WordPress 7.2.

    Lo anunció Henrique Iamarino en Make WordPress Core el 16 de septiembre de 2026. Él firma el diseño; el desarrollo lo lideran Carolina Nymark, Maggie Cabrera y Juanfra Aldasoro.

    La apuesta técnica es la que cabía esperar en 2026: bloques, theme.json y Global Styles haciendo el trabajo, con el mínimo CSS propio posible. Todas las combinaciones de estilos pasan WCAG AA, según el anuncio.

    El nombre viene de lorem ipsum, el texto de relleno que ocupa la página hasta que llega el contenido real. Es una declaración de intenciones bastante honesta: esto no es el diseño, es lo que hay antes de que tú pongas el diseño.

    Dato Valor Fuente
    Anuncio 16 de septiembre de 2026 Make WordPress Core
    Versión objetivo WordPress 7.2 Anuncio oficial
    Beta 1 de 7.2 (desde aquí, solo bugs) 20-22 de octubre de 2026 Calendario de la release 7.2
    RC1 (congelado de textos) 17-19 de noviembre de 2026 Calendario de la release 7.2
    Salida de 7.2 8-10 de diciembre de 2026 (fecha objetivo; el roadmap avisa de que sus fechas son orientativas) Roadmap de wordpress.org
    Ventana real para que tu feedback cambie el tema ~5 semanas 34 días del 16 de septiembre al 20 de octubre
    Issues abiertos en el repo (19 sep 2026) 17 github.com/WordPress/ipsum
    Pull requests abiertos (19 sep 2026) 19 github.com/WordPress/ipsum
    Requisitos WordPress 7.1+ · PHP 7.4+ README del repo
    Licencia GPLv2+ (fuentes con SIL OFL) README del repo

    Puedes probarlo en WordPress Playground sin instalar nada, ojear el sitio de demo o bajarte el ZIP del repo a wp-content/themes/ipsum.

    Piden feedback ya, y se entiende: del anuncio a la Beta 1 hay cinco semanas para cerrar los 17 issues y 19 pull requests que seguían abiertos el 19 de septiembre en el tema que va a ser la cara del proyecto.

    El final de "Twenty X" es la noticia, no el tema

    Desde Twenty Ten en 2010, WordPress ha publicado un tema por defecto con el año en el nombre. Dieciséis años. Twenty Eleven, Twenty Twelve, y así hasta Twenty Twenty-Five, que es el que Ipsum viene a sustituir.

    Por indicación de Matt Mullenweg, eso se acaba. Los temas por defecto pasan a tener nombre propio y a cambiar cuando el diseño lo pida, no cuando lo pida el calendario.

    Me parece mejor decisión de lo que aparenta. Un tema por año era una promesa de puntualidad que ni siquiera se cumplía: nunca hubo Twenty Eighteen, y 2026 se ha quedado sin tema del año. Y a cambio le ponía fecha de caducidad en el nombre a un tema que alguien iba a tener seis años en producción. Nadie quiere explicarle a un cliente por qué su web corre "Twenty Twenty-Two" en 2026.

    De paso, el cambio se lleva por delante a Mētis, el tema en el que el equipo trabajaba antes —orientado a writers, makers and thinkers— y que Ipsum desplaza como propuesta por defecto. Mētis no se cancela: saldrá por su cuenta cuando esté listo.

    Por qué el tema por defecto solo importa si renderizas con WordPress

    Un tema de WordPress es la capa de renderizado: convierte contenido en HTML. Ipsum hace eso, y lo hace bien.

    Dónde ocurre esa conversión —en el servidor de WordPress, en el build de tu frontend o en el navegador del lector— es la decisión de arquitectura de siempre: CSR, SSR, SSG o ISR. Y es esa decisión, no el tema, la que determina si Ipsum te importa.

    Si tu WordPress sirve las páginas que ven tus lectores, Ipsum es relevante para ti de forma inmediata: es el punto de partida de tu diseño, el conjunto de patterns que vas a heredar y el theme.json que va a definir tu paleta y tu escala tipográfica.

    Si tu WordPress solo expone /wp-json/wp/v2/posts y el HTML lo pinta otra cosa, Ipsum es un directorio en tu servidor cuyas plantillas no van a pintar ni una página para un lector. Ese JSON, en cambio, sí trabaja: es lo que pinta la web, y también lo que uso para que un MCP pueda buscar dentro del blog.

    No es una hipótesis: es mi caso exacto. Llevo desde enero sirviendo este blog con un frontend propio, y el tema de WordPress no ha renderizado nada de cara al público en todo ese tiempo. Si mañana borro ese directorio, lo único que se rompe es la vista previa del editor.

    Y eso es lo que hace interesante la noticia más allá del tema: WordPress está invirtiendo su identidad de marca —el escaparate, la primera impresión, el objeto sobre el que Mullenweg da instrucciones personales— en la capa de renderizado, justo cuando una parte creciente de su base lo usa como API de contenido y nada más.

    Criterio WordPress clásico (el tema renderiza) WordPress headless (solo API)
    Qué hace Ipsum por ti Es tu frontend: plantillas, patterns, estilos Nada visible: no renderiza ninguna página pública (aunque WordPress sí carga el tema en cada petición REST)
    Dónde vive el diseño theme.json + Global Styles En tu repo de frontend
    Quién puede cambiarlo Cualquiera con acceso al editor Solo quien hace deploy
    Qué te aporta WordPress 7.2 Tema nuevo, patterns, mejoras del editor Cambios en la REST API y en el HTML de bloques que consumes
    Tiempo hasta tener algo en pie Minutos Días, y después mantenimiento continuo
    Límite / riesgo Estás atado al ciclo de releases y a PHP: una actualización de core, del tema o de un plugin te toca el render en producción Reconstruyes tú lo que WordPress daba gratis —previews, sitemaps, schema, búsqueda, formularios— y el editor deja de enseñar cómo se ve de verdad el post

    Si quieres el cómo en detalle, ya escribí la guía completa para hacer WordPress headless con Next.js. Este post no va de eso. Va de qué significa que el proyecto siga poniendo su mejor esfuerzo de marca en una capa que muchos hemos apagado.

    "¿Y por qué WordPress sigue necesitando un tema?"

    No soy el único que lo piensa, y esto no es un "mucha gente dice". Está escrito, con nombre y fecha, en los comentarios del propio anuncio.

    El mismo día de la publicación, a las 11:17, Xilonz lo preguntó directo:

    "I'm curious why WordPress still needs a (default) theme? Cant we just create a decent onboarding instead?"

    Su argumento: el tema por defecto impone estructura —cabecera, pie— cuando el editor de sitio completo ya permite construirlo todo desde cero, y la libertad creativa real empieza en blanco.

    annezazu le respondió esa misma tarde, a las 17:22, y su respuesta es la mejor defensa que he leído del tema por defecto:

    "Core can't provide onboarding that properly covers all use cases…"

    Añadió que parte de la intención de Ipsum es justo esa: dar unos valores por defecto muy simples para que cada uno lo haga suyo, pero partiendo de un punto sólido.

    Dave Whitley entró al día siguiente pidiendo también una opción de empezar en blanco, pero concediendo de inmediato la objeción práctica: hacerlo es "very intimidating for most users, and it takes a lot of time to start from scratch". Y remató con la frase que resume el dilema entero: "Themes show people what is possible".

    Los tres tienen razón, y es porque hablan de usuarios distintos. Para quien instala WordPress hoy y quiere publicar esta tarde, Ipsum es imprescindible. Para quien lo usa como cabecera de un pipeline de contenido, sobra.

    Y por si alguien piensa que un tema por defecto es solo diseño: en el repo hay debates de ingeniería de verdad.

    troychaplin abrió el issue #41 preguntando si el PHP del tema debería pasar a una estructura de clases, con el dato encima de la mesa como argumento para esperar —133 líneas en functions.php frente a las 159 de Twenty Twenty-Five— y la duda de qué pasa después: "if the theme gains more bindings, template types or block styles, one flat file gets harder to scan".

    bueltge abrió el issue #43 proponiendo que los nombres de los temas por defecto sigan una convención simbólica —Commons, Agora, Atrium— igual que las releases de WordPress homenajean a músicos de jazz.

    Eso es un proyecto vivo. No es una nota de prensa.

    Qué mirar de WordPress 7.2 si vas headless

    Si el tema no te afecta, te afecta todo lo demás. Tres cosas, y ninguna tiene que ver con Ipsum.

    El HTML serializado de los bloques. Lo que consumes en content.rendered es el output del block parser. Cuando core cambia el marcado o las clases utilitarias de un bloque, ese cambio viaja hasta tu JSON, y ahí es donde se rompe tu CSS.

    Los cambios de la REST API. Campos nuevos, campos deprecados, cambios en cómo se devuelven taxonomías o metadatos. Un campo que cambia de forma en silencio es peor que uno que desaparece.

    Lo que tu frontend replica a mano. El canonical, el schema, el sitemap: todo lo que WordPress hacía por ti vive ahora en tu código y no se actualiza con wp-admin. Cuando core mejora algo en esa zona, tú no te enteras. Lo aprendí por las bravas con un canonical mal generado que estuvo semanas apuntando a donde no debía.

    Nada de esto es exclusivo de WordPress. Es el peaje de todo el ecosistema JavaScript en 2026: ganas control y te llevas a casa el mantenimiento entero.

    Cuándo NO irte a headless

    Escribí la guía de headless y sigo pensando que para este blog fue la decisión correcta. También creo que se recomienda demasiado alegremente. Estos son los límites reales, los que me he comido yo.

    Si no eres tú quien mantiene el frontend, no lo hagas. Un WordPress clásico lo toca cualquiera con acceso al editor. Un frontend en Next.js lo toca quien sabe hacer deploy. Si le montas esto a un cliente sin equipo técnico, no le has dado una web moderna: le has dado una dependencia de una sola persona, y esa persona eres tú para siempre.

    Pierdes el WYSIWYG y duele más de lo que crees. El editor de WordPress te enseña cómo va a quedar el post. En headless te enseña cómo quedaría si usaras el tema, que no es el caso. Cada bloque nuevo que usas es una apuesta a que tu frontend sabe renderizarlo. He publicado posts con bloques que en mi frontend salían sin estilos y no me enteré hasta verlo en producción.

    Duplicas la superficie operativa. Dos deploys, dos sitios donde mirar logs, dos cachés que invalidar, dos facturas. Para un blog de una docena de posts al año, eso no lo compensa ninguna mejora de Lighthouse.

    Y la que menos se dice: si tu problema es que la web va lenta, headless no es la solución más barata. Caché de página, un hosting decente y menos plugins te llevan al 90% del resultado con el 5% del trabajo. Vete a headless cuando quieras el control del frontend, no cuando quieras velocidad.

    Si el control es justo lo que buscas y lo que te frena es montar el frontend, hoy esa parte se acelera muchísimo con IA — siempre que trabajes con especificaciones y no a base de prompts sueltos. Es el método que enseño en Construye con IA: de la idea al producto sin que el proyecto se te convierta en un pantano.

    Qué hacer hoy

    Abre WordPress Playground, activa Ipsum y dale diez minutos.

    No para opinar sobre el tema. Para contestarte una pregunta que casi nadie se hace en frío: ¿la capa de renderizado de mi WordPress me importa, o hace tiempo que dejó de importarme?

    Si te importa, tienes hasta la Beta 1 del 20 de octubre para que tu feedback entre en un tema que vas a mirar durante años. Después de esa fecha solo entran correcciones de bugs. Es de las pocas veces en que un usuario normal influye en algo que van a usar millones de instalaciones.

    Y ten claro qué estás probando. Lo que hay publicado hoy es el trabajo de diseño: la revisión formal de desarrollo viene después. Mientras Ipsum esté en desarrollo la versión del tema se queda clavada en 1.0.0 y los cambios se registran en las descripciones de los pull requests, no en un changelog. Traducido: si lo instalas en un sitio real no vas a poder distinguir una build de otra por el número de versión, así que pruébalo en Playground o en staging, nunca en producción.

    Y si al abrirlo sientes exactamente lo que sentí yo —"qué bonito, y qué poco tiene que ver conmigo"—, ya tienes tu respuesta. Ese es el momento de mirar la arquitectura de tu sitio con honestidad, no el momento de instalarte un tema.

    En Dominicode Labs desmenuzamos este tipo de decisiones de arquitectura sobre proyectos reales, sin quedarnos en el "depende". Y si prefieres verlo antes que leerlo, lo voy contando en el canal.

    Preguntas frecuentes

    ¿Qué es Ipsum en WordPress?

    Ipsum es un tema de bloques minimalista propuesto como tema por defecto de WordPress 7.2. Está construido sobre theme.json y Global Styles con el mínimo CSS posible, todas sus combinaciones de estilos pasan WCAG AA y se presenta como un lienzo en blanco centrado en la experiencia de blogging. Lo anunció Henrique Iamarino en Make WordPress Core el 16 de septiembre de 2026.

    ¿Por qué WordPress deja de llamar "Twenty X" a sus temas por defecto?

    Por indicación de Matt Mullenweg. Los temas por defecto pasan a tener nombre propio y a renovarse cuando el diseño lo pida, no cuando llegue el cambio de año. El modelo anterior forzaba una entrega anual aunque el tema vigente siguiera siendo válido, dejaba en producción temas con el año fosilizado en el nombre y además ya estaba roto en la práctica: nunca existió Twenty Eighteen y 2026 se ha quedado sin tema del año.

    ¿Cuándo sale WordPress 7.2?

    El roadmap oficial de WordPress sitúa la versión 7.2 el 10 de diciembre de 2026, y el calendario de la release marca la ventana de lanzamiento del 8 al 10 de diciembre. Antes hay dos hitos que importan más si vas a dar feedback sobre Ipsum: Beta 1 el 20-22 de octubre, a partir del cual el equipo solo corrige bugs, y la Release Candidate 1 el 17-19 de noviembre, cuando se congelan los textos.

    ¿Al actualizar a WordPress 7.2 me va a cambiar el tema a Ipsum?

    No. WordPress no cambia el tema activo de un sitio que ya existe: Ipsum llegará a tu instalación como tema disponible pero inactivo, igual que llegaron en su día los Twenty X. El tema por defecto solo se activa en instalaciones nuevas. Si quieres probarlo en un sitio en producción tienes que activarlo tú, y conviene hacerlo antes en staging o en WordPress Playground.

    ¿Ipsum afecta a mi sitio si uso WordPress headless?

    Casi nada, pero no exactamente cero. Las plantillas del tema solo entran cuando WordPress renderiza páginas, así que con un frontend propio Ipsum no pinta nada de lo que ve tu lector. Lo que sí sigue vivo es su theme.json: WordPress carga el tema activo también en las peticiones REST, y sus ajustes de layout acaban en las clases y los estilos inline que recibes dentro de content.rendered. Eso, y los cambios de la REST API de 7.2, es lo único que te afecta.

    ¿Qué pasa con Mētis, el tema anterior?

    Mētis era el tema en el que el equipo trabajaba antes, orientado a escritores y creadores. Ipsum lo desplaza como propuesta de tema por defecto, pero no se cancela: se publicará por su cuenta cuando esté terminado.

    ¿Cómo puedo probar Ipsum antes de que salga WordPress 7.2?

    La vía más rápida es WordPress Playground, que abre una instalación temporal en el navegador sin instalar nada. La otra es descargar el ZIP del repositorio WordPress/ipsum y descomprimirlo en wp-content/themes/ipsum. Necesitas WordPress 7.1 o superior y PHP 7.4 o superior. En ambos casos, pruébalo fuera de producción: mientras el tema esté en desarrollo su versión no se mueve de 1.0.0.


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

  • Sincronizar el read model en CQRS: outbox, idempotencia y rebuild

    Sincronizar el read model en CQRS: outbox, idempotencia y rebuild

    Un lunes por la mañana, soporte abrió un ticket con la frase que más miedo da de todas: «el cliente dice que pagó y su pedido sigue saliendo como pendiente».

    Miramos la tabla de pedidos. Pagado. Miramos la vista que consume el frontend. Pendiente. Llevaba once días así.

    Nadie se había enterado porque el sistema no estaba roto. Todos los endpoints devolvían 200. Los logs estaban limpios. Simplemente, el read model se había quedado atrás y no existía ninguna alarma que mirase esa diferencia.

    Ese es el problema real de CQRS: cómo sincronizar el read model con el write model sin que se pudra. No aparece el día que lo eliges; aparece tres meses después. Si todavía estás decidiendo si el patrón te conviene, ese es otro debate — qué es CQRS y cuándo compensa aplicarlo. Este post empieza el día siguiente.

    Y la tesis, por delante: no pierdes eventos por culpa del bus. Los pierdes en el hueco que hay entre tu COMMIT y tu publish.


    Por qué el read model se desincroniza: el problema del dual write

    El read model se desincroniza porque el cambio de estado y la publicación del evento son dos operaciones contra dos sistemas distintos, sin transacción común. Si la segunda falla, no queda nadie para reintentarla.

    Este código lo he visto en producción más veces de las que me gustaría:

    await db.query(`update orders set status = 'paid' where id = $1`, [orderId])
    await bus.publish('order.paid', { orderId })
    

    Dos líneas. Parecen una unidad. No lo son.

    Entre la primera y la segunda cabe todo: un timeout del broker, un deploy que mata el pod, el OOM killer, un ECONNRESET. Si la segunda línea falla, la base de datos de escritura dice "pagado" y el read model no se entera nunca. No hay reintento que te salve, porque el proceso que tenía que reintentar ya no existe.

    Invertir el orden es peor. Si publicas primero y la transacción hace rollback después, has emitido un evento sobre algo que no ocurrió. El read model muestra un pedido pagado que en la fuente de verdad sigue pendiente. Ese bug se tarda semanas en encontrar.

    Esto tiene nombre: dual write. Escribir en dos sistemas que no comparten transacción. No se arregla con try/catch, ni con reintentos en el catch, ni metiendo el publish dentro de la transacción —porque la red no hace rollback.

    La solución académica es 2PC. La que usa la gente que tiene que dormir por las noches es otra.


    Transactional outbox: sincronizar el read model en una sola transacción

    El transactional outbox es un patrón que elimina el dual write escribiendo el evento en una tabla de la misma base de datos, dentro de la misma transacción que el cambio de estado; un proceso aparte —el relay— lee esa tabla y lo publica en el bus. Está catalogado así en el catálogo de patrones de microservicios de Chris Richardson.

    Y la idea es tonta de simple: si no puedes hacer atómicas dos operaciones contra dos sistemas, haz que las dos vayan contra el mismo sistema.

    El evento no se publica. Se inserta en una tabla de la misma base de datos, dentro de la misma transacción que el cambio de estado. Si el COMMIT pasa, el evento existe. Si no pasa, tampoco. Atomicidad gratis, la que ya te da Postgres.

    create table outbox (
      id             bigserial   primary key,
      aggregate_id   uuid        not null,
      aggregate_type text        not null,
      event_type     text        not null,
      version        int         not null,
      payload        jsonb       not null,
      occurred_at    timestamptz not null default now(),
      published_at   timestamptz
    );
    
    -- índice parcial: solo lo pendiente, que es lo que el relay consulta cada tick
    create index outbox_pending_idx on outbox (id) where published_at is null;
    

    Y el comando queda así:

    import { Pool } from 'pg'
    
    const pool = new Pool({ connectionString: process.env.DATABASE_URL })
    
    export async function markOrderAsPaid(orderId: string, total: number) {
      const client = await pool.connect()
    
      try {
        await client.query('begin')
    
        const { rows } = await client.query<{ version: number }>(
          `update orders
              set status = 'paid', paid_at = now(), version = version + 1
            where id = $1 and status = 'pending'
            returning version`,
          [orderId],
        )
    
        if (rows.length === 0) throw new Error('ORDER_NOT_PENDING')
    
        await client.query(
          `insert into outbox (aggregate_id, aggregate_type, event_type, version, payload)
           values ($1, 'order', 'order.paid', $2, $3)`,
          [orderId, rows[0].version, { orderId, total, paidAt: new Date().toISOString() }],
        )
    
        await client.query('commit')
      } catch (error) {
        await client.query('rollback')
        throw error
      } finally {
        client.release()
      }
    }
    

    Fíjate en el returning version. Ese número lo vas a necesitar dentro de dos secciones y es lo que separa un read model correcto de uno que a veces acierta.

    El relay

    Un proceso aparte lee la tabla y publica. Nada más. La clave está en el for update skip locked —disponible desde Postgres 9.5—: te deja correr varias instancias del relay sin que dos cojan la misma fila.

    export async function relayTick(bus: Bus, batchSize = 100) {
      const client = await pool.connect()
    
      try {
        await client.query('begin')
    
        const { rows } = await client.query<OutboxRow>(
          `select id, aggregate_id, event_type, version, payload, occurred_at
             from outbox
            where published_at is null
            order by id
            limit $1
              for update skip locked`,
          [batchSize],
        )
    
        for (const row of rows) {
          await bus.publish({
            id: String(row.id),
            type: row.event_type,
            aggregateId: row.aggregate_id,
            version: row.version,
            payload: row.payload,
            occurredAt: row.occurred_at,
          })
        }
    
        if (rows.length > 0) {
          await client.query(
            `update outbox set published_at = now() where id = any($1::bigint[])`,
            [rows.map((r) => r.id)],
          )
        }
    
        await client.query('commit')
      } catch (error) {
        await client.query('rollback')
        throw error
      } finally {
        client.release()
      }
    }
    

    Léelo otra vez y busca el agujero, porque lo tiene: si bus.publish va bien y el commit del update ... published_at falla, el evento sale publicado dos veces.

    Eso es intencionado. El outbox te garantiza at-least-once, nunca exactly-once. Y está bien. Preferimos un evento duplicado que un evento perdido, porque el duplicado se resuelve en el consumidor y la pérdida no se resuelve en ningún sitio.

    El relay es, además, donde el bus te va a fallar de verdad. Con el broker caído, reintentar en bucle cerrado solo empeora las cosas: aplica el mismo razonamiento que expliqué sobre circuit breakers y clasificación de fallos. Cortar, esperar, dejar que el outbox acumule. Para eso está la tabla: el relay puede pasarse diez minutos parado y no se pierde ni un evento.


    Idempotencia en el proyector: procesar dos veces sin duplicar

    Si el bus entrega al menos una vez, el proyector tiene que poder comerse el mismo evento dos veces y terminar en el mismo estado. Punto.

    Dos piezas: una tabla que registra qué eventos ya se procesaron y un upsert que no dependa del orden de llegada.

    create table processed_events (
      projection   text        not null,
      event_id     bigint      not null,
      processed_at timestamptz not null default now(),
      primary key (projection, event_id)
    );
    
    create table projection_checkpoint (
      projection    text        primary key,
      last_event_id bigint      not null default 0,
      updated_at    timestamptz not null default now()
    );
    
    create table orders_read (
      order_id uuid        primary key,
      status   text        not null,
      total    numeric     not null,
      paid_at  timestamptz,
      version  int         not null
    );
    

    El primary key de order_id no es decorativo: sin esa restricción única, el on conflict (order_id) del proyector ni siquiera llega a ejecutarse.

    Antes de tocar nada, valida el payload. El evento viaja como JSON opaco y puede llevar meses en la tabla: el día que alguien cambie su forma en el productor, tu proyector recibirá algo que no espera. Parsea siempre con un schema —aquí, Zod 4— y manda a dead letter lo que no cumpla, en vez de dejar que un undefined acabe escrito en la vista.

    import { z } from 'zod'
    
    const OrderPaid = z.object({
      orderId: z.uuid(),
      total: z.number().nonnegative(),
      paidAt: z.iso.datetime(),
    })
    
    export async function projectOrderPaid(event: DomainEvent) {
      const parsed = OrderPaid.safeParse(event.payload)
      if (!parsed.success) {
        await deadLetter(event, parsed.error)
        return
      }
    
      const client = await pool.connect()
    
      try {
        await client.query('begin')
    
        // 1. Reclamar el evento. Si ya estaba, no hacemos nada más.
        const claim = await client.query(
          `insert into processed_events (projection, event_id)
           values ('orders_read', $1)
           on conflict do nothing`,
          [event.id],
        )
    
        if (claim.rowCount === 0) {
          await client.query('rollback')
          return
        }
    
        // 2. Upsert con guarda de versión.
        await client.query(
          `insert into orders_read (order_id, status, total, paid_at, version)
           values ($1, 'paid', $2, $3, $4)
           on conflict (order_id) do update
              set status  = excluded.status,
                  total   = excluded.total,
                  paid_at = excluded.paid_at,
                  version = excluded.version
            where orders_read.version < excluded.version`,
          [parsed.data.orderId, parsed.data.total, parsed.data.paidAt, event.version],
        )
    
        // 3. Avanzar el checkpoint.
        await client.query(
          `update projection_checkpoint
              set last_event_id = greatest(last_event_id, $1), updated_at = now()
            where projection = 'orders_read'`,
          [event.id],
        )
    
        await client.query('commit')
      } catch (error) {
        await client.query('rollback')
        throw error
      } finally {
        client.release()
      }
    }
    

    Lo importante: los tres pasos van en la misma transacción. Si el proceso muere entre el paso 1 y el 2, el rollback deshace la reclamación y el evento se vuelve a entregar. Sin transacción, esa tabla de deduplicación no te protege, te miente.

    Este parseo de eventos es, por cierto, uno de los sitios donde Zod paga solo: el mismo schema te da el tipo de TypeScript, la validación en runtime y el mensaje de error que vas a leer en el dead letter a las tres de la mañana.

    Si quieres exprimir esa parte —discriminated unions por event_type, transformaciones, versionado de schemas— lo trabajo a fondo en el curso de Zod para TypeScript.


    Orden y versiones: cuando el v3 llega antes que el v2

    Ningún bus te garantiza el orden global. Con particiones, reintentos y varios consumidores en paralelo, el evento v3 de un pedido puede llegar antes que el v2. Es normal, no es un bug del broker.

    La cláusula que ya has visto arriba resuelve el caso:

    where orders_read.version < excluded.version
    

    Si llega el v3 y lo aplicas, cuando aparezca el v2 el WHERE da falso y el update no ocurre. El evento viejo se descarta en silencio, que es exactamente lo que quieres: tu read model no retrocede jamás.

    Ahora la letra pequeña, que es donde se rompe la gente: esto solo funciona si tus eventos llevan el estado completo. Si order.paid dice "el total es 120 y el estado es paid", aplicar el v3 y tirar el v2 deja la vista correcta. Si tus eventos son deltas —"suma 3 al stock", "descuenta 20 del saldo"— descartar el v2 te deja con un número mal para siempre.

    Con deltas necesitas detectar huecos y esperar. Cambias la guarda por una igualdad estricta:

    -- solo aplico si soy exactamente el siguiente
    where orders_read.version = excluded.version - 1
    

    Y si rowCount === 0 y la versión del evento es mayor que la actual más uno, lanzas para que el bus te lo vuelva a entregar más tarde, cuando el que falta ya haya pasado.

    Cambia también el SET: con deltas ya no copias excluded, acumulas — set total = orders_read.total + excluded.total. Y ojo al caso borde, que es el que muerde: esa guarda solo se evalúa en la rama DO UPDATE. Si la fila todavía no existe, el INSERT entra con la versión que traiga y el hueco pasa sin que nadie lo vea. Con deltas, crea la fila en la versión 0 cuando das de alta el agregado.

    Mi recomendación después de sufrir las dos: haz los eventos state-carrying siempre que puedas. Pesan más en la cola y a cambio te ahorran toda la maquinaria de gaps, buffers y reentregas. Es el cambio de diseño más rentable de esta lista.


    Rebuild de proyecciones: el superpoder que nadie usa

    Aquí está la parte buena de CQRS, la que compensa todo lo anterior: si tu read model es una función pura de la secuencia de eventos, el read model es desechable. ¿Se corrompió por un bug del proyector? Lo tiras. ¿Quieres añadir una columna calculada a la vista? Lo tiras. ¿Cambias la forma entera de la proyección? Lo tiras.

    Con la condición que casi nadie cumple: no borres los eventos. Un outbox con delete from outbox where published_at is not null es un outbox que funciona y que te quita esta capacidad para siempre. Archiva a un event_log en vez de borrar. Es la diferencia entre una cola y un log.

    El patrón es proyección versionada, y son cuatro pasos:

    1. Creas orders_read_v2 con el esquema nuevo, vacía, y su propia fila en projection_checkpoint.
    2. Arrancas el proyector v2 en modo replay, leyendo el event_log desde el id 0. El v1 sigue vivo y sirviendo tráfico.
    3. Cuando el v2 alcanza al v1 y ambos consumen en tiempo real, comparas. Unos cuantos agregados a mano o un diff de checksums.
    4. Cambias el puntero.

    Ese cambio de puntero es lo único delicado. Si la capa de consulta lee a través de una vista, es una sentencia:

    begin;
      drop view orders_read_current;
      create view orders_read_current as select * from orders_read_v2;
    commit;
    

    Dentro de la transacción toma un ACCESS EXCLUSIVE sobre la vista: las consultas en vuelo esperan unos milisegundos y siguen. Y no, create or replace view no vale aquí: solo admite añadir columnas al final, no cambiar nombres, tipos ni orden —que es exactamente lo que cambia en un rebuild.

    Si no tienes vista, usa un flag de configuración que lea la capa de consulta al construir la query: más código, pero te deja volver atrás sin desplegar.

    Con un rebuild fiable, tocar el read model deja de dar miedo. Ya no migras datos con un ALTER TABLE a las dos de la mañana: construyes una tabla nueva en paralelo, con tráfico real, y decides con datos si la enciendes.

    Un apunte de método: el evento order.paid es una API pública aunque no tenga endpoint. Quién lo emite, qué campos garantiza y cómo se versiona tiene que estar escrito antes de picar el proyector.

    Es de lo que más insisto en el libro de Spec-Driven Development, y en sistemas de eventos se nota el doble: el coste de equivocarte no lo pagas en el deploy, lo pagas seis meses después, cuando ya hay cuatro consumidores.


    Medir el lag del read model: el único aviso temprano que vas a tener

    En CQRS la consistencia eventual no es un fallo, es el contrato: el read model siempre va algo por detrás del write model. El fallo es no saber cuánto.

    Todo lo anterior puede estar bien implementado y aun así tu read model puede ir veinte minutos por detrás porque el relay se quedó colgado. No se lanza ninguna excepción. No hay error 500. Todo está "verde".

    Solo hay una métrica que te avisa: la antigüedad del evento pendiente más viejo.

    select coalesce(
      extract(epoch from now() - min(o.occurred_at)),
      0
    ) as lag_seconds
    from outbox o
    left join processed_events p
           on p.projection = 'orders_read'
          and p.event_id   = o.id
    where p.event_id is null;
    

    Devuelve 0 cuando no hay nada pendiente y crece cuando algo se atasca. Exponla como gauge y ponle alerta.

    No la escribas contra last_event_id del checkpoint. Como el checkpoint avanza con greatest(), es una marca de agua alta: el v2 que se fue al dead letter queda por debajo de ella, la query no lo ve y te devuelve 0 con la proyección rota. Que es, literalmente, el ticket de los once días. Si purgas processed_events, limita el anti-join a tu ventana de retención.

    Cuidado con la versión ingenua de esta métrica, que es la que suele estar puesta: now() - last_event_at del checkpoint. Esa te mide "cuánto hace que proyecté algo", y si a las tres de la madrugada no hay tráfico te va a despertar sin motivo. Peor: te acostumbra a ignorar la alarma. Mide lo que está esperando, no lo último que hiciste.

    Yo añado dos series más al dashboard:

    • Lag en eventos: cuántas filas del outbox siguen sin aparecer en processed_events. Te dice si el proyector está perdiendo la carrera. No lo calcules como max(id) − last_event_id: arrastra exactamente el mismo punto ciego de la marca de agua.
    • Tamaño del dead letter: si crece, hay eventos que no se están aplicando y el read model ya está mal.

    Con esas tres, aquel ticket de los once días se habría abierto en once minutos.


    Cuándo no necesitas absolutamente nada de esto

    Si tu "read model" es una réplica de lectura de la misma base de datos, no tienes este problema. Postgres replica por ti, la sincronía la resuelve el WAL, y tu única métrica es el lag de replicación —que ya viene dado por pg_last_xact_replay_timestamp() y pg_stat_replication.

    Nada de outbox, nada de proyectores, nada de checkpoints. Si separaste lectura y escritura solo para repartir carga, esa es la respuesta correcta y es aburridísima, que es justo lo que quieres en infraestructura.

    Lo mismo si tu vista denormalizada es una materialized view en la misma base y toleras refrescarla cada pocos minutos. REFRESH MATERIALIZED VIEW CONCURRENTLY resuelve más casos de los que la gente cree. Pide dos cosas: un índice UNIQUE sobre columnas —sin expresiones y sin WHERE— y que la vista ya esté poblada. A cambio refresca sin bloquear lecturas, aunque tarda bastante más que un refresh normal.

    Todo lo de este post empieza a hacer falta cuando el read model vive en otro sitio: otro motor, otro servicio, un índice de búsqueda, una tabla con una forma que no se deriva de un SELECT. Ahí sí tienes dual write y ahí sí necesitas el outbox.

    Dónde vive tu read model Cómo se sincroniza Qué tienes que operar Lag típico
    Réplica de lectura, misma base Replicación física (WAL) Nada, lo hace Postgres Milisegundos
    Materialized view, misma base REFRESH MATERIALIZED VIEW CONCURRENTLY Un cron Minutos
    Otra tabla, otro servicio, índice de búsqueda Transactional outbox + relay Tabla outbox, relay, checkpoints Segundos
    Igual que arriba, sin mantener relay CDC leyendo el WAL Kafka + Connect Segundos

    Y si estás en ese caso pero no quieres mantener la tabla ni el relay, mira CDC antes de escribir código: resuelve lo mismo leyendo el WAL, a cambio de infraestructura extra. Lo desarrollo en las preguntas de abajo.


    Qué hacer hoy

    Si ya tienes CQRS en producción y nada de esto está montado, no empieces por el outbox. Empieza por la métrica.

    Escribe la query del lag, ponla en un dashboard y déjala una semana. Vas a descubrir dos cosas: cuántos eventos estabas perdiendo sin saberlo, y si tu problema real era ese o era otro. Es media hora de trabajo, y es lo único de esta lista que te da información antes de que te la pida un cliente enfadado. El resto —outbox, idempotencia, versiones, rebuild— se construye después, con datos encima de la mesa.

    Si quieres ver este tipo de arquitecturas montadas de principio a fin, con el código completo y las decisiones discutidas, en Dominicode Labs es donde publico los proyectos largos que no caben en un post.


    Preguntas frecuentes

    ¿Por qué mi read model no se actualiza en CQRS?

    Casi siempre por dual write: el cambio de estado se guardó, pero el evento nunca llegó al bus porque publicar y hacer commit son dos operaciones distintas y la segunda falló sin que nadie reintentara.

    Los otros dos sospechosos habituales son el relay parado —el evento sigue en la tabla outbox con published_at a null— y un evento que el proyector rechaza una y otra vez hasta acabar en el dead letter. Los tres casos se distinguen en treinta segundos con la query de lag de este post: si devuelve un número alto, el evento existe y no se ha proyectado; si devuelve 0 y la vista sigue mal, mira el dead letter.

    ¿El transactional outbox añade latencia a cada escritura?

    Añade un INSERT dentro de una transacción que ya estaba abierta. En la práctica es ruido comparado con el resto del comando.

    La latencia que sí importa es la otra: cuánto tarda el evento en llegar al read model. Eso lo marca el intervalo de polling del relay, no el insert. Si necesitas bajarlo, usa LISTEN/NOTIFY de Postgres para despertar al relay en cuanto hay una fila nueva, en lugar de esperar al siguiente tick.

    ¿Puedo usar CDC en lugar de la tabla outbox?

    Sí, y resuelve el mismo problema de dual write. Debezium lee el WAL de Postgres y publica los cambios sin que tu código haga nada — trae incluso un outbox event router preparado exactamente para este patrón.

    La diferencia es qué publicas. Con outbox publicas eventos de dominio que tú diseñas; con CDC a secas publicas cambios de filas, y tus consumidores acaban acoplados al esquema de tu base de datos. El punto medio más usado es CDC leyendo precisamente la tabla outbox: eventos de dominio sin escribir relay, a cambio de operar Kafka y Connect.

    ¿Qué hago con un evento que el proyector nunca consigue procesar?

    Dead letter después de N intentos, y alerta. Lo que no puedes hacer es reintentarlo en bucle para siempre: bloqueas la partición y frenas todo lo que viene detrás.

    Ojo con la consecuencia que se pasa por alto: si mandas a dead letter el v2 de un agregado y sigues procesando el v3, esa fila queda incoherente hasta que reproceses. Por eso el dead letter va en el dashboard y no en un buzón que nadie abre.

    ¿Necesito event sourcing para poder reconstruir proyecciones?

    No. Necesitas retener los eventos, que es mucho menos que event sourcing.

    En event sourcing el log de eventos es la fuente de verdad y el estado se deriva de él. Aquí la fuente de verdad sigue siendo tu tabla orders, y el log de eventos es solo el historial de cambios publicados. Con archivar el outbox en un event_log en vez de borrarlo ya puedes reconstruir cualquier proyección.

    ¿Cada cuánto debería hacer polling del outbox?

    Depende del lag que tu producto tolere, no de lo que haga la industria. Un panel interno aguanta segundos; un contador que el usuario ve moverse tras pulsar un botón, no.

    Define el número primero —"el read model va como mucho X segundos por detrás"—, mídelo con la query de lag y ajusta el intervalo hasta cumplirlo. Sin ese número escrito, cualquier valor que pongas es una opinión.


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

  • Validación en NestJS: DTOs, class-validator y sus trampas

    Validación en NestJS: DTOs, class-validator y sus trampas

    El bug tardó once días en aparecer y cuatro minutos en explicarse.

    Una API NestJS, endpoint de registro. El controlador recibía el body, lo pasaba al servicio, el servicio lo pasaba al ORM. Limpio, corto, elegante. Alguien mandó un role: "admin" de más en el JSON y se creó una cuenta con permisos de administrador.

    Lo que más duele: el proyecto tenía validación en NestJS. Tenía DTOs con decoradores. Tenía el ValidationPipe registrado globalmente. Y aun así el campo pasó.

    Porque el pipe estaba puesto sin opciones. new ValidationPipe(), tal cual. Eso valida que los campos declarados cumplan sus reglas, pero no elimina los que no declaraste. Y en una API, lo que no declaras es exactamente lo que te van a mandar.

    La validación en NestJS bien montada no es una capa de seguridad más. Es el contrato entre el mundo exterior y tu aplicación. Cuando lo defines bien, un montón de código defensivo que hoy vive en tus servicios simplemente desaparece.


    El DTO en NestJS es un contrato, no un tipo

    Un DTO (Data Transfer Object) en NestJS es una clase que define la forma exacta de los datos que un endpoint acepta. No la forma que esperas: la que aceptas.

    // src/users/dto/create-user.dto.ts
    import {
      IsEmail,
      IsInt,
      IsOptional,
      IsString,
      MaxLength,
      Min,
      MinLength,
    } from 'class-validator';
    
    export class CreateUserDto {
      @IsEmail({}, { message: 'El email no tiene un formato válido' })
      email: string;
    
      @IsString()
      @MinLength(12, { message: 'La contraseña necesita al menos 12 caracteres' })
      password: string;
    
      @IsString()
      @MinLength(2)
      @MaxLength(60)
      fullName: string;
    
      @IsOptional()
      @IsInt()
      @Min(18)
      age?: number;
    }
    

    En el controlador no haces nada especial:

    @Post()
    create(@Body() dto: CreateUserDto) {
      return this.usersService.create(dto);
    }
    

    Y aquí viene la primera regla que no se negocia: CreateUserDto tiene que ser una class, nunca una interface.

    Las interfaces de TypeScript desaparecen al compilar. Nest lee el tipo del parámetro en runtime con reflect-metadata; si es una interface, el metatype que recibe es Object, no hay decoradores que leer, y el pipe deja pasar el payload entero sin decir nada. Es un fallo silencioso, que son los peores — y es el mismo problema de fondo que trato en cómo tipar correctamente una API REST en TypeScript: el tipo estático no te protege de lo que llega por el cable.


    ValidationPipe en NestJS: las opciones que sí importan

    El ValidationPipe de NestJS es el pipe integrado que intercepta el payload de una request, lo compara contra los decoradores del DTO usando class-validator y lanza un 400 Bad Request con la lista de errores si algo no cumple. Viene en @nestjs/common y no valida nada por sí solo: todo depende de las opciones con las que lo construyas.

    Registra el pipe globalmente y configúralo. Este es el bloque que uso en producción:

    // src/main.ts
    import { ValidationPipe } from '@nestjs/common';
    import { NestFactory } from '@nestjs/core';
    import { AppModule } from './app.module';
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
    
      app.useGlobalPipes(
        new ValidationPipe({
          whitelist: true,
          forbidNonWhitelisted: true,
          forbidUnknownValues: true,
          transform: true,
          transformOptions: { enableImplicitConversion: false },
          stopAtFirstError: false,
        }),
      );
    
      await app.listen(process.env.PORT ?? 3000);
    }
    bootstrap();
    

    Qué hace cada una, sin adornos.

    Opción Por defecto en Nest Recomendado Qué te juegas
    whitelist false true Sin ella, las propiedades que no declaras en el DTO llegan intactas al servicio
    forbidNonWhitelisted false true Sin ella los campos de más se borran en silencio en vez de devolver un 400
    forbidUnknownValues false (Nest lo fuerza) true Con false, un objeto sin metadatos de class-validator pasa la validación entera
    transform false true Sin ella recibes un objeto literal, no una instancia del DTO: sin métodos ni getters
    transformOptions.enableImplicitConversion false false Si lo pones a true, ?onlyActive=false activa el filtro, porque Boolean('false') es true
    stopAtFirstError false false Con true devuelves un error por request en vez de todos los del formulario de una vez

    whitelist: true elimina del objeto toda propiedad que no tenga al menos un decorador de validación. Este flag por sí solo habría evitado el bug de la historia. role no estaba en el DTO, así que se habría borrado antes de llegar al servicio.

    forbidNonWhitelisted: true sube la apuesta: en lugar de borrar en silencio, devuelve un 400 diciendo qué propiedad sobra. Lo prefiero, porque el silencio de whitelist a secas te oculta que el frontend lleva tres sprints mandando un campo muerto.

    transform: true convierte el JSON plano en una instancia real de la clase. Sin esto recibes un objeto literal con la forma correcta, no un CreateUserDto: si tu DTO tiene métodos o getters, no existen.

    stopAtFirstError viene en false por defecto en class-validator, y así lo dejo: quiero devolver todos los errores del formulario de una vez, no obligar al cliente a hacer seis viajes.

    Y luego está forbidUnknownValues, que merece su propia sección porque es la trampa gorda.


    La trampa: forbidUnknownValues no vale lo que crees

    La documentación de class-validator es tajante: forbidUnknownValues vale true por defecto y recomienda no tocarlo, porque desactivarlo hace que objetos desconocidos pasen la validación.

    Ahora mira el constructor del ValidationPipe de Nest:

    // packages/common/pipes/validation.pipe.ts
    this.validatorOptions = { forbidUnknownValues: false, ...validatorOptions };
    

    Nest lo pone a false salvo que tú lo pidas explícitamente. Es una decisión deliberada de compatibilidad hacia atrás (viene del issue 10683), no un despiste. Pero el efecto práctico es que la opción que class-validator considera crítica está desactivada por defecto en tu API de Nest.

    Muerde cuando el pipe valida un objeto del que class-validator no tiene metadatos: un DTO sin decoradores, uno que olvidaste importar bien, una clase generada dinámicamente. Con false eso pasa limpiamente. Con true falla y te enteras.

    Ponlo a true explícitamente y pasa tu suite después. Si algo se rompe, es que algo no se estaba validando.


    Objetos anidados: @ValidateNested sin @Type no valida nada

    @ValidateNested() solo valida un objeto anidado si va acompañado de @Type(() => Clase). Sin @Type, class-validator no sabe en qué clase instanciar el valor, no encuentra metadatos y deja pasar el objeto entero. Es la segunda trampa, y la he visto en más proyectos que la anterior.

    // src/orders/dto/create-order.dto.ts
    import { Type } from 'class-transformer';
    import {
      ArrayMinSize,
      IsArray,
      IsInt,
      IsNotEmpty,
      IsString,
      Matches,
      Min,
      ValidateNested,
    } from 'class-validator';
    
    export class AddressDto {
      @IsString()
      @IsNotEmpty()
      street: string;
    
      @IsString()
      @IsNotEmpty()
      city: string;
    
      @Matches(/^\d{5}$/, { message: 'El código postal debe tener 5 dígitos' })
      zipCode: string;
    }
    
    export class OrderItemDto {
      @IsString()
      @IsNotEmpty()
      sku: string;
    
      @IsInt()
      @Min(1)
      quantity: number;
    }
    
    export class CreateOrderDto {
      @IsString()
      @IsNotEmpty()
      customerId: string;
    
      @ValidateNested()
      @Type(() => AddressDto)
      shippingAddress: AddressDto;
    
      @IsArray()
      @ArrayMinSize(1)
      @ValidateNested({ each: true })
      @Type(() => OrderItemDto)
      items: OrderItemDto[];
    }
    

    @Type() viene de class-transformer, no de class-validator, y es la que le dice en qué clase instanciar el objeto anidado.

    Si la quitas, el anidado se queda como objeto plano, @ValidateNested() no encuentra metadatos asociados a ese valor y no comprueba nada. La request pasa y shippingAddress llega a tu servicio con lo que sea que mandaran.

    @ValidateNested sin @Type es decoración. Van siempre en pareja. Y en arrays, { each: true } es obligatorio o solo validas el array como un todo.


    Update sin duplicar el DTO

    No copies y pegues el DTO de create para poner todo opcional.

    // src/users/dto/update-user.dto.ts
    import { OmitType, PartialType } from '@nestjs/mapped-types';
    import { CreateUserDto } from './create-user.dto';
    
    export class UpdateUserDto extends PartialType(
      OmitType(CreateUserDto, ['email'] as const),
    ) {}
    

    PartialType hace todas las propiedades opcionales manteniendo sus reglas. OmitType quita las que no deben poder cambiarse. Tienes también PickType e IntersectionType.

    Aviso de la documentación oficial que cuesta caro ignorar: si usas @nestjs/swagger o @nestjs/graphql, importa los mapped types desde esos paquetes, no desde @nestjs/mapped-types. La doc oficial lo deja en "efectos secundarios varios y no documentados", sin concretar. En mi experiencia se manifiesta casi siempre como esquemas OpenAPI vacíos que nadie sabe explicar.


    Query params y la conversión implícita

    Los query params llegan siempre como string. Aquí es donde mucha gente activa enableImplicitConversion: true y se olvida del tema. Mala idea.

    // src/users/dto/find-users-query.dto.ts
    import { Transform, Type } from 'class-transformer';
    import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
    
    export class FindUsersQueryDto {
      @IsOptional()
      @Type(() => Number)
      @IsInt()
      @Min(1)
      page: number = 1;
    
      @IsOptional()
      @Type(() => Number)
      @IsInt()
      @Min(1)
      @Max(100)
      limit: number = 20;
    
      @IsOptional()
      @Transform(({ value }) => value === 'true' || value === true)
      @IsBoolean()
      onlyActive: boolean = false;
    
      @IsOptional()
      @IsString()
      search?: string;
    }
    

    onlyActive lo transformo a mano por una razón muy concreta.

    Cuando activas enableImplicitConversion, class-transformer usa el tipo reflejado por TypeScript y aplica el constructor correspondiente. Para booleanos, el código es literalmente return Boolean(value).

    Y Boolean('false') en JavaScript es true. Cualquier string no vacío lo es.

    Es decir: ?onlyActive=false te activa el filtro. Tu API hace lo contrario de lo que pide el cliente, devuelve un 200 y no aparece un solo error en los logs. Lo he depurado dos veces y las dos me llevó más de una hora.

    Deja enableImplicitConversion en false y sé explícito propiedad a propiedad con @Type() y @Transform(). Más verboso, y correcto.


    Validadores custom: cuando el decorador no existe

    Los decoradores integrados cubren tipo y formato. Las reglas de negocio no. Para eso escribes una clase que implementa ValidatorConstraintInterface.

    // src/users/validators/is-email-available.validator.ts
    import { Injectable } from '@nestjs/common';
    import {
      ValidationArguments,
      ValidatorConstraint,
      ValidatorConstraintInterface,
    } from 'class-validator';
    import { UsersRepository } from '../users.repository';
    
    @ValidatorConstraint({ name: 'isEmailAvailable', async: true })
    @Injectable()
    export class IsEmailAvailableConstraint implements ValidatorConstraintInterface {
      constructor(private readonly users: UsersRepository) {}
    
      async validate(email: unknown): Promise<boolean> {
        if (typeof email !== 'string') return false;
        const existing = await this.users.findByEmail(email.toLowerCase());
        return existing === null;
      }
    
      defaultMessage(args: ValidationArguments): string {
        return `El email ${args.value} ya está registrado`;
      }
    }
    

    Lo enganchas al DTO con @Validate:

    import { IsEmail, Validate } from 'class-validator';
    import { IsEmailAvailableConstraint } from '../validators/is-email-available.validator';
    
    export class CreateUserDto {
      @IsEmail()
      @Validate(IsEmailAvailableConstraint)
      email: string;
    
      // ...resto de propiedades
    }
    

    Para que la inyección de dependencias funcione necesitas dos cosas. Primero, declarar el constraint como provider en su módulo. Segundo, decirle a class-validator que use el contenedor de Nest:

    // src/main.ts
    import { useContainer } from 'class-validator';
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
      useContainer(app.select(AppModule), { fallbackOnErrors: true });
      // ...el resto del bootstrap: useGlobalPipes, listen
    }
    

    fallbackOnErrors: true no es opcional: sin él, Nest lanza una excepción en cuanto class-validator le pide al contenedor una clase que no está registrada como provider.

    Una advertencia: consultar la base de datos durante la validación es best effort, no una garantía. Entre que el validador pregunta y el servicio inserta hay una ventana de carrera. El índice único de la tabla sigue siendo la fuente de verdad; el validador solo sirve para devolver un 400 legible en vez de un 500 con un error del driver.

    A favor juega el orden del ciclo de vida de Nest: los pipes se ejecutan después de los guards. Cuando ese validador toca la base de datos, la request ya está autenticada.


    Testea el validador como lógica pura

    Un validador custom es una clase con una dependencia. No necesitas levantar un TestingModule.

    // src/users/validators/is-email-available.validator.spec.ts
    import { IsEmailAvailableConstraint } from './is-email-available.validator';
    
    describe('IsEmailAvailableConstraint', () => {
      const usersRepository = { findByEmail: jest.fn() };
      const constraint = new IsEmailAvailableConstraint(usersRepository as never);
    
      beforeEach(() => jest.resetAllMocks());
    
      it('acepta un email que no existe', async () => {
        usersRepository.findByEmail.mockResolvedValue(null);
        await expect(constraint.validate('nuevo@dominicode.com')).resolves.toBe(true);
      });
    
      it('rechaza un email ya registrado', async () => {
        usersRepository.findByEmail.mockResolvedValue({ id: '1' });
        await expect(constraint.validate('bezael@dominicode.com')).resolves.toBe(false);
      });
    
      it('normaliza a minúsculas antes de consultar', async () => {
        usersRepository.findByEmail.mockResolvedValue(null);
        await constraint.validate('Bezael@Dominicode.com');
        expect(usersRepository.findByEmail).toHaveBeenCalledWith('bezael@dominicode.com');
      });
    });
    

    Tres tests, cero infraestructura. Si tu validador necesita un módulo entero para poder testearse, tiene demasiada responsabilidad.


    Cuándo NO usar class-validator

    class-validator es la librería de decoradores (@IsEmail, @MinLength, @ValidateNested) sobre la que NestJS construye toda su validación de entrada. No es un paquete de Nest: es un proyecto independiente, y ahí está la parte incómoda.

    class-validator va por la 0.15.1, publicada el 26 de febrero de 2026. El parón fuerte fue entre la 0.14.1 (enero de 2024) y la 0.14.2 (mayo de 2025): dieciséis meses sin release. Desde entonces ha recuperado ritmo — 0.14.3 en noviembre de 2025, 0.14.4 y 0.15.1 en febrero de 2026. Está vivo.

    class-transformer es otra historia. Su última versión publicada es la 0.5.1, de noviembre de 2021. Casi cinco años sin release, y es la pieza de la que dependen @Type, @Transform, transform: true y toda la conversión implícita que acabamos de ver. No está roto, pero tampoco se está arreglando.

    En paralelo, NestJS 12 salió el 27 de agosto de 2026 con soporte nativo de Standard Schema. Los decoradores de parámetro aceptan una opción schema, y hay un pipe nuevo para validarla:

    // src/users/schemas/create-user.schema.ts
    import { z } from 'zod';
    
    export const createUserSchema = z.strictObject({
      email: z.email(),
      password: z.string().min(12),
      fullName: z.string().min(2).max(60),
      age: z.number().int().min(18).optional(),
    });
    
    export type CreateUserDto = z.infer<typeof createUserSchema>;
    
    // src/main.ts
    import { StandardSchemaValidationPipe } from '@nestjs/common';
    
    app.useGlobalPipes(new StandardSchemaValidationPipe());
    
    // src/users/users.controller.ts
    @Post()
    create(@Body({ schema: createUserSchema }) body: CreateUserDto) {
      return this.usersService.create(body);
    }
    

    Aquí CreateUserDto es un tipo inferido, no una clase. El esquema y el tipo son la misma cosa, así que no puedes desincronizarlos. Con decoradores son dos verdades separadas que mantienes a mano, y ahí es donde entran los bugs.

    La comparación honesta:

    class-validator Zod / Standard Schema
    Fuente de verdad Tipo + decoradores (dos) Esquema (una)
    DI en validadores Sí, vía useContainer No de serie
    Mapped types (PartialType) Sí .partial(), .omit(), .pick()
    OpenAPI @nestjs/swagger maduro Nativo en v12, o nestjs-zod
    Mantenimiento Lento (class-transformer congelado) Activo
    Reutilizar en el frontend No Sí, mismo esquema

    Mi criterio, sin vender humo: si tu proyecto ya es class-based de arriba abajo (entidades TypeORM, Swagger, validadores con DI), class-validator sigue siendo el camino de menor fricción y no hay que migrarlo por moda.

    Si empiezas hoy en Nest 12, si compartes contratos con un frontend TypeScript, o si validas salidas de un LLM —donde necesitas parsear, transformar y reintentar en el mismo sitio—, Zod gana con claridad. Si sigues en v11 y quieres esa ruta, nestjs-zod (5.5.0, julio de 2026) te da createZodDto, el pipe y la serialización de respuestas. Ojo: sus peer dependencies todavía declaran @nestjs/common ^10 || ^11, así que para v12 aún no es opción.

    Sobre este tema escribí a fondo en validación en runtime con Zod y TypeScript, y si quieres dominar la librería entera —transformaciones, refinamientos, esquemas compuestos— la trabajo paso a paso en el curso de Zod para TypeScript.

    Y si aún estás eligiendo framework, esta capa de validación es uno de los argumentos de más peso a favor de Nest frente a opciones más ligeras, como analicé en Hono vs NestJS vs Express y al mirar la alternativa más directa a Nest, ExpressoTS 4.0.


    Checklist: revisa hoy tu validación en NestJS

    Abre tu main.ts. Si ves new ValidationPipe() sin opciones, ya tienes trabajo para los próximos veinte minutos:

    1. Añade whitelist: true y forbidNonWhitelisted: true.
    2. Pon forbidUnknownValues: true explícitamente y ejecuta tu suite de tests.
    3. Busca en el proyecto @ValidateNested y comprueba que cada uno tiene su @Type() al lado.
    4. Si tienes enableImplicitConversion: true, quítalo y haz explícitas las conversiones.

    Después vete a tus servicios y borra los if (!dto.email) throw .... Esos guardias existen porque en algún momento nadie confió en la entrada. Cuando el contrato vive en el DTO, sobran: es el principio que desarrollo en programación defensiva en TypeScript, donde la mejor defensa es la que se aplica una vez, en el borde, y no en cada función.

    Esa es la fortaleza silenciosa de NestJS. No es que valide. Es que, bien montado, te deja escribir servicios que asumen datos correctos porque lo son.

    Si quieres verlo aplicado sobre un proyecto real, con la capa de validación, los tests y las decisiones de arquitectura completas, lo trabajamos en Dominicode Labs. Y si lo que te interesa es NestJS llevado al terreno de la IA, monté el streaming de respuestas en tiempo real con el Vercel AI SDK sobre esta misma base. En vídeo, subo NestJS y arquitectura backend cada semana en el canal de YouTube.


    Preguntas frecuentes

    ¿Qué es un DTO en NestJS?

    Un DTO (Data Transfer Object) en NestJS es una clase que describe la forma exacta del payload que un endpoint acepta: qué propiedades existen, de qué tipo son y qué reglas cumplen. Se declara con decoradores de class-validator y se usa como tipo del parámetro @Body(), @Query() o @Param() en el controlador.

    Tiene que ser una class y no una interface: las interfaces desaparecen al compilar y en runtime no queda nada a lo que asociar los decoradores. Y no es solo documentación — con el ValidationPipe configurado, el DTO es lo que decide qué request entra y cuál se rechaza con un 400.

    ¿Puedo usar una interface en lugar de una clase para el DTO?

    No, si quieres que se valide. Las interfaces desaparecen en la transpilación, así que en runtime no hay nada a lo que asociar los decoradores: Nest recibe Object como metatype y el ValidationPipe deja pasar el payload entero.

    Si te molesta escribir clases, la alternativa real es la ruta de esquemas: con Zod y el StandardSchemaValidationPipe de Nest 12 el DTO sí puede ser un tipo inferido, porque la validación no depende de metadatos de runtime sino del esquema que pasas al decorador.

    Tengo el ValidationPipe puesto y la validación en NestJS no salta. ¿Qué reviso?

    Por orden. Que el DTO sea una clase y que el tipo del parámetro en el controlador sea exactamente esa clase, no any ni un union. Que emitDecoratorMetadata y experimentalDecorators estén a true en tu tsconfig.json, porque sin ellos no hay metadatos de tipo que leer.

    Después, que el pipe esté registrado donde crees: si lo pusiste con APP_PIPE en un módulo de feature en lugar del root, solo aplica a ese ámbito. Y si lo que pasa es que un objeto entero se cuela sin validarse, mira forbidUnknownValues, que Nest fuerza a false cuando no lo declaras tú.

    ¿class-validator sigue mantenido en 2026?

    Sí, aunque a ritmo irregular. La versión actual es la 0.15.1, del 26 de febrero de 2026, publicada justo un día después de la 0.14.4. El bache serio fueron los dieciséis meses entre la 0.14.1 (enero de 2024) y la 0.14.2 (mayo de 2025); desde ahí ha vuelto a publicar con regularidad. Sigue en 0.x diez años después de su primera versión, lo cual dice bastante sobre su compromiso de estabilidad de API.

    El problema mayor es class-transformer, su dependencia inseparable: última versión 0.5.1, noviembre de 2021. Toda la lógica de @Type, @Transform y transform: true corre sobre un paquete que lleva casi cinco años sin release. No es motivo para migrar mañana, sí para tenerlo en cuenta al empezar un proyecto nuevo.

    ¿Dónde valido las reglas de negocio: en el DTO o en el servicio?

    En el DTO va todo lo que es forma: tipos, formatos, longitudes, rangos, campos requeridos, estructura de los objetos anidados. Son reglas que se responden mirando solo el payload.

    En el servicio va todo lo que necesita contexto: si este usuario puede hacer esta operación, si el stock alcanza, si el pedido está en un estado que admite ese cambio. La prueba rápida es preguntarte si la regla depende de quién hace la petición o del estado actual del sistema. Si depende, no es validación de entrada, es lógica de dominio, y meterla en un decorador te va a complicar los tests.

    ¿Merece la pena migrar un proyecto grande de class-validator a Zod?

    Rara vez de golpe, y casi nunca por el argumento de que "está más moderno". Rehacer DTOs, mapped types, validadores con inyección de dependencias y la integración con Swagger son semanas de trabajo sin una sola feature nueva para el usuario.

    Lo que sí funciona es la convivencia. Nest 12 mantiene el flujo de class-validator plenamente soportado junto al de Standard Schema, así que escribes con Zod lo nuevo y dejas lo existente como está. Se migra por presión real —un bug de sincronía entre tipo y validación, un esquema que necesitas compartir con el frontend— y no por calendario.


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

  • Harness multiagente vs un solo agente: qué midió Uncle Bob

    Harness multiagente vs un solo agente: qué midió Uncle Bob

    Robert C. Martin —Uncle Bob, el de Clean Code— pasó meses construyendo lo que parecía la cosa correcta: un harness de orquestación de agentes IA con roles especializados, sesiones aisladas y handoffs que no se contaminaban entre sí. Gates deterministas. Métricas de complejidad. Todo.

    Mientras lo montaba, el suelo se movía debajo.

    Cuando por fin lo tuvo funcionando hizo lo que casi nadie hace: medirlo contra la alternativa tonta. Le dio la misma tarea a un solo agente, con un par de directrices y cero orquestación. Se fue cuarenta minutos. Al volver estaba hecho. Y mejor que lo que le entregaba el enjambre.

    Su conclusión pública cabe en seis palabras: "OK. It's time to rethink this."

    En corto: Uncle Bob midió su harness multiagente de seis roles contra un solo agente en la misma tarea: cuarenta minutos frente a tres o cuatro horas, y mejor código. La orquestación con roles fijos y handoffs por contrato era un andamio para modelos débiles, y los modelos dejaron de serlo. Hoy un agente único bien dirigido suele ganar en tiempo, en calidad y en tokens. Lo que sigue valiendo del harness no es la orquestación: es la verificación determinista —tests, tipos, cobertura, complejidad— contra la que mides su salida.


    Qué era SwarmForge, el harness que Uncle Bob construyó y tiró

    Un harness de orquestación multiagente es una capa de software que reparte una tarea entre varios agentes con roles predefinidos, controla el orden en que se pasan el trabajo y bloquea el avance hasta que cada etapa cumple unos criterios medibles.

    SwarmForge, el harness de Uncle Bob, se autodescribe en su README como "a simple tool for coordinating several AI agents". Es bastante más que eso.

    Los roles están separados de verdad. El six-pack del repo los nombra así: especificación, implementación, limpieza, arquitectura, hardening y QA. Cada agente vive en su propia sesión de tmux y su git worktree bajo .worktrees/ para no pisarse, y los handoffs los mueve un daemon en Babashka.

    Las técnicas que aplica cada rol no están en el repo: las cuenta él. Gherkin para la especificación, TDD para implementar, revisiones de duplicación y de CRAP para la limpieza, mutation testing para el hardening.

    Cada decisión ahí responde a un fallo real que conoce cualquiera que haya montado esto. Si llegas frío, la pieza por pieza está en la anatomía de un agent harness, y el recorrido completo en construir un agente de IA desde cero.

    Y aun así perdió contra un agente solo. Con el mismo modelo corriendo dentro y fuera del harness.

    El experimento es de septiembre de 2026 y el modelo era Grok. Uncle Bob no precisa la versión, y eso limita la reproducibilidad: esto es la medición de un practicante con oficio, no un paper.

    El experimento que lo tiró abajo

    El experimento fue este: misma tarea, mismo modelo, dos caminos — el harness de seis roles y un agente solo con dos directrices. Y Uncle Bob relajó las restricciones a favor del harness, a propósito.

    En vez de su umbral habitual de CRAP por debajo de 6, pidió mantenerlo por debajo de 12. CRAP —Change Risk Anti-Patterns— combina complejidad ciclomática con cobertura de tests: cuanto más ramifica un método y menos cubierto está, más alto puntúa y más caro es tocarlo.

    Algo de mutation testing y tests unitarios. Nada de Gherkin. Dos directrices y a correr.

    El agente hizo algo que nadie le pidió: partió el código en módulos y dejó todo el CRAP por debajo de 6 igualmente. Por debajo del umbral relajado y del estricto.

    Cuarenta minutos. Lo que al harness completo le costaba tres o cuatro horas, y salía aceptable-pero-no-bueno.

    Métrica Harness SwarmForge Un solo agente
    Agentes implicados 6 (six-pack del repo) 1
    Tiempo en la misma tarea tres o cuatro horas cuarenta minutos
    Calidad entregada aceptable, no buena mejor, con un par de quejas menores
    Umbral de CRAP pedido por debajo de 6 (su estándar) por debajo de 12 (relajado a propósito)
    CRAP entregado — por debajo de 6, y modularizado sin pedírselo
    Consumo de tokens la referencia cayó "by a huge factor" al abandonar el harness

    Todas las cifras salen de lo que cuenta Uncle Bob en sus hilos; el recuento de agentes, del README del repo.

    Días después llegó la segunda medición, la que duele en la factura: "Since I stopped using my harness, my token consumption has fallen by a huge factor. That harness was massively inefficient."

    Cada handoff es un resumen que uno escribe, otro lee y un tercero vuelve a expandir. Y aquí conviene no confundir dos facturas distintas. Cuando medí el overhead de los frameworks de IA en tokens el resultado fue que no hay prompts ocultos inyectados: ese impuesto es un mito. El coste de un harness es el opuesto, explícito y a la vista: serializar el estado en cada salto para que el siguiente agente pueda leerlo. Nadie te lo esconde. Lo pagas igual.

    Lo que se ha roto no es el multiagente

    La idea que se cae no es "usar varios agentes". Es otra, más específica: tratar al agente como un componente de un diagrama de software. Una caja con interfaz fija, un rol asignado y un contrato de handoff.

    Tenía sentido hace un año, cuando los modelos se perdían en tareas largas: el rol estrecho y el gate duro eran una prótesis para una debilidad real.

    Los modelos dejaron de ser débiles. El andamio se convirtió en camisa de fuerza.

    El detalle que lo resume es la modularización. Nadie se la pidió. Un pipeline con un rol architect habría producido esa decisión como etapa obligatoria, en su turno, con su handoff. El agente solo la tomó porque veía el problema entero de una vez.

    Ahí está el fondo: un harness de roles fijos parte el contexto por la línea que dibujaste hace tres meses, no por donde el problema se parte hoy.

    Harness, agente único y subagentes bajo demanda

    No son tres sabores del mismo plato. Se diferencian en una cosa: quién decide el reparto del trabajo.

    Harness orquestado Agente único dirigido Subagentes bajo demanda
    Quién reparte Tú, antes de empezar Nadie: no hay reparto El modelo, en ejecución
    Qué resuelve Determinismo, trazabilidad por etapa, aislamiento fuerte El criterio del modelo sobre el problema completo Aislar contexto sucio sin fijar roles
    Qué cuesta Meses de construcción y tokens en cada handoff Una sesión larga y directrices bien escritas Latencia y contexto duplicado
    Límite o riesgo Bloquea decisiones transversales que el modelo tomaría solo; envejece con cada modelo nuevo Se cae si la tarea no cabe en una sesión o cruza permisos Si abusas, vuelves a un pipeline implícito
    Cuándo elegirlo Aprobación humana intermedia, aislamiento por datos o permisos, paralelismo real Casi todo el trabajo normal de feature o refactor Investigación previa a escribir código

    Fíjate en la fila de límites: ninguna columna está limpia. La pregunta no es "multiagente sí o no", sino cuánta estructura te puedes permitir antes de que la estructura decida por el modelo.

    Lo que defendí hace un mes y qué parte ha caducado

    El 24 de agosto publiqué Arquitectura de subagentes IA: por qué falla el mega-prompt: un agente mío con 3.000 palabras de system prompt y 28 herramientas que colapsaba a la cuarta tarea compleja.

    Esa mitad sigue en pie. Un prompt con cincuenta reglas y treinta herramientas reparte la atención del modelo entre instrucciones que casi nunca aplican. Una ventana más grande no lo arregla: solo retrasa el momento en que se nota.

    La otra mitad ha caducado. Allí proponía un pipeline fijo —investigador, implementador, revisor— comunicándose por artefactos en disco, con el orden decidido por mí antes de empezar. Eso es orquestación rígida: lo mismo que acaba de tirar Uncle Bob, en pequeño.

    La distinción que reconcilia las dos posiciones es quién manda.

    Subagentes bajo demanda: el modelo decide delegar cuando le conviene, el subagente vive lo que dura su pregunta y muere con su contexto sucio dentro. Nadie le asignó un rol permanente. Sigue siendo buena idea, porque aislar contexto no ha dejado de importar.

    Orquestación rígida: los roles existen antes que la tarea, el orden vive en un fichero de configuración y el trabajo pasa por todas las etapas aunque tres no aporten nada. Esto es lo que los modelos han dejado obsoleto.

    Escribí aquello hace un mes. Un mes. Esa es la velocidad a la que caduca hoy una decisión de arquitectura sobre agentes, y el mejor argumento para construir lo menos posible alrededor del modelo.

    Cuándo el harness sigue ganando

    Tirar la orquestación entera sería el error simétrico. Cuatro casos donde aún compensa:

    La tarea no cabe en una sesión. Migrar cuatrocientos ficheros no es un problema de criterio, es de volumen: repartir gana, aunque reparta trabajo y no roles.

    Hay una aprobación humana en medio. Si alguien firma antes del siguiente paso, necesitas una parada explícita con un artefacto revisable. Un agente continuo no te la da.

    El aislamiento es por permisos o por datos. El agente que lee el ticket del cliente no debería tener credenciales de producción. Eso no es diseño: es requisito, y sobrevive a cualquier modelo mejor.

    Paralelismo real sobre repos distintos. Tres repositorios independientes, tres agentes, cero coordinación. Funciona precisamente porque no hay handoffs.

    Y un límite más, del propio experimento: verificar de más deja cicatrices. Uncle Bob es honesto con el mutation testing —encontró bugs y omisiones reales, pero el algoritmo empuja al agente a hacer cosas tontas con tal de matar mutantes, y eso queda escrito en el código. Ningún gate es gratis.

    Y lo obvio: esto es la medición de una persona, con sus tareas y su modelo. No es un benchmark controlado. Si tu dominio no se parece al suyo, lo que te vale es el método, no la conclusión.

    Quédate la verificación, tira la orquestación

    Del harness se tira la orquestación y se conserva la verificación: la primera decide quién hace qué y caduca con cada modelo nuevo; la segunda define qué tiene que cumplir el resultado y no caduca.

    Separa las dos cosas que el harness mezclaba.

    La orquestación dice quién hace qué y en qué orden. Es la parte que envejece cada vez que sale un modelo mejor.

    La verificación dice qué tiene que cumplir el resultado para ser aceptable: tests que pasan, tipos que compilan, lint sin warnings, cobertura mínima, complejidad bajo umbral. No depende de quién escriba el código ni de cuántos agentes participen. Por eso no caduca.

    Tres cosas para esta semana:

    1. Escribe el contrato antes que el prompt. Entradas, salidas, errores, invariantes y umbrales. Si no puedes decir qué hace fallar la entrega, no tienes un gate: tienes una opinión. Lo tienes en revisión por contrato para código de agentes y entero en el ebook gratuito de 30 páginas.
    2. Convierte cada gate en un comando que devuelva 0 o 1. Si el criterio vive dentro del prompt de un rol, no es determinista: es una sugerencia. Un verify no necesita ser más que esto, y el agente lo ejecuta igual que tú:
    #!/usr/bin/env bash
    set -e                            # el primer fallo corta y devuelve != 0
    bun test                          # los tests pasan
    bunx tsc --noEmit                 # los tipos compilan
    bunx eslint . --max-warnings 0    # cero warnings
    bunx vitest run --coverage        # cobertura sobre el umbral del config
    
    1. Mide tu pipeline contra un agente solo. Misma tarea, dos caminos, cronómetro y factura de tokens. La comparación que casi nadie hace y la única que decide.

    El paso previo es tener la especificación escrita antes de que el agente toque nada: lo que trabajamos en Construye con IA y la tesis del libro de Spec-Driven Development. Un agente sin criterio escrito no va más rápido: va más rápido equivocándose.

    Si llevas meses montando tu orquestador, esta es la conclusión que importa: no tires el trabajo, tira la mitad correcta. Los roles y los handoffs ya no te compran nada. Los gates sí.


    Preguntas frecuentes

    ¿Qué es un harness de orquestación multiagente?

    Una capa de software que reparte una tarea entre varios agentes con roles predefinidos, controla el orden de los handoffs y bloquea el avance hasta que cada etapa cumple criterios medibles. SwarmForge lo implementa con una sesión de tmux y un git worktree por agente. Su valor original: compensar las limitaciones del modelo con estructura externa.

    ¿Significa esto que los subagentes ya no sirven?

    No. Lo que ha dejado de compensar son los roles fijos decididos antes de conocer la tarea. Delegar bajo demanda sigue siendo útil: cuando un subagente explora el repo o lee logs enormes, su contexto sucio muere con él sin contaminar la sesión principal. La diferencia está en quién decide: si lo decides tú en un fichero de configuración, es orquestación rígida; si lo decide el modelo en ejecución, es aislamiento de contexto.

    ¿Qué es CRAP y por qué se usa como gate?

    CRAP —Change Risk Anti-Patterns— combina complejidad ciclomática y cobertura en un número: un método muy ramificado y poco cubierto puntúa alto, y eso indica que cambiarlo es caro. Funciona como gate porque lo calcula una herramienta, no una opinión. Uncle Bob trabaja con umbral por debajo de 6, y aquí lo relajó a 12 a propósito.

    ¿Por qué un harness multiagente consume tantos más tokens?

    Porque cada handoff obliga a serializar el estado: uno resume lo que ha hecho y el siguiente reconstruye el contexto que el anterior ya tenía cargado. Multiplícalo por seis roles y por cada iteración. Uncle Bob lo comprobó al dejar de usar el suyo: su consumo cayó de forma drástica y calificó el harness de "massively inefficient".


    ¿Merece la pena construir mi propio harness multiagente hoy?

    Solo si tu problema es de los que no arregla un modelo mejor: volumen que no cabe en una sesión, una aprobación humana en medio, aislamiento por permisos o por datos, o paralelismo real sobre repos separados. Si tu motivo es "que el agente no se despiste", ya no lo necesitas: escribe los gates como comandos verificables y dale la tarea entera. Construir el harness te va a costar meses y va a envejecer con el siguiente modelo; los gates no.

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

  • Claude Code en monorepos: dale solo la rebanada que necesita

    Claude Code en monorepos: dale solo la rebanada que necesita

    Un cliente me pasó su monorepo el mes pasado. Nueve paquetes, pnpm workspaces, Turborepo por encima. Le pedí a Claude Code algo ridículamente pequeño: cambiar el tipo de una prop en packages/ui.

    Tres respuestas después me estaba proponiendo tocar el cliente HTTP del backend.

    No era un modelo tonto. Era yo. Había arrancado la sesión desde la raíz del repo, y trabajar con Claude Code en monorepos desde la raíz significa una cosa muy concreta: le has dado nueve paquetes de superficie para una tarea que vive en uno.

    Esto no es el problema del que ya escribí en Context Drift. Aquel es temporal: la sesión se alarga, el historial se pudre, el agente se olvida de la instrucción de la iteración 3. Este es espacial. Se degrada en el minuto uno, con la ventana medio vacía, porque el repo es grande y nadie le ha dicho qué parte del repo importa.

    La tesis del post es esta: en un monorepo, la decisión más importante que tomas no es qué prompt escribes. Es desde qué directorio arrancas el agente.

    Smart context slicing es la práctica de arrancar el agente en el subárbol mínimo del monorepo que la tarea necesita, calculado a partir del grafo de dependencias en vez de a ojo. Son tres decisiones concretas: desde qué directorio lanzas claude, qué paquetes vecinos añades con --add-dir y qué rutas bloqueas con reglas de denegación. Las tres, en ese orden, son el resto del post.

    Claude Code en monorepos: un CLAUDE.md en la raíz no escala

    La documentación de Anthropic recomienda mantener cada CLAUDE.md por debajo de 200 líneas, y lo justifica: los archivos largos consumen más contexto y reducen la adherencia a las instrucciones.

    Ahora divide. Nueve paquetes, 200 líneas: 22 líneas por paquete para explicar su stack, sus convenciones y sus trampas.

    Así que solo hay dos finales, y he visto los dos.

    O el CLAUDE.md crece hasta las 600 líneas y el agente ignora la mitad — incluidas las reglas que importaban. O se queda genérico ("usa TypeScript estricto", "escribe tests"), que es una forma elegante de no decir nada.

    Si todavía estás montando el tuyo, el punto de partida lo dejé en CLAUDE.md: el system prompt de tu proyecto. Aquí doy por hecho que ya lo tienes y se te ha quedado pequeño.

    La solución es partirlo: raíz para lo global, un archivo por paquete para lo local.

    monorepo/
      CLAUDE.md                 # reglas globales: commits, estilo, "corre los scripts desde el paquete"
      packages/
        ui/CLAUDE.md            # convenciones de componentes, tokens de diseño
        api/CLAUDE.md           # Knex, migraciones, .env obligatorio
        web/CLAUDE.md           # rutas, data fetching
    

    Pero partirlo no sirve de nada si no entiendes cuándo se carga cada trozo.

    La regla de carga que casi nadie ha leído

    Claude Code no trata igual a los CLAUDE.md que están por encima de ti y a los que están por debajo.

    Dónde vive el CLAUDE.md Cuándo entra en contexto
    Tu directorio de trabajo y todos sus ancestros Al arrancar la sesión, siempre
    Subdirectorios por debajo de ti Bajo demanda, solo cuando el agente lee un archivo de esa carpeta

    Si arrancas desde la raíz, cargas solo el CLAUDE.md raíz — y vas acumulando el de cada paquete que el agente toque. Toca muchos, porque no sabe dónde está el límite.

    Si arrancas con cd packages/ui && claude, cargas raíz + packages/ui de golpe, y los de api y web no existen para esa sesión mientras no los pises. Además, solo puede leer y editar dentro de ese subárbol hasta que le concedas más.

    Eso es una rebanada. Y te ha costado un cd.

    Compruébalo: lanza /context y mira la lista de Memory files. Ahí está lo que se cargó de verdad.

    El slice no lo decides tú: lo decide el grafo de dependencias

    "Trabaja desde el paquete" está bien hasta que la tarea toca de verdad a los vecinos. Cambiar un tipo exportado de ui puede romper a quien lo consume, y si el agente no ve a esos consumidores, te entrega algo que compila en su rebanada y revienta en CI.

    La pregunta correcta no es qué paquetes te apetece abrir, sino qué paquetes toca esta tarea de verdad. Y esa respuesta ya está en tu repo: en el grafo de dependencias.

    Monté un workspace de cinco paquetes para verlo, con pnpm 11.1.3 y Turborepo 2.10.12. @acme/api y @acme/web dependen de @acme/ui; @acme/ui depende de @acme/config; @acme/jobs va por libre.

    Inventario primero:

    pnpm ls -r --depth -1
    

    Ahora el blast radius hacia arriba — qué se rompe si toco @acme/ui. En la sintaxis de filtros de pnpm, los tres puntos delante del nombre significan "y todo lo que depende de él":

    pnpm --filter "...@acme/ui" ls --depth -1
    # (salida recortada al nombre de cada paquete)
    # @acme/ui
    # @acme/api
    # @acme/web
    

    Y hacia abajo, con los puntos detrás, "y todo aquello de lo que depende":

    pnpm --filter "@acme/ui..." ls --depth -1
    # (salida recortada)
    # @acme/ui
    # @acme/config
    

    Si quieres el cierre completo en los dos sentidos, pones los puntos a ambos lados: "...@acme/ui...". Y si te sobra el propio paquete, el circunflejo lo excluye: "...^@acme/ui" devuelve solo api y web.

    Turborepo lo da con un matiz. --dry enseña el plan sin ejecutar nada:

    turbo run build --filter="...@acme/ui" --dry
    
    • Packages in scope: @acme/api, @acme/ui, @acme/web
    • Running build in 3 packages
    

    El detalle que solo ves ejecutándolo: "Packages in scope" son 3, pero si sacas el JSON aparecen 4 tareas:

    turbo run build --filter="...@acme/ui" --dry=json | jq -r '.tasks[].directory' | sort -u
    # packages/api
    # packages/config
    # packages/ui
    # packages/web
    

    @acme/config no está en el scope de edición, pero entra en el grafo de build porque ui lo necesita compilado. Son dos rebanadas distintas y conviene no confundirlas:

    Rebanada Paquetes % del repo
    Repo completo 5 100%
    Slice de edición (ui + dependientes) 3 60%
    Slice de build (añade config) 4 80%
    Nunca entra (@acme/jobs) 1 20%

    En un repo de cinco paquetes, dejar fuera un paquete suena a poco. En el del cliente, con nueve, el slice real de la tarea eran tres paquetes: dos tercios del repo que no tenían por qué abrirse nunca.

    Con esa lista en la mano, el arranque deja de ser una corazonada:

    cd packages/ui
    claude --add-dir ../api --add-dir ../web
    

    Si el equipo entero trabaja así, lo fijas en packages/ui/.claude/settings.json:

    {
      "permissions": {
        "additionalDirectories": ["../api", "../web"]
      }
    }
    

    Ojo con una diferencia que muerde: additionalDirectories da acceso a los ficheros pero no carga nunca el CLAUDE.md ni las skills de esos directorios. Con --add-dir sí cargan las skills, y el CLAUDE.md solo si arrancas con CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1. Si escribiste un CLAUDE.md en packages/api y no sale en /context, es por esto.

    Y como el slice te dice qué se puede romper, también sabes qué verificar antes de dar la tarea por buena: los tests de api y web, no los de ui. Convertir "parece que funciona" en un veredicto ejecutable lo desarrollé entero en el ebook gratuito Revisión por Contrato, sobre cómo revisar lo que te entrega un agente sin leértelo línea a línea.

    Lo que no debe entrar en la ventana bajo ningún concepto

    Las búsquedas de contenido de Claude Code respetan tu .gitignore por defecto, así que node_modules/, dist/ y build/ ya están fuera de los resultados de un grep.

    El problema es lo que sí está commiteado: código generado, un SDK vendorizado, snapshots enormes. Para eso hay reglas de denegación:

    {
      "permissions": {
        "deny": [
          "Read(./**/dist/**)",
          "Read(./**/*.generated.*)",
          "Read(./vendor/**)"
        ]
      }
    }
    

    Un detalle que rompe esto sin avisar: los patrones relativos anclan en el directorio desde el que arrancas la sesión, no en la raíz del repo. Si guardas estas reglas en la raíz pero lanzas la sesión desde packages/ui, Read(./vendor/**) está apuntando a packages/ui/vendor/. Para que apliquen en todo el repo las escribes absolutas, con doble barra: Read(//ruta/absoluta/al/repo/vendor/**).

    Y si arrancando desde la raíz se te cuelan los CLAUDE.md de equipos con los que no trabajas, existe claudeMdExcludes, en el .claude/settings.local.json de la raíz. Los patrones se comparan contra rutas absolutas, así que empiezan por **/ para que casen en cualquier punto del árbol:

    {
      "claudeMdExcludes": ["**/packages/legacy-*/**"]
    }
    

    Con un aviso honesto: esa lista es estática, no un interruptor por tarea. Para alternar de paquete cada día la herramienta sigue siendo el cd.

    Cuando de verdad no sabes dónde está, delega la búsqueda

    Todo lo anterior asume que sabes qué paquete tocar. A veces no lo sabes, y ahí es donde la gente destroza la sesión: "busca en el repo dónde se genera el token de refresco". El agente lee doscientos archivos y te devuelve una frase. Los doscientos archivos se quedan en tu ventana. La frase también, pero ya da igual.

    Delégalo a un subagente. Corre en su propia ventana de contexto y te devuelve el resumen, no los archivos:

    Usa un subagente para localizar en qué paquetes se genera y se valida
    el token de refresco. Devuélveme solo la lista de rutas y una línea
    por cada una. No propongas cambios todavía.
    

    El resultado es una lista de paquetes. Cierras la sesión, haces cd al correcto y empiezas la tarea real con la ventana limpia. La exploración se paga una vez y se tira.

    Es el mismo principio que conté en Context Engineering: lo caro no es el token, es el token irrelevante que se queda mirándote el resto de la sesión.

    Lo que puedes hacer hoy en tu monorepo

    Una sola cosa, y es gratis: deja de arrancar el agente desde la raíz del monorepo.

    Antes de la próxima tarea, corre pnpm --filter "...<tu-paquete>" ls --depth -1, mira los tres o cuatro nombres que salen, y arranca así:

    cd packages/<tu-paquete>
    claude --add-dir ../<vecino>
    

    No hace falta que escribas ni un CLAUDE.md nuevo para notar la diferencia. Eso viene después, cuando ya sepas qué reglas son globales y cuáles de un paquete — y eso solo se ve claro tras unos días trabajando por rebanadas.

    Si quieres el flujo completo, de la idea al producto con estas decisiones tomadas antes de escribir código, es lo que montamos en el curso Construye con IA.

    Preguntas frecuentes

    ¿Es mejor arrancar Claude Code desde la raíz del monorepo o desde el paquete?

    Desde el paquete, salvo que la tarea cruce varios subsistemas de verdad. Arrancando desde packages/ui cargas el CLAUDE.md raíz más el de ui, y el agente solo puede leer y editar ese subárbol. Desde la raíz tienes acceso a todo: útil para refactors transversales, caro para cualquier otra cosa. Si necesitas un vecino puntual, --add-dir te lo añade sin romper el aislamiento.

    ¿Los CLAUDE.md de los subdirectorios se cargan siempre?

    No, y esta es la confusión más habitual. Los de tu directorio de trabajo y de todos sus ancestros se cargan al arrancar la sesión. Los de subdirectorios por debajo de ti se cargan bajo demanda, solo cuando el agente lee un archivo de esa carpeta. Para ver qué se cargó de verdad en una sesión, lanza /context.

    ¿Qué hago si la tarea toca varios paquetes a la vez?

    Dásela entera en una sola sesión, con el slice completo delante. Partirla en una sesión por paquete es peor: cada sesión redecide el diseño desde cero y acabas con tres criterios distintos. Calcula el slice con el filtro de dependientes, añade esos directorios y trabaja en plan mode antes de editar: el plan se escribe a un archivo que Claude Code reinyecta tras cada compactación.

    ¿Esto sirve si uso Nx o si mi repo es un solo árbol grande sin paquetes?

    Sí. En Nx el equivalente es nx graph para ver el grafo y nx show projects --affected para saber qué proyectos toca un cambio: cambia el comando, no la idea. Y en un repo de un solo árbol sustituyes "paquete" por "subsistema" — src/billing/, src/auth/, lib/core/. Un CLAUDE.md por subsistema y un cd hacen el mismo trabajo.

    ¿No basta con el .gitignore para que el agente no lea dist?

    Para las búsquedas de contenido sí: Claude Code respeta el .gitignore por defecto, así que dist/, build/ y node_modules/ no aparecen cuando busca texto. Lo que no cubre es lo commiteado — código generado, SDKs vendorizados, fixtures gigantes. Para eso necesitas reglas Read(...) en permissions.deny. Con un límite: cubren las herramientas de fichero y los comandos de Bash que Claude Code reconoce, pero un grep -r sobre una carpeta con ficheros denegados sigue sacándolos por pantalla.


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

  • Tu agente no sale del repo: interoperabilidad de agentes de IA

    Tu agente no sale del repo: interoperabilidad de agentes de IA

    Escribí un subagente de revisión de código para Claude Code. Lee el diff, comprueba el contrato del módulo y marca lo que rompe.

    Un equipo con el que trabajo quiso ese mismo criterio en su pipeline, que no corre sobre Claude Code. Abrí el fichero para copiarlo y la ilusión me duró treinta segundos.

    Lo único portable era el criterio, y el criterio son cuatro párrafos de texto. El resto —cómo pide las herramientas, dónde guarda lo revisado, quién arranca el bucle, cómo reporta— estaba pegado al harness.

    Ese es el estado real de la interoperabilidad de agentes de IA hoy: no existe. Tenemos agentes que funcionan muy bien exactamente donde nacieron y en ningún otro sitio.

    La interoperabilidad de agentes de IA es la capacidad de ejecutar el mismo agente —su criterio, sus herramientas, su memoria y su bucle— en un harness distinto de aquel donde se escribió, sin reescribirlo. No consiste en que hable con otros agentes: consiste en que se mude.


    El software se volvió reutilizable. Los agentes, no

    El software se convirtió en una industria enorme por una razón aburrida: se escribe una vez y se usa muchas.

    Una librería la escribe un dev y la usan miles. Una API expone una capacidad y acaba dentro de productos que su autor nunca vio. Las app stores añadieron distribución global a eso. Cada pieza de software podía ser el bloque de construcción de otra cosa.

    Un agente debería llevar esa idea más lejos, no menos. No expone una función: expone un criterio. Entiende un objetivo, decide, usa herramientas, se comunica y ejecuta trabajo. Un buen agente de revisión, de extracción de facturas o de migración de tests debería ser un trabajador digital que enchufas donde haga falta su capacidad.

    Y sin embargo. El agente de extracción de facturas que montó tu compañero con LangChain no puede entrar en el CLI del equipo de al lado. El agente de tests que va fino en tu runtime se rompe entero en otro. No porque el criterio sea malo: porque el criterio nunca aprendió a viajar solo.

    Eso tiene tres consecuencias que ya estamos pagando.

    La primera es que cada equipo reconstruye lo mismo. Miles de empresas escribiendo su propio agente de research, su propio agente de soporte, su propio agente de procesamiento de documentos. El mismo trabajo de ingeniería repetido porque ninguno de esos agentes se mueve de su proyecto.

    La segunda es que impide la especialización. Nadie puede dedicar dos años a construir el mejor agente de auditoría de accesibilidad del mundo y distribuirlo por muchos sistemas. Cada agente se trata como un detalle de implementación interno, no como un producto.

    Y la tercera: sin portabilidad no hay mercado. No puede existir un marketplace real si un agente solo funciona dentro del harness donde nació, ni efecto red si añadirlo beneficia a una sola aplicación.


    El acoplamiento no está donde crees

    Cuando alguien dice "muevo mi agente a otro entorno" suele pensar en copiar el prompt. El prompt es lo barato. Lo caro es todo lo que el harness le daba gratis.

    Capa Qué cambia al mover el agente
    Tool calling El esquema de las tools, sus nombres, cómo se serializan los resultados
    Contexto y memoria Qué entra en la ventana, qué se resume, dónde persiste entre turnos
    Bucle de ejecución Quién decide cuándo parar, cuántos pasos caben, quién reintenta
    Transporte stdio, HTTP con streaming, cola de mensajes
    Permisos Quién aprueba una escritura y con qué granularidad
    Reporte de progreso Logs sueltos, eventos tipados, estados de tarea

    Copiar el prompt y creer que has movido el agente es como copiar un componente de React sin llevarte el router, los tipos ni el ciclo de vida. Tienes el texto. No tienes el comportamiento.

    Por eso insisto tanto en que el harness es la pieza que de verdad define a un agente. El modelo es intercambiable. El harness, hoy, no.


    Qué resuelven MCP y A2A de la interoperabilidad de agentes de IA (y qué no)

    MCP y A2A resuelven dos capas del problema: las herramientas y la comunicación entre agentes. Ninguno de los dos toca el runtime, el contexto ni el bucle, que es donde vive el acoplamiento real. Son dos intentos serios de estandarizar esto y conviene ser honesto con el alcance de cada uno.

    MCP estandariza la capa de herramientas y el contexto que se sirve. En su revisión 2026-07-28, un servidor expone tres primitivas —tools, resources y prompts— sobre JSON-RPC 2.0, y cualquier cliente las descubre e invoca igual. Eso arregla la primera fila de la tabla y parte del transporte, y no es poco: el mismo servidor vale para clientes distintos. Si nunca has montado uno, empieza por qué es MCP exactamente.

    Lo que MCP no define es el comportamiento del agente: qué entra en su ventana de contexto, quién arranca su bucle o cuándo decide parar. Los permisos ni siquiera intenta cubrirlos —la propia spec reconoce que "MCP itself cannot enforce these security principles at the protocol level" y los delega en el host. La revisión actual se acerca por los bordes, eso sí: la extensión Tasks cubre operaciones largas con polling y handles duraderos, y el grupo de trabajo Skills over MCP quiere distribuir instrucciones de agente como recurso. Ninguna de las dos, todavía, te deja mover un agente de harness.

    A2A estandariza el intercambio entre agentes. La versión 1.0.0 define la Agent Card para descubrir capacidades, las Tasks con su ciclo de vida de ocho estados (submitted, working, input-required, auth-required, completed, failed, canceled, rejected), los Messages y los Artifacts. Eso arregla la fila del reporte y buena parte de la comunicación.

    Y el límite lo pone la especificación misma, por escrito: los agentes colaboran "without needing to share their internal thoughts, plans, or tool implementations". Ahí está la frontera, literal. A2A te deja hablar con un agente remoto; no te deja traerte ese agente a casa.

    Puestos capa por capa contra la tabla de antes, el reparto queda así:

    Capa de acoplamiento MCP 2026-07-28 A2A 1.0.0 Quién la resuelve hoy
    Tool calling Sí — MCP
    Transporte Parcial (JSON-RPC 2.0) Parcial MCP / A2A
    Reporte de progreso Parcial Sí (ciclo de vida de Task) A2A
    Contexto y memoria Parcial (resources) No Casi nadie — tu harness
    Bucle de ejecución No No Nadie — tu harness
    Permisos No (delega en el host) No Nadie — tu harness

    Los dos juntos te dan el cableado. Ninguno te da el agente portable. Si quieres la comparativa fila a fila de A2A y MCP, la tienes desarrollada en su propio post.

    Mi tesis es incómoda pero creo que es la correcta: el agente reutilizable de verdad todavía no existe, y la frontera no la marca el protocolo sino el harness. Lo que sí podemos hacer hoy es diseñar como si esa capa ya estuviera, para no tener que rehacerlo cuando llegue.


    Los tres pilares de la interoperabilidad de agentes de IA

    Un agente portable necesita tres propiedades arquitectónicas: concurrencia (se activa por eventos, no por su posición en una cadena), awareness o conciencia del entorno (lo consulta en vez de suponerlo) y adaptividad (decide con estado de runtime, no con un orden hardcodeado).

    Compartir un agente es más que mover su código. Un agente que aterriza en un entorno nuevo tiene que poder trabajar sin esperar a una secuencia predefinida, entender qué hay a su alrededor y ajustar su comportamiento a lo que encuentra.

    1. Concurrencia: fuera los pipelines secuenciales

    Casi todos los sistemas multiagente que reviso son esto:

    // Acoplado: el paso 3 no existe hasta que termina el 2.
    const spec = await specAgent.run(input);
    const code = await codeAgent.run(spec);
    const review = await reviewAgent.run(code);
    

    Esto no es un sistema de agentes. Es una función con tres llamadas caras. Que use await no lo salva: el orden está hardcodeado en el código que las invoca, así que el agente de revisión no puede existir fuera de ese fichero. Es el mismo error de fondo que hace fallar al mega-prompt cuando el sistema crece.

    La alternativa es que cada agente sea una unidad independiente que decide si un evento le incumbe:

    interface Agent {
      readonly id: string;
      readonly capabilities: readonly string[];
      // ¿Este evento va conmigo?
      accepts(event: AgentEvent): boolean;
      handle(event: AgentEvent, ctx: RuntimeContext): Promise<AgentEvent[]>;
    }
    

    Ningún agente bloquea a otro. Ninguno conoce su posición en una cadena. Cuando esto está bien hecho, a menudo descubres que no necesitas orquestador.

    2. Awareness: el entorno se consulta, no se supone

    Un agente acoplado solo conoce su prompt. Lo que hay alrededor está implícito en el orden de las llamadas.

    Un agente portable pregunta. Necesita dos cosas: un canal de eventos compartido y un registro de participantes.

    type Unsubscribe = () => void;
    
    type AgentEventType =
      | 'spec.ready'
      | 'code.changed'
      | 'review.blocked'
      | 'test.requested';
    
    interface AgentEvent {
      readonly type: AgentEventType;
      readonly source: string; // id del agente que lo emitió
      readonly payload: unknown;
      readonly at: number;
    }
    
    interface AgentDescriptor {
      readonly id: string;
      readonly capabilities: readonly string[];
    }
    
    interface Workspace {
      // Quién más está trabajando aquí y qué sabe hacer.
      participants(): readonly AgentDescriptor[];
      publish(event: AgentEvent): void;
      subscribe(handler: (event: AgentEvent) => void): Unsubscribe;
    }
    
    interface RuntimeContext {
      // Todo lo que el agente necesita del entorno donde aterriza.
      readonly workspace: Workspace;
    }
    

    La diferencia práctica: con esto, el mismo agente de revisión funciona en un entorno donde hay tres compañeros y en otro donde está solo, porque en el primer caso lo sabe. Monté el patrón completo en event bus para agentes descentralizados.

    3. Adaptividad: la decisión sale del estado, no del orden

    El tercer pilar es el que casi nadie implementa, y es el que separa un agente de un script con LLM dentro.

    async function handle(
      event: AgentEvent,
      ctx: RuntimeContext,
    ): Promise<AgentEvent[]> {
      const emit = (type: AgentEventType, payload: unknown): AgentEvent => ({
        type,
        source: 'reviewer',
        payload,
        at: Date.now(),
      });
    
      const findings = await runReview(event.payload, ctx);
      if (findings.length === 0) return [];
    
      // Si hay alguien capaz de ejecutar tests, delego. Si no, bloqueo.
      const peers = ctx.workspace.participants();
      const hasTester = peers.some((p) => p.capabilities.includes('test.run'));
    
      return hasTester
        ? [emit('test.requested', { findings })]
        : [emit('review.blocked', { findings })];
    }
    

    Fíjate en lo que no hay: ningún if (step === 'review'). La rama se decide con estado de runtime, no con una posición hardcodeada. Ese agente se comporta distinto en dos entornos distintos sin que nadie toque su código.

    Las tres juntas son las caras del mismo triángulo: independencia, conexión, colaboración. Si te falta una, el agente no viaja.

    Esta forma de pensar el sistema —el agente como unidad con contrato propio, no como paso de un flujo— es la que trabajo en el curso Construye con IA: de la idea al producto con Claude Code.


    Lo que se desbloquea cuando los agentes viajan

    Construyes un agente una vez y lo distribuyes en todas partes. Combinas especialistas en lugar de reconstruirlos. Y puedes monetizar una capacidad sin vender la aplicación entera alrededor.

    El cambio de fondo es de economía, no de ingeniería. En un ecosistema interoperable, cada agente nuevo aumenta el valor de todos los demás. Hoy cada agente nuevo aumenta el valor de exactamente un repositorio.


    Cómo diseñar hoy un agente portable en tu proyecto

    No hace falta esperar a que se asiente ningún estándar. Cinco decisiones que puedes tomar esta semana:

    1. Separa el agente de su runtime. El agente es un objeto con capacidades declaradas y un handle. Quién lo arranca y cada cuánto es responsabilidad de otro fichero.
    2. Expón sus herramientas vía MCP, aunque hoy solo lo use tu propio harness. Es la capa que ya está estandarizada; aprovéchala.
    3. Saca el contexto del prompt. Ficheros, un store, lo que sea. Si la memoria del agente vive en la cadena de mensajes de tu framework, tu agente es tu framework.
    4. No hardcodees la secuencia. Sustituye await a(); await b(); por eventos tipados. Si te cuesta imaginarlo, empieza por construir un agente de IA desde cero y verás dónde está cada costura.
    5. Escribe el contrato antes que el código. Qué acepta, qué emite, qué permisos pide, qué garantiza. Es revisión por contrato aplicada al diseño, y el mismo principio que desarrollo en Spec-Driven Development: la especificación es la parte portable; la implementación es desechable.

    Si el punto 5 te suena a burocracia, empieza por el ebook gratuito Revisión por Contrato. Va justo de eso: definir por escrito qué puede y qué no puede hacer un agente antes de dejarlo suelto en tu repo.

    En Dominicode Labs están las masterclasses y los repos donde desmonto este tipo de decisiones de arquitectura con el código delante y sin diapositivas.

    Elige hoy uno de tus agentes y responde a una sola pregunta: si mañana cambias de harness, ¿qué sobrevive? Si la respuesta es "el prompt", ya sabes por dónde empezar.


    Preguntas frecuentes

    ¿MCP no resuelve ya la interoperabilidad de agentes de IA?

    Resuelve una parte importante, no el conjunto. MCP estandariza cómo un agente descubre e invoca herramientas y cómo un servidor le sirve contexto como recurso, así que el mismo servidor vale para clientes distintos y eso elimina una de las seis capas de acoplamiento. Hay trabajo en curso para llevarlo más lejos —la extensión Tasks y el grupo de Skills over MCP—, pero a día de hoy nada de eso está cerrado. Pero un agente no es solo el conjunto de herramientas que puede llamar: es también su bucle, su gestión de contexto, su política de permisos y su forma de reportar. Nada de eso está cubierto. Puedes tener dos agentes que hablan MCP perfectamente y seguir sin poder mover ninguno de los dos al entorno del otro.

    Entonces, ¿A2A sobra?

    Al contrario: resuelve un problema distinto y complementario. A2A estandariza el intercambio entre agentes —descubrimiento de capacidades, envío de tareas, mensajes y progreso— y con eso puedes hacer que tu sistema hable con un agente que corre en otra empresa. Lo que no te da es portabilidad: sigues invocando un agente remoto que vive en su propio runtime. MCP y A2A son cableado en dos capas diferentes. El agente portable es otra discusión.

    ¿Concurrencia no es simplemente lanzar todo con Promise.all?

    No. Promise.all lanza varias llamadas a la vez, pero el punto donde se lanzan y el punto donde se espera siguen escritos en tu código: tú decides qué va junto y dónde se bloquea. Lo que pido aquí es otra cosa, desacoplamiento temporal: cada agente se activa por su cuenta cuando aparece un evento que le incumbe, sin que nadie coordine el orden desde fuera. La prueba está en si puedes añadir un agente nuevo al sistema sin tocar el fichero que orquesta. Si tienes que tocarlo, tienes llamadas concurrentes, no agentes autónomos.

    ¿No es sobreingeniería para un agente que solo uso yo?

    Depende de cuánto te haya costado ese agente. Si es un script de veinte líneas, sí, es sobreingeniería. Si le has dedicado semanas a afinar su criterio —y en revisión de código o extracción de datos eso pasa rápido— entonces lo que estás haciendo al acoplarlo es tirar ese trabajo cada vez que cambies de herramienta. Y cambiamos de herramienta cada pocos meses. En mi experiencia, separar el agente de su runtime cuesta una tarde; reescribirlo entero, varias semanas.

    Mi agente ya está acoplado al harness. ¿Por dónde empiezo?

    Por el contexto, que suele ser lo más doloroso y lo que antes se rompe. Saca de la cadena de mensajes del framework todo lo que sea conocimiento del agente y llévalo a ficheros o a un store propio. Después extrae la lógica de decisión a una función pura que recibe estado y devuelve eventos. Cuando tengas esas dos piezas, el runtime original pasa a ser un adaptador fino de treinta líneas, y escribir un segundo adaptador para otro entorno deja de dar miedo.


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

  • Refactorizar código legacy con IA: el método SDD en brownfield

    Refactorizar código legacy con IA: el método SDD en brownfield

    El fichero se llamaba pricing.ts, tenía 1.100 líneas y un comentario en la línea 3 que decía // NO TOCAR — hablar con Javi antes. Javi se había ido de la empresa en 2021.

    Cero tests. Cero documentación. Y toda la facturación pasando por ahí.

    Hice lo que hace todo el mundo la primera vez que intenta refactorizar código legacy con IA: se lo pegué entero a Claude Code y le pedí que lo dejara limpio. Me devolvió algo precioso. Funciones puras, nombres decentes, 300 líneas en vez de 1.100.

    Y roto para los pedidos que acumulaban cupón y descuento de socio a la vez.

    El agente no alucinó nada. Hizo exactamente lo que le pedí. Le pedí arreglar un código cuya intención nadie le había explicado, porque nadie la sabía.

    Esa es la tesis de este post: en legacy la spec no describe la feature que quieres, describe el comportamiento que ya tienes. Y por eso el primer artefacto no es spec.md, son los tests de caracterización.

    Por qué refactorizar código legacy con IA falla sin tests

    Refactorizar código legacy con IA usando SDD consiste en invertir el ciclo habitual: primero tests de caracterización que congelan el comportamiento observable, después una spec que documenta lo que el sistema ya hace, y solo entonces plan y tasks.

    El motivo es simple. Un modelo lee código y ve perfectamente qué hace. Lo que no puede ver es qué debería hacer.

    En un proyecto nuevo eso da igual, porque la intención está en tu cabeza y la escribes tú. Es lo que hacemos cuando arrancamos un greenfield con slices verticales: la spec va delante porque describe algo que todavía no existe.

    En legacy la intención está enterrada bajo seis años de parches de viernes por la tarde. Y ahí aparece el problema real: ningún modelo distingue una regla de negocio rara de un bug que lleva años tolerándose.

    En mi pricing.ts había un Math.floor donde cualquiera pondría Math.round. Claude lo "arregló". Llevaba ahí desde 2019 porque el departamento financiero quería redondear siempre a favor del cliente.

    Eso no es un bug. Es un requisito no escrito. Y el agente no tenía forma humana de saberlo.

    Los antipatrones de este escenario los desarrollé en los 5 errores fatales al refactorizar legacy con IA, así que no los repito. El método positivo empieza invirtiendo el orden.

    Spec greenfield Spec brownfield
    Qué describe Lo que quieres construir Lo que ya hace el sistema
    Fuente de verdad Tu criterio de producto El código en producción
    Primer artefacto spec.md Tests de caracterización
    Criterio de éxito Cumple los casos de uso nuevos No cambia ninguna salida observable
    Ambigüedad Se resuelve preguntando Se resuelve ejecutando
    Riesgo principal Construir lo que no toca Romper lo que ya funcionaba

    En greenfield el ciclo es spec → plan → tasks → código. En brownfield es tests → spec → plan → tasks → código. La spec sigue existiendo, pero llega en segundo lugar: hasta que no ejecutas el módulo no sabes qué escribir en ella.

    Paso 0 — Acota el blast radius antes de abrir el editor

    La regla que más refactors me ha salvado: si no puedes escribir en una línea qué NO vas a tocar, no empieces.

    Escribe estas cuatro cosas antes de nada:

    • Dentro: src/pricing.ts y sus dos helpers.
    • Fuera: el modelo de datos, los endpoints, la UI de checkout.
    • Consumidores: quién importa esto. Lanza un rg sobre el repo y pega la lista tal cual.
    • Contrato público: las funciones exportadas que otros usan. Esas firmas no se tocan.

    Ese último punto es el que hace el trabajo acotable: si la frontera del módulo se mueve, ya no es un refactor, es un rediseño.

    Paso 1 — Arqueología asistida: el agente lee, no escribe

    Aquí Claude Code es brutalmente bueno, y es la parte que casi nadie usa. El agente tiene prohibido cambiar una sola línea.

    El prompt que uso, más o menos literal:

    Lee src/pricing.ts. No propongas mejoras ni refactorices nada.
    
    Produce docs/legacy/pricing-observado.md con:
    1. Cada rama de decisión del módulo, con la condición exacta que la activa.
    2. Las entradas: tipos reales, no los declarados. Marca los que en la práctica
       llegan como null o undefined.
    3. Las salidas: forma del retorno en cada rama.
    4. Efectos secundarios: I/O, escrituras, logs, mutación de argumentos, lecturas
       de Date/Math.random o de variables globales.
    5. Una sección "Comportamientos sospechosos": cosas que parecen bugs.
       NO las arregles. Solo lístalas con número de línea.
    6. Una sección "Preguntas que no puedo responder leyendo el código".
    

    Las secciones 5 y 6 son el oro: una lista lo que el agente habría "arreglado" solo, la otra lo que tienes que ir a preguntarle a un humano o a los logs de producción.

    En pricing.ts la sección 6 tenía nueve preguntas. Siete las resolví mirando datos reales. Dos las resolvió el responsable de facturación en cinco minutos. Ese día no escribí código y fue el día más productivo del refactor.

    Paso 2 — Tests de caracterización: congela el comportamiento, incluso el feo

    Un test de caracterización no comprueba que el código sea correcto. Comprueba que sigue haciendo lo mismo. En TDD el test va delante y define lo deseable; aquí va detrás y define lo existente.

    Aunque lo existente sea horrible.

    // pricing.characterization.test.ts
    import { describe, it, expect } from 'vitest'
    import { calcularPrecioFinal } from '../src/pricing'
    
    // Casos capturados de pedidos reales de producción, anonimizados.
    const CASOS = [
      { nombre: 'base sin descuentos', pedido: { subtotal: 100, cupon: null, pais: 'ES', socio: false } },
      { nombre: 'cupon y socio acumulados', pedido: { subtotal: 100, cupon: 'VIP10', pais: 'ES', socio: true } },
      { nombre: 'cupon caducado', pedido: { subtotal: 100, cupon: 'OLD20', pais: 'ES', socio: false } },
      { nombre: 'pais sin IVA', pedido: { subtotal: 100, cupon: null, pais: 'US', socio: false } },
      { nombre: 'decimales feos', pedido: { subtotal: 1234.56, cupon: 'VIP10', pais: 'ES', socio: true } },
      { nombre: 'subtotal cero', pedido: { subtotal: 0, cupon: 'VIP10', pais: 'ES', socio: true } },
    ] as const
    
    // CONGELADO: el caso 'decimales feos' devuelve un céntimo de menos por el
    // Math.floor de pricing.ts:412. Se arregla DESPUÉS del refactor, en un
    // commit propio. Ver LEG-14.
    describe('calcularPrecioFinal — caracterización', () => {
      it.each(CASOS)('$nombre', ({ pedido }) => {
        expect(calcularPrecioFinal(pedido)).toMatchSnapshot()
      })
    })
    

    Fíjate en lo que no hay: ningún valor esperado escrito a mano. El snapshot lo genera la primera ejecución. Tú no decides la salida correcta, la registras.

    El término viene de Working Effectively with Legacy Code (Michael Feathers, 2004), y en Vitest 5 lo implementas con toMatchSnapshot().

    Después abres el fichero de snapshots y lo lees entero. Ahí aparecen las sorpresas y ahí apuntas los // CONGELADO:. Cada uno es un ticket futuro, no una excusa para tocar nada ahora.

    Y sí, congelas el bug a propósito. Si arreglas comportamiento y estructura en el mismo commit, cuando algo falle en producción no sabrás cuál de las dos cosas lo rompió.

    Paso 3 — La spec brownfield

    Ahora, y solo ahora, escribes la spec. Con los tests en verde delante deja de ser un ejercicio de memoria, y las secciones que importan no son las de un proyecto nuevo:

    # Spec — Refactor de pricing
    
    ## Comportamiento observado
    Documentado en docs/legacy/pricing-observado.md.
    Congelado en pricing.characterization.test.ts (6 casos).
    
    ## Contrato público (NO cambia)
    calcularPrecioFinal(pedido: Pedido): Precio
    - Devuelve `total` en céntimos como number. No se migra a bigint en este refactor.
    - Nunca lanza: ante entrada inválida devuelve { total: 0, error: string }.
    
    ## Efectos secundarios actuales
    - Escribe en la tabla pricing_audit. SE MANTIENE.
    - Lee process.env.TAX_MODE en caliente. SE MANTIENE, se aísla en config.ts.
    - Muta el objeto `pedido` recibido. SE ELIMINA: ningún consumidor depende de
      ello, verificado en los 4 call sites.
    
    ## Deuda congelada a propósito
    - LEG-14: redondeo con Math.floor en la línea 412.
    - LEG-15: cupón caducado devuelve descuento 0 en vez de error.
    
    ## Fuera de alcance
    Modelo de datos, endpoints, UI de checkout, migración a bigint.
    
    ## Criterio de aceptación
    Los 6 tests de caracterización pasan sin modificar sus snapshots.
    El test de equivalencia legacy/refactor pasa en las 72 combinaciones.
    

    Es corta a propósito. Y es lo que le das al agente en cada task, no el fichero de 1.100 líneas.

    El formato completo lo tienes en el libro de Spec-Driven Development. Para el esqueleto uso el skill dominicode-sdd-creator, que genera spec.md + plan.md + tasks.md; el contenido brownfield lo pones tú, porque sale de los tests.

    Si dudas de cuánta ceremonia merece el módulo, el criterio está en los tres niveles de SDD. Un refactor de legacy con dinero de por medio es nivel alto, sin discusión.

    Paso 4 — Plan por fases, tasks pequeñas, un commit verde cada una

    El plan de un refactor brownfield tiene siempre la misma forma:

    1. Aislar. Extraer funciones puras sin cambiar la lógica. Copiar, no reescribir.
    2. Tipar los bordes. Con los tipos reales del paso 1, no los declarados.
    3. Sustituir por partes. La implementación nueva convive con la vieja mientras dure.
    4. Borrar el legacy. Cuando la equivalencia lleve dos semanas en verde.

    La fase 3 es la que necesita andamio. Copia el original a pricing.legacy.ts, deja pricing.ts para la implementación nueva, y este es todo el andamio:

    // pricing.equivalence.test.ts
    import { describe, it, expect } from 'vitest'
    import { calcularPrecioFinal as legacy } from '../src/pricing.legacy'
    import { calcularPrecioFinal as refactor } from '../src/pricing'
    
    const subtotales = [0, 9.99, 100, 1234.56]
    const cupones = [null, 'VIP10', 'OLD20']
    const paises = ['ES', 'US', 'DE']
    const socios = [true, false]
    
    describe('legacy vs refactor — equivalencia', () => {
      for (const subtotal of subtotales) {
        for (const cupon of cupones) {
          for (const pais of paises) {
            for (const socio of socios) {
              const pedido = { subtotal, cupon, pais, socio }
              it(`${subtotal} / ${cupon ?? 'sin cupon'} / ${pais} / socio=${socio}`, () => {
                expect(refactor(pedido)).toEqual(legacy(pedido))
              })
            }
          }
        }
      }
    })
    

    72 combinaciones que el agente ejecuta solo cada vez que cierra una task. Y ojo: si el test sale intermitente no tienes un problema de refactor, tienes un Date.now() o un Math.random() sin inyectar. Arréglalo antes de seguir.

    Regla de tamaño de task: si el diff no lo puedes leer entero en diez minutos, pártela. El límite no lo pone el agente, lo pone tu capacidad de revisar lo que produjo — que es el verdadero cuello de botella de trabajar con agentes.

    Paso 5 — Qué haces cuando un test se pone rojo

    Un test de caracterización en rojo tiene tres causas. Míralas en este orden.

    Uno: el refactor rompió algo. Nueve de cada diez veces, por mi experiencia. Revierte la task, no la parchees: el diff es pequeño precisamente para que revertir sea barato.

    Dos: el refactor arregló un bug sin querer. Pasa más de lo que parece y es una trampa. Revierte igual y arréglalo en su propio commit, con su snapshot actualizado. Un cambio de comportamiento colado dentro de un refactor pasa desapercibido en la review casi siempre.

    Tres: el test no era determinista. Fechas, aleatoriedad, orden de un Object.keys, zona horaria. Eso no es caracterización, es ruido. Arréglalo en el test o inyecta la dependencia.

    La regla que resume el paso 5 entero: un refactor nunca cambia comportamiento, y un cambio de comportamiento nunca se llama refactor. Commits distintos, PRs distintas, riesgos distintos.

    Este bucle es el mismo que aplico en TDD potenciado por IA, solo que en legacy los tests no los escribes para diseñar: los escribes para tener permiso a tocar.

    Cómo empezar a refactorizar legacy con Claude Code el lunes

    Coge el fichero que todo el mundo evita en tu repo. No lo refactorices. Haz solo esto, y no tardas más de una hora.

    Escribe en una línea qué entra y qué queda fuera. Lanza a Claude Code el prompt de arqueología del paso 1 en modo lectura. Y escribe cinco tests de caracterización con los casos que ya te sabes de memoria, porque son los que se rompen cada trimestre.

    El lunes no refactorizas nada. El martes ya puedes, y con red.

    Cuando quieras montar la verificación en serio — el AGENTS.md, los carriles del agente y los criterios que se comprueban solos — está en el ebook gratuito de Revisión por Contrato. Y el ciclo completo de idea a producto con Claude Code ejecutando tasks es el recorrido del curso Construye con IA.

    El código legacy no da miedo por antiguo. Da miedo porque no sabes qué hace. Y eso se arregla escribiendo tests, no reescribiendo código.

    Preguntas frecuentes

    ¿Qué es un test de caracterización y en qué se diferencia de un test unitario normal?

    Un test unitario afirma que el código hace lo correcto. Un test de caracterización afirma que sigue haciendo lo mismo que antes, sea correcto o no. No lo escribes a mano: ejecutas el módulo con entradas reales y registras la salida en un snapshot. Su único trabajo es ponerse rojo cuando el refactor cambia una salida observable.

    ¿Merece la pena congelar un comportamiento que sé que es un bug?

    Sí, siempre. Si arreglas el bug en el mismo commit en el que reestructuras el código y algo revienta en producción, no podrás distinguir cuál de las dos cosas lo rompió. Congélalo con un comentario que explique la sospecha y su ticket, y arréglalo después en un commit propio donde el cambio de snapshot sea la parte visible de la pull request.

    ¿Cuánto código legacy le puedo dar a Claude Code de una vez?

    Menos del que cabe. El límite útil no es la ventana de contexto, es lo que tú puedes verificar después. Yo trabajo módulo a módulo y en cada task le paso la spec brownfield y los tests, no el fichero original. Una vez documentado el comportamiento en el paso 1, ese documento sustituye al código fuente como contexto.

    ¿Puedo saltarme los tests de caracterización si el módulo ya está tipado con TypeScript estricto?

    No. Los tipos garantizan la forma del dato, no el valor. Un refactor que cambia Math.floor por Math.round, que invierte el orden de dos descuentos o que redondea antes en vez de después compila perfecto, pasa el type-check y factura mal. Los tipos protegen el contrato; los tests de caracterización protegen el comportamiento.

    ¿Y si el módulo legacy no se puede ejecutar de forma aislada?

    Entonces esa es tu primera task, y no es refactorizar. Si no puedes invocar la función sin levantar media aplicación, lo que falta es una costura: inyectar la base de datos, el reloj y las llamadas HTTP para poder ejecutarla con entradas controladas. Feathers lo llama seam. Hasta que no consigues ejecutar el módulo con entradas que tú decides, no hay tests de caracterización posibles ni refactor seguro.

    ¿Sirve este método si el módulo legacy no está en TypeScript?

    Sí, el orden no cambia. Lo único que necesitas es un runner con snapshots: pytest con syrupy en Python, ApprovalTests en Java o C#, o el propio Vitest si es JavaScript sin tipar. Lo que sí cambia es el paso de tipar los bordes: sin tipos estáticos pierdes la red del compilador y el peso recae entero sobre los tests de caracterización, así que conviene capturar más casos de los que capturarías en TypeScript.


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

  • SDLC context engineering: arregla el ciclo, no el prompt

    SDLC context engineering: arregla el ciclo, no el prompt

    El mismo agente. El mismo modelo. Prácticamente el mismo prompt.

    En uno de mis repositorios la tarea salió a la primera. En el otro, el agente se inventó un helper que no existía y escribió los tests con una librería que ese proyecto abandonó hace más de un año.

    No falló el modelo. Falló todo lo que había alrededor del modelo.

    Y eso que hay alrededor tiene nombre: SDLC context engineering. Tu ciclo de desarrollo es la fábrica del contexto que consume el agente, y si la fábrica va mal, da igual cómo escribas el prompt.

    El primer repositorio tiene un CLAUDE.md con las convenciones escritas, una carpeta de decisiones de arquitectura y un índice del contenido previo que el agente puede consultar. El segundo tiene un README viejo y el resto vive en mi cabeza.

    Y ahí está el problema: cuando el contexto vive en tu cabeza, el agente tiene que adivinarlo. Adivinar, en un modelo de lenguaje, se llama alucinar.

    Por eso llevo meses insistiendo en lo mismo: el prompt no es la unidad de contexto. Puedes escribir el prompt más elaborado del mundo, con sus tres adjetivos y su frase en mayúsculas, que si la información que necesita el agente no existe en ningún sitio legible, no va a aparecer porque tú se lo pidas con más énfasis.

    El contexto no se escribe en el prompt. Se fabrica antes, en tu ciclo de desarrollo.

    Opero Dominicode solo: cursos, libros, una plataforma y un canal. No tengo un equipo que rellene los huecos por mí, así que los huecos los tengo que cerrar en el proceso. De ahí sale la idea que más ha cambiado mi forma de trabajar en el último año:

    Tu SDLC no es un proceso para humanos. Es la cadena de montaje que fabrica el contexto que consumen tus agentes.


    Qué es el SDLC context engineering

    El SDLC context engineering es tratar tu ciclo de vida del software como el sistema que fabrica el contexto que consumen tus agentes de IA.

    Cada una de las cinco fases —requisitos, diseño, implementación, code review y documentación— deja de producir artefactos para humanos y pasa a producir artefactos que una máquina puede leer, verificar y ejecutar: una spec con límites, un esquema de validación, una suite de tests como gate, un diff acotado y contexto versionado en el repositorio.

    La diferencia con el prompt engineering es de capa. El prompt engineering optimiza la instrucción de un turno. El SDLC context engineering optimiza la información que esa instrucción tiene disponible, y esa información la produce tu proceso, no tú en el momento de escribir.


    Qué cambia cuando el que lee el proceso es una máquina

    Cuando el que lee tu proceso es una máquina cambia el destinatario de cada artefacto: deja de valer lo que un humano completa con conocimiento implícito y solo cuenta lo que cabe en la ventana de contexto.

    El ciclo de vida clásico producía artefactos para personas: un ticket de tres líneas, la foto de una pizarra, un hilo de Slack, una reunión de refinamiento.

    Todo eso funciona con humanos por una razón que casi nunca decimos en voz alta: una persona rellena los huecos con conocimiento implícito. Sabe que en este proyecto los servicios van en esa carpeta. Sabe que ese campo del modelo está deprecado aunque siga ahí. Y, sobre todo, sabe a quién preguntar cuando algo no cuadra.

    Un agente no tiene a quién preguntar. Solo tiene lo que le entre por la ventana de contexto.

    Así que cada fase de tu ciclo tiene dos versiones posibles: la que produce algo para un humano y la que produce algo que una máquina puede leer, verificar y ejecutar.

    # Fase del ciclo Artefacto para humanos Artefacto para agentes
    1 Requisitos Ticket de 3 líneas spec.md con límites explícitos
    2 Diseño Diagrama en una pizarra Contratos ejecutables que validan
    3 Implementación "En mi máquina funciona" Tests como gate de salida
    4 Code review "A mí me parece bien" Diff acotado + auditor automático
    5 Documentación Wiki de hace tres años Contexto versionado en el repositorio

    La columna de la derecha es tu context engineering. No es un documento aparte que escribes el viernes por la tarde: es el residuo natural de un ciclo bien montado.

    Vamos fase por fase.


    1. Requisitos: del ticket de tres líneas al spec.md

    Qué falla: un ticket ambiguo no le da al agente lo único que de verdad necesita. Y no es la descripción de la funcionalidad: son los límites.

    Esta es la frase que más repito y la que más discusión genera: el alcance no es lo que el agente tiene que hacer, es lo que el agente no puede tocar.

    Un agente al que le pides "añade validación al formulario de registro" y no le dices nada más, se expande. Toca el modelo de datos porque le pareció que hacía falta. Refactoriza el componente de al lado porque estaba feo. Añade una dependencia. Y cuando abres el diff, tienes once archivos modificados y ninguna forma rápida de saber cuáles querías.

    Una spec no necesita ser larga. Una página con cuatro bloques:

    • Contratos de datos. La forma exacta de lo que entra y lo que sale.
    • Archivos afectados. Las rutas concretas que se pueden tocar.
    • Fuera de alcance. Lo que no se toca, escrito explícitamente.
    • Criterio de terminado. Qué comando tiene que pasar en verde.

    Con esos cuatro bloques, una spec entera te cabe en la pantalla:

    # Spec — Validación del formulario de registro
    
    ## Contratos
    - Entrada: { email: string, password: string, acceptedTerms: boolean }
    - Salida: { ok: true } | { ok: false, errors: FieldError[] }
    
    ## Archivos afectados
    - src/features/auth/register-form.tsx
    - src/features/auth/register.schema.ts
    
    ## Fuera de alcance
    - No tocar el modelo de usuario ni las migraciones
    - No añadir dependencias nuevas
    - No refactorizar componentes vecinos
    
    ## Terminado cuando
    - `bun test src/features/auth` pasa en verde
    - `tsc --noEmit` sin errores
    

    Ese tercer bloque es el que mejor retorno da de todo el documento, y es el que casi nunca veo escrito.

    Improvisar aquí no sale gratis, y el coste se puede calcular turno a turno: lo hice en la factura del vibe coding. Si tus specs ya existen pero el agente sigue desviándose, el problema suele estar en uno de estos 7 fallos. Y si quieres saber hasta dónde llevar el enfoque, están los tres niveles de Spec-Driven Development.

    La metodología completa, con las plantillas que uso a diario, está en el libro de Spec-Driven Development.


    2. Diseño: del diagrama en la pizarra a contratos ejecutables

    Qué falla: si la forma de tus datos vive dispersa por el código, el agente inventa propiedades. Y las inventa con una seguridad absoluta, porque estadísticamente user.email es un campo muy razonable aunque en tu proyecto se llame user.contactAddress.

    La solución no es documentar los tipos en un wiki. Es que la definición y la verificación sean el mismo artefacto.

    Un esquema de validación —Zod 4 en el ecosistema TypeScript, pero el principio vale para cualquier stack— hace tres cosas a la vez:

    • Describe la forma de los datos en un sitio único y localizable.
    • La comprueba en ejecución, así que si la descripción miente, algo se rompe y te enteras.
    • Genera el tipo con z.infer, así que la definición y la verificación salen del mismo artefacto y se actualizan a la vez.

    Esa segunda parte es la que lo convierte en contexto fiable. Un diagrama puede quedarse obsoleto en silencio durante dos años. Un esquema que se ejecuta, no: cuando un campo obligatorio cambia de tipo o desaparece, revienta y te enteras.

    Con un matiz que conviene saber, porque es donde la gente se confía: por defecto z.object() descarta las claves que no conoce en lugar de fallar. Si la API empieza a devolver campos nuevos, tu esquema los tira sin decir nada. Para que esa deriva también haga ruido necesitas z.strictObject(). El esquema te protege del campo que falta; del campo que sobra, solo si se lo pides.

    Y no confundas una cosa con la otra: los tipos de TypeScript desaparecen al compilar y no validan nada en ejecución. Evitan bugs antes de desplegar, que no es poco, pero el que comprueba lo que entra de verdad por la API es el esquema.

    Estos patrones —esquemas como contrato, inferencia de tipos y validación en los bordes— son los que desarrollo en el curso de Zod para TypeScript.

    La otra mitad del diseño es el acceso. En vez de pegar el esquema de tu base de datos dentro del prompt cada mañana, expones la fuente y dejas que el agente la consulte cuando la necesite. Eso es lo que resuelven los servidores de Model Context Protocol: el contexto deja de ser algo que copias y pasa a ser algo que se consulta.

    Eso sí, cada servidor que conectas mete sus definiciones de herramientas en la ventana. MCP cambia copiar por consultar, no elimina el coste de contexto: conecta los que uses, no los que tengas.


    3. Implementación: del "en mi máquina funciona" al gate de salida

    Qué falla: preguntarle al agente si ha terminado.

    Te va a decir que sí. No porque mienta, sino porque no tiene forma de saberlo: está evaluando su propio trabajo con exactamente el mismo contexto con el que lo escribió. Si le faltaba una pieza para escribirlo bien, le sigue faltando para revisarlo.

    Necesitas una señal que venga de fuera del modelo. Y la señal más barata que existe es un código de salida.

    El bucle que uso:

    1. El agente escribe primero el test que falla.
    2. Escribe el código mínimo para que pase.
    3. El pipeline ejecuta tipado, tests y lint. Si sale 0, la tarea entra en la cola de revisión. Si no, el agente recibe el error y corrige sin que yo intervenga.

    El gate no tiene que ser un pipeline entero. Un script que encadene los tres comandos ya sirve: si devuelve 0, la tarea pasa; si no, el agente recibe el error y sigue solo.

    {
      "scripts": {
        "gate": "tsc --noEmit && bun test && bun run lint"
      }
    }
    

    Lo importante no es que sea TDD de manual. Es que la condición de parada la decide un proceso externo y no una frase del agente. Mientras la puerta de calidad seas tú leyendo la terminal, no has automatizado nada: solo has cambiado de sitio el cuello de botella.

    El flujo completo de validar código generado antes de mergear lo desarrollé en TDD con IA. Y si lo que quieres es probar al propio agente en CI —no solo al código que produce— eso es un test harness, que es una pieza distinta.

    Y ojo con el nivel de la suite, porque aquí hay un efecto perverso: unos tests flojos no son neutros. Le dan al agente permiso para dar por terminado un trabajo a medias, con la ventaja de que ahora el sello de aprobado es automático.


    4. Code review: del "a mí me parece bien" al diff acotado

    Qué falla: el volumen. Un agente produce en veinte minutos más código del que puedes revisar con atención en una tarde.

    Y aquí hay una trampa que cuesta ver: la calidad de tu code review se decide en la fase 1, no en la fase 4. Un diff de once archivos es muy difícil de auditar bien, y la razón por la que toca once archivos es que la spec no dijo cuáles no tocar. Cuando el alcance está escrito, el diff sale acotado solo, y revisarlo pasa de ser una tarde a ser un rato.

    Con el diff ya acotado, la revisión se reparte en dos filtros:

    • El automático, primero. Tipado, tests, lint y un auditor que mire el diff antes que tú. Lo que no pasa esos gates no llega a tus ojos. Cómo montarlo en el pipeline lo detallé en revisiones de código con IA en CI/CD.
    • El tuyo, después, y solo para lo que la máquina no puede ver. Que la abstracción elegida sea la correcta. Que no haya duplicado algo que ya existía. Que el error se maneje donde tiene sentido y no donde resultaba cómodo.

    Esa segunda lista es más larga de lo que parece, y hay fallos del código generado por IA que un code review directamente no ve. Los tests cubren la corrección. Tú cubres el criterio.

    El checklist que uso para auditar diffs generados por IA antes de mergear está en el ebook gratuito Revisión por Contrato.


    5. Documentación: del wiki muerto al contexto versionado

    Qué falla: guardar la arquitectura en herramientas que el agente no puede abrir.

    El contexto tiene que vivir en el repositorio, al lado del código y bajo control de versiones, en cuatro capas de artefactos de contexto para agentes que hacen cosas distintas:

    • CLAUDE.md o AGENTS.md en la raíz. Convenciones, comandos de build, qué no se toca. AGENTS.md es un formato abierto supervisado por la Agentic AI Foundation, bajo la Linux Foundation, y lo usan ya más de 60.000 proyectos open source. Es lo primero que lee el agente al arrancar y lo que evita la mayoría de los "esto no va aquí".
    • docs/adr/ con decisiones de arquitectura. Markdown ligero que explica por qué se decidió algo, no solo qué se decidió. Sin el porqué, el agente deshace tus decisiones creyendo que mejora el código.
    • Un índice consultable del conocimiento previo. Para que pueda buscar en lo que ya existe sin que le metas el proyecto entero en la ventana.
    • Un mapa de dependencias del repositorio. Qué depende de qué. Es la diferencia entre un agente que cambia una función y otro que sabe qué se rompe al cambiarla, y va de graph engineering.

    Con una advertencia importante, porque es el error clásico de quien descubre esto: más contexto no es mejor contexto. Llenar la ventana de documentación irrelevante degrada las respuestas igual que no tener nada, solo que gastando más. Cómo estructurar esa memoria para que sume está en context engineering aplicado a agentes, y qué pasa cuando la conversación se alarga demasiado, en context drift.


    La regla del eslabón más débil de tu SDLC

    La regla del eslabón más débil dice que tu ciclo rinde lo que rinda su fase peor: da igual lo bien que hagas las otras cuatro, el resultado del agente lo marca la fase rota.

    Por eso esta es la parte práctica, la que decide por dónde empezar mañana:

    • Specs impecables sin gate de tests: el agente escribe muy rápido algo que nadie valida.
    • Tests excelentes con tickets ambiguos: validas a la perfección la funcionalidad equivocada.
    • Todo bien montado y el conocimiento en tu cabeza: cada mañana empiezas de cero.

    Así que no empieces por la fase que más te apetece, que suele ser la que ya haces bien. Empieza por la que te está costando dinero ahora mismo. Este diagnóstico lo resuelve en un minuto:

    Lo que te pasa con el agente Fase que tienes rota
    Se sale del alcance y toca archivos que no debía 1. Requisitos
    Inventa campos, funciones o rutas que no existen 2. Diseño
    Dice que ha terminado y no funciona 3. Implementación
    Los diffs son tan grandes que no los revisas 4. Code review
    Repite errores que ya corregiste la semana pasada 5. Documentación

    Ese último síntoma es el más frecuente y el que más gente confunde con un problema de memoria del modelo. No lo es. Es que la corrección se quedó en el chat en vez de acabar en un archivo del repositorio.


    Lo que no debes hacer

    Documentarlo todo.

    Es la reacción típica cuando alguien entiende esta idea: se pasa un fin de semana escribiendo un CLAUDE.md de cuarenta secciones y una carpeta de ADRs preciosa. Tres meses después, la mitad ya no es verdad.

    Y contexto desactualizado es peor que no tener contexto, porque el agente lo obedece. Un archivo que dice que los servicios van en una carpeta que ya no existe no es un documento inútil: es una instrucción activa para hacerlo mal.

    La regla que aplico: si no lo vas a mantener, no lo escribas. Es preferible un archivo de quince líneas verdaderas que uno de doscientas donde no sabes cuáles siguen siéndolo.

    Tampoco todo proyecto necesita este aparato montado. Hay casos concretos en los que el enfoque de spec te frena, y conviene reconocerlos antes de meter ceremonia donde no hace falta.


    Los 3 cambios para tu próximo ticket

    No hace falta rehacer la metodología de tu equipo. En la próxima tarea que delegues:

    1. Escribe el "fuera de alcance". Una línea diciendo qué archivos no debe tocar el agente. Es el cambio con mejor retorno de esta lista.
    2. Pon un gate automático. Aunque sea solo tsc --noEmit y los tests. Que la respuesta a "¿ha terminado?" la dé un código de salida y no una frase.
    3. Mueve una convención de tu cabeza al repositorio. Una. La que más veces has tenido que repetirle al agente esta semana.

    Con esos tres, el siguiente prompt que escribas tiene muchas más probabilidades de salir a la primera sin que le cambies ni una palabra. Porque no habrás mejorado el prompt: habrás mejorado la fábrica que lo alimenta. Eso es SDLC context engineering.

    El flujo completo, de la idea al producto con herramientas agénticas, lo enseño paso a paso en el curso Construye con IA con Claude Code.

    Y si quieres ver los artefactos reales —specs, gates y archivos de contexto de proyectos que están en producción— eso es lo que compartimos cada semana en Dominicode Labs.

    Deja de buscar el prompt mágico. Arregla la fase que tienes rota y el contexto se arregla solo.


    Preguntas frecuentes

    ¿Qué es exactamente el SDLC context engineering?

    Es tratar tu ciclo de vida del software como el sistema que fabrica el contexto de tus agentes de IA. En lugar de escribir prompts cada vez más largos, haces que cada fase del ciclo —requisitos, diseño, implementación, revisión y documentación— deje un artefacto que una máquina pueda leer y verificar: una spec con límites, un esquema de validación, una suite de tests, un diff acotado y contexto versionado en el repositorio.

    ¿Esto no es lo mismo que el prompt engineering?

    No, y la diferencia es de escala. El prompt engineering trabaja sobre la instrucción concreta que escribes en un turno. El context engineering trabaja sobre la información que esa instrucción tiene disponible, y esa información la produce tu proceso, no tú en el momento de escribir. Un buen prompt sobre un ciclo roto sigue dando resultados malos, solo que con mejor redacción.

    ¿Por dónde empiezo si tengo las cinco fases mal?

    Por el síntoma que estés sufriendo ahora, no por el orden numérico. Si el agente se sale del alcance, empieza por la spec. Si inventa campos que no existen, por los contratos de datos. Si dice que ha terminado y no funciona, por el gate de tests. La cadena rinde lo que rinda su fase peor, así que arreglar la que más te está costando da más retorno que mejorar la que ya funciona.

    ¿Hace falta usar Spec-Driven Development para esto?

    No es obligatorio, pero la fase de requisitos es la que más impacto tiene sobre las otras cuatro, y SDD es la forma más ordenada de resolverla. Puedes empezar con algo mucho más ligero: una línea de "fuera de alcance" en el ticket ya cambia el comportamiento del agente. Y hay casos concretos en los que el enfoque de spec te frena en vez de ayudarte, así que conviene reconocerlos antes de montar ceremonia.

    ¿Cuánto contexto es demasiado contexto?

    El que no puedas mantener actualizado. Un archivo de contexto que ya no refleja la realidad no es neutro: el agente lo obedece y hace las cosas mal con total seguridad. La medida correcta no es cuántas páginas tienes, sino cuántas líneas puedes garantizar que siguen siendo ciertas hoy. Además, llenar la ventana de contexto irrelevante degrada las respuestas y encarece cada turno.

    ¿Qué documentación para agentes de IA hace falta de verdad en un repositorio?

    Cuatro capas y nada más: un CLAUDE.md o AGENTS.md en la raíz con convenciones y comandos, una carpeta docs/adr/ con el porqué de las decisiones de arquitectura, un índice consultable del conocimiento previo y un mapa de dependencias del repositorio. Todo versionado junto al código. Lo que no esté en el repositorio, el agente no lo puede abrir.


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

  • Self-healing code en agentes TypeScript: el bucle que sí corrige

    Self-healing code en agentes TypeScript: el bucle que sí corrige

    El self-healing code en agentes de TypeScript es un patrón sencillo de describir y fácil de implementar mal. Te cuento primero cómo me enteré.

    Un agente mío se pasó tres minutos razonando una tarea, escribió ochenta líneas de TypeScript, llamó a la API interna y devolvió el objeto con userId donde el schema pedía id.

    Una palabra.

    El pipeline hizo lo que hacen todos: lanzó la excepción, abortó con código de salida 1 y me mandó un aviso para que abriera el editor y cambiara esa palabra a mano.

    Lo absurdo es que Zod ya sabía exactamente qué había fallado. Sabía el campo, el tipo recibido, el tipo esperado y la ruta dentro del objeto. Tenía el diagnóstico completo escrito en una estructura de datos. Y con todo eso en la mano, el sistema decidió despertar a un humano.

    Así que monté el bucle de autocorrección. Y durante dos semanas no funcionó, gastando el doble de llamadas al modelo, por un motivo que no vi hasta que abrí el objeto de error con el debugger.

    Resumen rápido:

    • El self-healing convierte el diagnóstico de un verificador determinista en el contexto del siguiente intento, en vez de escalar a un humano.
    • Si usas generateObject del Vercel AI SDK, el detalle del fallo no está en error.message — está en error.cause. Ese es el error que arruina la mayoría de implementaciones.
    • Techo de dos intentos totales, y cuenta intentos, no reintentos.
    • No todos los errores son curables: los de tipos y schema sí, los de credenciales o herramienta caída no.

    Qué es el self-healing code (y qué no es)

    El self-healing code es un patrón en el que el sistema que genera —código o datos estructurados— ejecuta un verificador determinista, captura el diagnóstico exacto del fallo y lo reinyecta como contexto en un reintento acotado, en lugar de tratar el fallo como terminal.

    La idea de fondo: el validador es el mejor prompt que vas a escribir en tu vida, porque es el único que describe el fallo con precisión de campo y sin ambigüedad.

    El bucle tiene cinco pasos:

    1. Generar. El modelo produce el objeto o el código.
    2. Verificar. Zod, tsc o el test runner dictaminan. Sin intervención humana y sin LLM de por medio: determinista.
    3. Extraer el diagnóstico real. Campo, ruta, tipo esperado, tipo recibido. Aquí es donde falla casi todo el mundo.
    4. Reinyectar. El diagnóstico vuelve como turno nuevo de la conversación, junto a la salida anterior.
    5. Acotar. Techo de intentos y salida limpia cuando se agota.

    Conviene separarlo de dos patrones vecinos con los que se confunde.

    No es un retry con backoff. El backoff reintenta lo mismo esperando que el mundo cambie: que se descongestione la red, que el proveedor se recupere. El self-healing reintenta algo distinto, porque le has añadido información que antes no estaba. Si reintentas idéntico un fallo de validación, el modelo suele reproducir el mismo error.

    No es un circuit breaker. El breaker existe para dejar de insistir cuando una herramienta externa lleva minutos caída; lo conté en circuit breaker para agentes IA. Son capas distintas: el breaker mira la salud de un servicio externo, el self-healing mira la forma de lo que devuelve el modelo. En un agente serio acaban conviviendo.

    Y una frontera más: este post va del bucle. De cómo validar y tipar la respuesta en sí ya escribí en cómo tipar las respuestas de una LLM con Zod y TypeScript. Si no tienes esa parte montada, empieza por ahí y vuelve.


    El error que hace que tu bucle de autocorrección no sirva de nada

    Aquí está lo que me costó dos semanas.

    Cuando usas generateObject del Vercel AI SDK y el modelo devuelve algo que no valida, el SDK lanza un NoObjectGeneratedError. La reacción natural es esta:

    catch (error: any) {
      prompt = `Tu respuesta anterior falló con este error: ${error.message}`;
    }
    

    Ese código se ejecuta sin romperse, el bucle gira, gastas otra llamada al modelo y parece que el patrón funciona.

    No funciona. El message de un NoObjectGeneratedError es genérico —del tipo "No object generated"— y no lleva el campo, ni el tipo esperado, ni la ruta. Le estás diciendo al modelo "lo has hecho mal" y esperando que adivine el qué.

    El detalle está en otras propiedades del error, documentadas en el propio AI SDK:

    • error.cause — el error subyacente real: el ZodError con sus issues, o el fallo de parseo de JSON.
    • error.text — el texto crudo que el modelo llegó a generar, que le permite ver su propia salida y compararla con el diagnóstico.
    • error.finishReason — si vale 'length', el JSON no es inválido por confusión del modelo: está truncado porque se acabaron los tokens. Reintentar con el mismo límite es tirar dinero; ahí toca subirlo o partir el schema.

    Ese último matiz es la diferencia entre un bucle que corrige y un bucle que solo encarece la factura.

    Hay un segundo fallo igual de común, y es de contexto. Si en el reintento reasignas el prompt en vez de acumular la conversación, el modelo recibe "tu respuesta anterior falló, corrígela" sin la tarea original y sin su propia salida. generateObject no guarda historial: cada llamada es independiente. El modelo no sabe qué tenía que generar ni qué generó. No hay nada que corregir.


    Implementación del bucle en TypeScript

    Con eso claro, el bucle queda así. Versiones: AI SDK 5 y Zod 4.

    import { generateObject, NoObjectGeneratedError } from "ai";
    import { anthropic } from "@ai-sdk/anthropic";
    import { z } from "zod";
    
    const PaymentConfigSchema = z.object({
      customerId: z.string().min(5).describe("ID del cliente, prefijo cus_"),
      amountInCents: z.number().int().positive().describe("Importe en céntimos, nunca decimal"),
      currency: z.enum(["EUR", "USD"]),
      maxPaymentRetries: z.number().int().min(1).max(5),
    });
    
    type PaymentConfig = z.infer<typeof PaymentConfigSchema>;
    
    // Intentos TOTALES, no reintentos: 2 = la primera llamada y una corrección.
    const MAX_ATTEMPTS = 2;
    
    export async function generateSelfHealingConfig(
      userRequirement: string,
    ): Promise<PaymentConfig> {
      const messages: Array<{ role: "user" | "assistant"; content: string }> = [
        { role: "user", content: `Genera la configuración de pago para: "${userRequirement}"` },
      ];
    
      for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
        try {
          const { object } = await generateObject({
            model: anthropic("claude-opus-5"),
            schema: PaymentConfigSchema,
            messages,
            // Clave: el maxRetries del SDK son reintentos de TRANSPORTE (429, 5xx)
            // y vale 2 por defecto. Sin ponerlo a 0, cada vuelta de este bucle
            // puede disparar hasta 3 peticiones HTTP: 6 llamadas en el peor caso.
            maxRetries: 0,
          });
          return object;
        } catch (error) {
          if (!NoObjectGeneratedError.isInstance(error)) throw error; // 401, red, bugs propios
    
          // Truncado por tokens: reintentar igual no arregla nada.
          if (error.finishReason === "length") {
            throw new Error(
              "[SELF-HEALING] Respuesta truncada por límite de tokens: súbelo o parte el schema.",
            );
          }
    
          if (attempt === MAX_ATTEMPTS) {
            throw new Error(
              `[SELF-HEALING] Sin corregir tras ${MAX_ATTEMPTS} intentos:\n${formatIssues(error.cause)}`,
            );
          }
    
          // El feedback útil: su salida + el diagnóstico concreto, como turno nuevo.
          messages.push({ role: "assistant", content: error.text ?? "(sin salida)" });
          messages.push({
            role: "user",
            content:
              `Tu respuesta no cumple el schema:\n${formatIssues(error.cause)}\n\n` +
              `Corrige ÚNICAMENTE esos campos y devuelve el objeto completo.`,
          });
        }
      }
    
      throw new Error("[SELF-HEALING] Bucle terminado sin resultado");
    }
    
    // El diagnóstico en tres líneas, no el volcado entero.
    function formatIssues(cause: unknown): string {
      if (cause instanceof z.ZodError) {
        return cause.issues
          .map((i) => `· ${i.path.join(".") || "(raíz)"}: ${i.message}`)
          .join("\n");
      }
      return cause instanceof Error ? cause.message : String(cause);
    }
    

    Tres decisiones que no son cosméticas.

    maxRetries: 0. Es la que más gente se salta. El maxRetries del AI SDK vale 2 por defecto y cubre fallos de transporte —408, 409, 429 y 5xx— con backoff exponencial. Son compatibles con este bucle, pero se multiplican: dos vueltas tuyas por tres peticiones suyas son seis llamadas donde creías tener dos. Si quieres backoff de red, ponlo tú fuera y controla el total.

    El error vuelve como turno de conversación. Al empujar la salida fallida como mensaje assistant y la corrección como user, el modelo ve su propio intento enfrentado al diagnóstico. Reasignar el prompt original pierde ese contraste — es el segundo fallo que veíamos arriba.

    formatIssues recorta. Un ZodError serializado entero son cientos de caracteres de ruido que pagas en cada vuelta. Las issues mapeadas a ruta: mensaje son las tres líneas que importan.

    Si quieres exprimir la parte del schema —.describe(), uniones discriminadas, enums en vez de strings abiertos— es lo que trabajo en el curso de Zod para TypeScript. Un schema bien diseñado reduce cuántas veces entras en este bucle, que sigue siendo el mejor ahorro disponible.


    Las tres reglas para que esto no se convierta en un bucle infinito caro

    1. Pásale el diagnóstico, no el volcado

    Cinco mil caracteres de stack trace con trazas internas de Node entierran la señal en ruido, y encima los pagas en cada vuelta. Ruta, mensaje y tipo esperado. Nada más.

    2. Techo estricto, y cuenta intentos, no esperanzas

    Dos intentos totales. Si un modelo actual no arregla un fallo de forma teniendo delante el error exacto, el problema casi nunca es el modelo: es un schema que pide algo que el contexto no contiene. El tercer intento no corrige, factura.

    El fallo más común es contar mal. Un for (let i = 1; i <= maxRetries; i++) con maxRetries = 2 da dos intentos totales, es decir, un solo reintento. Si querías dos correcciones, el bucle se te queda corto y no te enteras. Por eso arriba la constante se llama MAX_ATTEMPTS.

    Este techo vive dentro del límite global de pasos del agente, no lo sustituye: sobre eso escribí en el agentic loop en producción.

    3. Corrección quirúrgica, no regeneración

    Pide explícitamente que corrija solo los campos señalados. Si dejas que regenere el objeto entero, es habitual que arregle el campo roto y rompa otro que ya estaba bien — y con techo de dos intentos te quedas sin margen.


    Un oráculo por cada tipo de error

    Zod valida la forma de los datos en runtime. Es la primera capa, no la única: el patrón es idéntico cambiando quién emite el diagnóstico.

    Oráculo Qué detecta Qué le pasas al modelo
    Zod Salida estructurada que no cumple el schema issues mapeadas a ruta: mensaje
    tsc --noEmit Errores de tipos en el código generado Código de error, archivo, línea, tipo esperado vs recibido
    Vitest / Jest Errores de lógica de negocio Nombre del test y el diff esperado/recibido
    ESLint Estilo y patrones prohibidos Nada: esto se arregla con --fix, no con el modelo

    El compilador. Cuando el agente escribe código en vez de devolver datos, tsc --noEmit da diagnósticos con archivo, línea y tipos enfrentados. Un Type '{ userId: string }' is not assignable to type '{ id: string }' es la misma señal que un ZodError: precisa, accionable y gratis. Pásale las líneas del diagnóstico, no la salida completa del compilador — en un proyecto mediano son cientos de líneas y un solo error de tipos suele arrastrar diez mensajes derivados del mismo origen.

    Los tests. El compilador y Zod atrapan errores de forma; los tests atrapan errores de fondo. Un agente que ejecuta la suite, lee qué aserción falló y corrige antes de enseñarte nada es la versión completa del patrón. Es también donde el techo se vuelve innegociable: un agente iterando contra una suite en rojo sin límite es la forma más rápida que conozco de quemar presupuesto. Y hay una trampa propia de esta capa: ejecuta la suite entera antes de aceptar el parche, no solo el test que fallaba. Arreglar el test A rompiendo el B es un resultado muy común y, si solo miras A, lo das por bueno.

    Para montar el entorno donde ese ciclo corre aislado, escribí sobre el test harness para desarrollo con agentes. Y ese salto —de validar datos a montar el ciclo entero de generar, verificar y corregir— es el hilo del curso Construye con IA: de la idea al producto con Claude Code.

    Un apunte de arquitectura: el bucle queda más limpio si los fallos ya viajan como datos tipados en lugar de excepciones sueltas, algo que conté en cómo manejar errores en agentes de IA con TypeScript.


    Qué errores son curables y cuáles no

    Aplicar el bucle a todo es peor que no tenerlo. Esta es la tabla que uso para decidir:

    Tipo de fallo ¿Self-healing? Qué hacer
    Error de tipos (tsc) Sí Reinyectar diagnóstico, 1 reintento
    Schema de salida inválido Sí Reinyectar error.cause + la tarea original
    Aserción de test fallida Sí, con cuidado Reinyectar el diff y correr la suite completa
    Respuesta truncada (finishReason: 'length') No Subir el límite de salida o partir el schema
    Lint y formato No Determinista: --fix
    Tool externa 5xx o timeout No Circuit breaker, no reintento
    Credenciales, 401 No Abortar y escalar
    Requisito ambiguo No Humano en el bucle
    Operación con efectos ya aplicados No Idempotencia o compensación

    Ese último merece un párrafo. Si el primer intento escribió en base de datos o llamó a un endpoint de cobro, reintentar no es autocorregir: es duplicar. El bucle solo es seguro mientras la operación no haya salido de tu proceso. Valida primero, ejecuta después.

    Y hay un coste que conviene tener presente: cada vuelta añade la latencia completa de una llamada al modelo y paga de nuevo los tokens del contexto acumulado, que ahora incluye la salida fallida y el diagnóstico. En un flujo interactivo, a veces es mejor devolver el fallo rápido que hacer esperar el doble para acertar. Si quieres saber en qué se te va de verdad el presupuesto, medir el consumo de tokens del agente es el paso previo.


    Cuando el segundo intento también falla

    El techo implica que existe un camino de salida, y ese camino no puede ser una excepción sin contexto que alguien encuentre en un log tres días después.

    Lo que funciona: registrar el fallo con las cuatro piezas que lo hacen reproducible —la tarea original, la salida del modelo, el diagnóstico del verificador y el número de intentos consumidos— y encolarlo. Ese registro sirve para dos cosas distintas. La inmediata, que alguien lo resuelva. La útil a medio plazo, que la cola se convierte en tu mejor fuente de mejoras del schema: cuando ves tres fallos seguidos sobre el mismo campo, el problema no era el modelo.


    Por dónde empezar mañana

    Coge el punto de tu agente donde hoy salta una excepción de validación. Uno solo.

    Añade tres cosas: extrae el error real (error.cause, no error.message), formatéalo a ruta y mensaje, y devuélvelo como turno nuevo con techo de dos intentos y maxRetries: 0. Loguea cuántas veces entra en la segunda vuelta y cuántas sale con éxito.

    Ese ratio es el diagnóstico del diagnóstico. Si entra a menudo y se corrige, tienes un schema mejorable pero un bucle sano. Si entra mucho y no se corrige, tienes un schema imposible: le estás pidiendo al modelo un campo que nadie podría rellenar con el contexto que le das. Y si no entra casi nunca, enhorabuena — tu schema ya hace el trabajo y el bucle es solo la red.

    En Dominicode Labs es donde vamos rodando estos patrones sobre proyectos reales, con las métricas puestas.

    Los sistemas agénticos que aguantan en producción no son los que no se equivocan. Son los que tienen el diagnóstico a mano y saben devolvérselo al modelo antes de despertar a nadie.


    Preguntas frecuentes

    ¿Por qué mi agente no se corrige aunque le paso el error?

    La causa más común es pasar error.message en vez de error.cause. En un NoObjectGeneratedError del Vercel AI SDK, message es un texto genérico que no nombra el campo ni el tipo esperado; el diagnóstico útil vive en error.cause —el ZodError con sus issues— y la salida cruda del modelo en error.text. Con solo message, el bucle gasta llamadas sin darle al modelo nada con lo que corregir.

    ¿En qué se diferencia el self-healing code de un retry con backoff?

    En qué cambia entre un intento y el siguiente. El backoff reintenta la misma petición esperando que se recupere algo externo —red, proveedor, rate limit— y por eso funciona con fallos transitorios. El self-healing modifica la entrada: añade al contexto el diagnóstico que provocó el fallo. Ante un error de schema, el backoff solo repite el mismo error más despacio.

    ¿No reintenta ya generateObject por su cuenta con maxRetries?

    No de esta forma, y conviene ponerlo a 0. La opción maxRetries del AI SDK vale 2 por defecto y cubre fallos de transporte: errores de red y respuestas de API reintentables (408, 409, 429, 5xx) con backoff exponencial. Un fallo de validación de schema no entra ahí, se propaga como NoObjectGeneratedError. Si lo dejas por defecto, cada vuelta de tu bucle puede disparar hasta tres peticiones HTTP.

    ¿Cuántos intentos debería permitir?

    Dos totales: la llamada inicial y una corrección. Con el error exacto delante, un modelo actual corrige los fallos de forma en el primer reintento o no los corrige. Un tercero rara vez cambia el resultado y multiplica coste y latencia. Y cuenta intentos, no reintentos: un bucle i <= 2 da una sola corrección, y es donde más gente se equivoca al implementarlo.

    ¿Se puede hacer self-healing solo con el compilador, sin Zod?

    Sí, y son capas complementarias. tsc --noEmit cubre el código que el agente escribe; Zod cubre la salida estructurada que el modelo devuelve. Si tu agente genera archivos, el compilador es tu oráculo principal. Si devuelve objetos que tu aplicación consume, lo es Zod. Muchos agentes acaban usando los dos en puntos distintos del flujo.

    ¿Sirve para errores de lógica o solo para errores de tipos?

    Sirve para los de lógica, pero cambiando el oráculo: ahí el que dictamina es el test runner, y el diagnóstico que reinyectas es la aserción fallida con su diff esperado/recibido. La diferencia práctica es el riesgo. Un error de tipos tiene una única corrección posible; un test rojo admite varias, y alguna rompe otra cosa. Por eso en esta capa se ejecuta la suite completa antes de aceptar el parche.

    ¿Qué hago si el agente rompe otro test al arreglar el primero?

    Tratarlo como un fallo del intento, no como un éxito parcial. Si el criterio de aceptación es solo el test que fallaba, el bucle acepta parches que degradan el código. El criterio tiene que ser la suite entera en verde; si el parche pone A en verde y B en rojo, se descarta y se consume intento. Con techo de dos, eso normalmente significa escalar — que es la respuesta correcta.

    ¿Es seguro autocorregir una operación que ya escribió en base de datos?

    No. Si el intento fallido tuvo efectos externos, el reintento los duplica. El bucle es seguro mientras la operación no haya salido de tu proceso: valida primero, ejecuta después. Si el efecto ya ocurrió, lo que necesitas es idempotencia o compensación, no autocorrección.


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

  • Local-first con PGlite: Postgres en el navegador, ¿te toca?

    Local-first con PGlite: Postgres en el navegador, ¿te toca?

    Abro una app de notas con IA, escribo una pregunta y espero.

    No espero al modelo. Eso lo entiendo: el modelo piensa. Espero a que la app viaje al servidor para leer tres filas de mi propio historial, las traiga de vuelta y solo entonces monte el prompt. Doscientos milisegundos de red para leer datos que ya estaban en mi portátil.

    Ese es el patrón por defecto de casi todas las apps de IA que reviso. La arquitectura local-first le da la vuelta: la base de datos vive en el dispositivo del usuario, la app lee de ahí a velocidad de memoria, y el servidor pasa de ser la fuente de toda verdad a ser un destino de sincronización.

    Aquí va la tesis. Mover la base de datos al cliente no es una optimización: es un cambio de arquitectura que reparte de otra forma la latencia, el coste de tokens y la privacidad, y a cambio te devuelve tres problemas que en el servidor no tenías — una sola conexión, migraciones que corren en máquinas que no controlas, y un camino de escritura hacia el servidor que nadie te da hecho.

    Si terminas de leer sabiendo si te toca o no, el post ha cumplido.

    Qué es la arquitectura local-first

    Local-first es una arquitectura en la que la base de datos vive en el dispositivo del usuario: la aplicación lee y escribe siempre contra esa copia local, a velocidad de memoria, y el servidor deja de ser la fuente de toda verdad para convertirse en un destino de sincronización. La app funciona sin red por defecto, no como caso degradado.

    No es lo mismo que cachear. Una caché es una copia que aceleras y que puedes tirar; en local-first la copia local es donde de verdad ocurre todo, y la red es un detalle de implementación.

    Qué es PGlite y por qué no es "otra base de datos en el navegador"

    PGlite es Postgres compilado a WebAssembly y empaquetado como librería de TypeScript. Corre en el navegador, en Node.js y en Bun, sin dependencias externas y sin proceso servidor.

    La distinción que importa está en su propia documentación: "Unlike previous 'Postgres in the browser' projects, PGlite does not use a Linux virtual machine – it is simply Postgres in WASM."

    Eso no es marketing, es la decisión arquitectónica del proyecto. Los intentos anteriores emulaban una máquina Linux entera para arrancar encima un Postgres normal: pagabas el peso de un sistema operativo simulado para ejecutar un SELECT. PGlite elimina esa capa.

    Los números, para que no tengas que buscarlos:

    • PGlite pesa menos de 3 MB gzipped y corre Postgres compilado a WASM, sin máquina virtual Linux.
    • Persiste en memoria (efímero), en IndexedDB en el navegador, o en el sistema de ficheros en Node y Bun.
    • La versión actual de @electric-sql/pglite es la 0.5.8, publicada el 26 de agosto de 2026, y el plugin de sincronización @electric-sql/pglite-sync va por la 0.6.9.

    Retén ese "0.x". Vuelvo a ello más abajo.

    Qué gana una app de IA con arquitectura local-first

    Una app de IA gana tres cosas al pasar a local-first: latencia de microsegundos en lugar de un round-trip de red, menos tokens por turno porque filtras el contexto donde ya están los datos, y una garantía de privacidad real porque el historial no tiene por qué salir del dispositivo. Ninguno de los tres es "va más rápido" a secas.

    Latencia: microsegundos frente a un round-trip de red

    Los benchmarks oficiales, medidos en un MacBook Air M2 con PGlite en memoria, dan estos tiempos de ida y vuelta por operación: insert de una fila pequeña en 0,058 ms, select en 0,088 ms, update en 0,073 ms y delete en 0,145 ms.

    Compáralo con los 50-200 ms de una llamada HTTP a tu API y el cambio deja de ser cuantitativo: pasa a ser de diseño.

    Cuando leer cuesta microsegundos dejas de diseñar para minimizar consultas. Se acabó cachear tres pantallas por delante, montar endpoints agregados y pintar skeletons.

    Coste de tokens: RAG en el navegador con pgvector

    Este es el eje que más se subestima, y el que de verdad justifica local-first en una app de IA.

    El patrón habitual del chatbot es incómodo cuando lo miras de frente: mandas todo el historial al servidor en cada turno para que el backend reconstruya un contexto que ya estaba entero en el dispositivo del usuario. Pagas tokens por transportar información que no había salido de casa.

    Con un Postgres real en el cliente filtras el contexto en local —con SQL de verdad, joins y filtros por fecha— y mandas al modelo solo lo que importa. Y como PGlite soporta pgvector, ese filtro puede ser búsqueda semántica y no solo un WHERE:

    import { PGlite } from '@electric-sql/pglite'
    import { vector } from '@electric-sql/pglite-pgvector'
    
    const pg = new PGlite({
      extensions: { vector },
    })
    
    await pg.exec('CREATE EXTENSION IF NOT EXISTS vector;')
    

    La consulta por distancia de embeddings corre en el navegador y solo los cinco fragmentos relevantes viajan al modelo:

    SELECT id, content
    FROM chunks
    ORDER BY embedding <-> $1::vector
    LIMIT 5;
    

    Ese <-> es distancia euclídea; si tus embeddings vienen de OpenAI, lo habitual es coseno con <=>. Elige el operador que case con el índice que crees, no el del ejemplo que copiaste.

    Decidir qué entra en el contexto antes de escribir el primer prompt es justo el trabajo que hacemos en el curso de Construye con IA: el prompt no es el sistema, es la última capa.

    Si vienes de montar RAG en servidor, el contraste está en Búsqueda híbrida y embeddings en Supabase — misma técnica, distinto sitio. Y si aún dudas de si tu caso pide RAG, contexto o fine-tuning, esa decisión va antes que esta y la tienes en RAG vs fine-tuning vs contexto.

    Privacidad: los datos no salen del dispositivo

    En una app de notas es un argumento de venta. En salud, legal o finanzas es el requisito que decide si el proyecto existe.

    Con la base de datos en el cliente decides tú qué se sincroniza. Y puedes decidir que nada: si esa tabla no entra en ningún shape, el historial del usuario nunca toca tu infraestructura. Solo sale lo que mandas al modelo, y eso lo controlas con una consulta, no con una política de retención.

    Es la diferencia entre "prometemos que no miramos tus datos" y "no los tenemos".

    Pero ese modo máximo se paga dos secciones más abajo: sin copia en servidor no hay red de seguridad para las migraciones ni multi-dispositivo. Privacidad total y recuperación ante desastres son dos posiciones del mismo mando.

    Cómo queda la arquitectura: PGlite como fuente de verdad local

    PGlite es la fuente de verdad para la UI: la aplicación lee y escribe siempre contra la base local, nunca contra la red. El servidor mantiene su Postgres, que sigue siendo la fuente de verdad del negocio, y las escrituras llegan hasta él por un camino que montas tú. Ahora vuelvo a eso.

    Entre los dos, sincronización basada en shapes. Un shape es un subconjunto de una tabla: no te bajas messages entera, te bajas "los mensajes de las conversaciones de este usuario de los últimos 90 días". El cliente declara qué porción del mundo le interesa y el sync la mantiene al día.

    Y aquí va el matiz que decide presupuestos: ese sync va en una sola dirección. La documentación de pglite-sync no se esconde — "We don't yet support local writes being synced out, or conflict resolution" — y Electric lo remata: hace read-path sync, y no hace write-path sync.

    Traducido: el camino servidor → cliente te lo dan hecho. El camino cliente → servidor lo escribes tú. Cola de escrituras local, reintentos, orden, idempotencia y qué pintas mientras la escritura está en vuelo. Electric documenta cuatro patrones para eso, pero son patrones, no un paquete que instalas.

    Dos límites más de los shapes que conviene saber antes de diseñar: no se pueden sincronizar varios shapes sobre la misma tabla, porque una suscripción necesita poder tirar todos los datos y empezar de cero; y para garantizar consistencia transaccional los datos se agregan en memoria, lo que con shapes muy grandes se nota.

    Encima van las live queries del módulo @electric-sql/pglite/live: registras una consulta y los resultados se actualizan solos cuando cambian los datos, vengan del usuario o del sync. Sin polling y sin invalidación manual de caché.

    Ahí está el efecto secundario grande: desaparece la mitad de tu capa de gestión de estado. El estado del servidor deja de ser algo que cacheas a mano y pasa a ser una tabla que se actualiza. Esa reorganización de responsabilidades la traté en Clean Architecture en Frontend.

    Lo que se te rompe al llevar Postgres al navegador

    Llevar Postgres al navegador te devuelve seis problemas que en el servidor no tenías: una sola conexión, migraciones que corren en dispositivos ajenos, resolución de conflictos sin librería que la resuelva por ti, el peso de arranque, benchmarks que solo valen en memoria y una API todavía en 0.x. Esta es la sección que importa.

    Una sola conexión: el worker no es opcional

    La documentación lo dice sin adornos: "PGlite is single connection only". Y hay un segundo problema encima: si ejecutas PGlite en el hilo principal, bloqueas la UI.

    La solución oficial es el multi-tab worker: una única instancia de PGlite dentro de un Web Worker y una elección de líder que hace de proxy para las peticiones de todas las pestañas abiertas. Cuando la pestaña líder se cierra, se elige otra y se levanta una instancia nueva.

    El worker:

    // my-pglite-worker.js
    import { PGlite } from '@electric-sql/pglite'
    import { worker } from '@electric-sql/pglite/worker'
    
    worker({
      async init() {
        return new PGlite()
      },
    })
    

    Y el cliente:

    import { PGliteWorker } from '@electric-sql/pglite/worker'
    
    const pg = new PGliteWorker(
      new Worker(new URL('./my-pglite-worker.js', import.meta.url), {
        type: 'module',
      }),
    )
    

    Son quince líneas, pero no las trates como boilerplate. Tu base de datos vive ahora detrás de una frontera asíncrona con elección de líder, y eso condiciona cómo pruebas la app y qué ocurre en el segundo en que el usuario cierra la pestaña líder.

    Migraciones de esquema en dispositivos que no controlas

    En el servidor una migración es un evento: la lanzas, corre, se acabó. Hay una base de datos y tú tienes la llave.

    En local-first tienes N versiones del esquema repartidas por dispositivos ajenos. Un usuario abrió la app en marzo y no ha vuelto. Cuando vuelva, su base local está seis migraciones por detrás y esas seis tienen que aplicarse en orden, en su navegador, sin romperse a mitad.

    Esto es lo que se lleva por delante los planes de rollback: no puedes revertir una migración en 8.000 portátiles.

    La consecuencia práctica es que el esquema local evoluciona de forma aditiva casi siempre. Columnas nuevas, no renombradas. Tablas nuevas, no reestructuradas. Y una tabla de versión de esquema desde el día uno, antes de tener usuarios.

    La red de seguridad que sí funciona no es el rollback, es el reset. Si el servidor es la fuente de verdad del negocio, la base local es desechable: ante una migración que no aplica, la borras del dispositivo y vuelves a sincronizar los shapes desde cero. Es feo, tarda y hay que pintarlo bien, pero funciona.

    La letra pequeña: eso solo existe si hay copia en servidor. Si elegiste el modo máximo de privacidad, no hay de dónde resincronizar y cada migración es un disparo único sobre datos irrecuperables. Ahí el esquema aditivo deja de ser buena práctica y pasa a ser la única opción.

    Resolución de conflictos: aquí no hay magia

    Dos dispositivos offline. Los dos editan el mismo registro. Los dos recuperan la red.

    Sí existen librerías que deciden por ti: los CRDT de Yjs, Automerge o Loro convergen sin preguntarte. Pero convergen a una respuesta, no necesariamente a la que tu negocio considera correcta. Un CRDT te garantiza que dos dispositivos acaban iguales; no te garantiza que el saldo resultante sea el que el usuario esperaba. Esa decisión no la delegas.

    Tus opciones reales son tres: last write wins y perder ediciones en silencio, guardar ambas versiones y preguntar al usuario, o modelar los datos para que los conflictos sean estructuralmente imposibles — append-only, eventos en vez de estado, campos con un único dueño. La tercera es la buena, y es una decisión de modelado que tomas antes de escribir código.

    Su contrapartida: una base append-only crece sin techo, y eso choca con la pregunta que cierra este post — si los datos caben en el dispositivo. Compacta por antigüedad o materializa el estado cada N eventos, desde el principio.

    Un detalle que se olvida: lo que llega del sync es entrada externa y merece validarse como el body de una API. Un payload con un campo cambiado por una versión antigua del cliente puede corromper la base local del usuario, y ahí ya no tienes acceso para arreglarlo. Es el escenario exacto para el que trabajamos schemas en el curso de Zod: validar en la frontera, no confiar en el tipo.

    3 MB antes de que el usuario vea nada

    PGlite pesa menos de 3 MB gzipped, y es un coste de arranque real que pagas en el primer render.

    En una app que el usuario abre a diario se amortiza en el primer uso. En una landing con formulario es inaceptable. Cárgalo diferido, después del primer pintado, con un estado de "preparando" que no sea una pantalla en blanco.

    Los 0,058 ms son en memoria

    Aquí es donde muchos posts sobre PGlite venden humo, así que lo digo claro: los benchmarks de PGlite están medidos en memoria; en cuanto persistes a IndexedDB, la foto cambia.

    La propia documentación lo reconoce: "An fsync or flush to the underlying storage can be quite slow, particularly in the browser with IndexedDB for PGlite, or OPFS for wa-sqlite."

    Y es igual de honesta comparándose con SQLite en WASM: "wa-sqlite is faster than PGlite when run purely in memory", aunque "For single row CRUD inserts and updates, PGlite is faster then wa-sqlite", por usar Write-Ahead Log frente al rollback journal de SQLite.

    La doc avisa además de que comparar Postgres con SQLite es difícil y de que sus benchmarks son un punto de partida, no una sentencia.

    Traducción: sigue siendo órdenes de magnitud más rápido que la red, pero mide tus escrituras con persistencia activada antes de prometer nada.

    Sigue en 0.x

    @electric-sql/pglite está en la 0.5.8 y @electric-sql/pglite-sync en la 0.6.9. Pre-1.0 significa que la API puede moverse entre versiones menores.

    No es razón para descartarlo. Es razón para fijar la versión, leer los changelogs antes de actualizar y no esparcir PGlite por medio proyecto sin una capa propia delante.

    Cuándo NO usar local-first (y qué hacer en su lugar)

    La respuesta honesta es que a la mayoría de las apps no les toca.

    Tu situación Qué hacer
    App de IA de uso diario, con historial largo y propio de cada usuario Local-first con PGlite. Es tu caso.
    Datos sensibles que no deberían tocar tu servidor (salud, legal, finanzas) Local-first, y aquí es requisito, no optimización.
    Necesitas funcionar offline de verdad Local-first. No hay alternativa real.
    Datos compartidos que muchos usuarios editan a la vez Servidor. El coste de resolver conflictos se come la ganancia.
    Landing, e-commerce o cualquier app de sesión corta Servidor. 3 MB de arranque para dos consultas no sale.
    Necesitas consultar millones de filas que no caben en el cliente Servidor, con RAG clásico. Los shapes tienen un límite práctico.
    Equipo sin experiencia en sincronización de datos Servidor, hasta que el dolor justifique la curva.
    Tests de integración y CI que hoy levantan Docker con Postgres PGlite en Node o Bun. Sin migraciones ni sync, pero sigue siendo de una sola conexión.

    Esa última fila merece una nota. Aunque tu app no sea local-first, PGlite te sirve hoy en el pipeline: es un Postgres real, arranca en milisegundos y no necesita contenedor. Cambiar docker compose up por una instancia en memoria en tus tests es la puerta de entrada barata a esta tecnología. Con dos límites: al ser de una sola conexión ahí no vas a reproducir deadlocks, bloqueos entre sesiones ni el comportamiento de tu pool; y PGlite trae un catálogo concreto de extensiones, así que comprueba que las de tu esquema estén en la lista antes de tirar el Docker.

    Cómo decidir esto hoy, en diez minutos

    Responde a una sola pregunta: ¿los datos que tu IA necesita para responder son de un único usuario y caben en su dispositivo?

    Si es que sí, local-first con PGlite te saca el round-trip de red de la ruta crítica, te baja los tokens por turno y te da una historia de privacidad que tus competidores no pueden contar. Empieza por el worker, el esquema versionado, el camino de escritura y una estrategia de conflictos escrita antes de crear la primera tabla.

    Si es que no, quédate en el servidor y duerme tranquilo.

    Y si quieres ver este tipo de decisiones discutidas con proyectos reales delante, es lo que hacemos cada semana en Dominicode Labs.

    Preguntas frecuentes sobre local-first con PGlite

    ¿PGlite sustituye a mi Postgres del servidor?

    No. PGlite es un Postgres embebido de una sola conexión, pensado para vivir junto a la aplicación y no para servir a muchos clientes concurrentes. En una arquitectura local-first, PGlite es la fuente de verdad local del dispositivo y tu Postgres del servidor sigue siendo la del negocio: entre ambos hay sincronización —de servidor a cliente te la dan hecha, de cliente a servidor la montas tú—, no sustitución.

    ¿Puedo hacer RAG entero en el navegador con PGlite?

    Sí, siempre que el corpus sea del usuario y quepa en su dispositivo. PGlite soporta pgvector a través del paquete @electric-sql/pglite-pgvector, así que puedes guardar embeddings y hacer búsqueda por similitud en local sin que los documentos salgan del navegador. Lo que no puedes hacer en el cliente es RAG sobre un corpus corporativo de millones de documentos: eso sigue siendo trabajo de servidor.

    ¿Cuánto pesa PGlite y cómo afecta al arranque de la app?

    PGlite pesa menos de 3 MB gzipped. Se carga una vez y luego queda cacheado, pero es un coste real en el primer render, así que conviene cargarlo diferido después del primer pintado. En una app de uso diario se amortiza sin problema; en una página de sesión corta no compensa.

    ¿Qué pasa si el usuario abre la app en dos pestañas?

    PGlite admite una sola conexión, así que dos pestañas no pueden abrir dos instancias sobre la misma base de datos. La solución oficial es el multi-tab worker: una única instancia dentro de un Web Worker y una elección de líder que hace de proxy para todas las pestañas. Cuando la pestaña líder se cierra, se elige otra automáticamente y se levanta una instancia nueva.

    ¿Está listo para producción si sigue en 0.x?

    Depende de tu tolerancia a que la API cambie. @electric-sql/pglite está en la 0.5.8 y el plugin de sync en la 0.6.9, y pre-1.0 significa que puede haber cambios de API entre versiones menores. Hay proyectos en producción con PGlite, pero si entras, fija la versión exacta, lee los changelogs antes de cada actualización y aísla PGlite detrás de una capa propia para que un cambio de API no te toque cincuenta ficheros. Y si vas a hacer RAG en el navegador, mira el eslabón más verde de la cadena: el paquete de pgvector, @electric-sql/pglite-pgvector, va por la 0.0.9.

    ¿En qué se diferencia PGlite de IndexedDB o de SQLite en WASM?

    IndexedDB es un almacén clave-valor sin lenguaje de consultas: cualquier filtro o join lo escribes tú en JavaScript. SQLite compilado a WASM sí te da SQL y en memoria pura es más rápido que PGlite, pero es SQLite: otro dialecto y otras extensiones que las de tu servidor. PGlite es Postgres compilado a WASM sin máquina virtual Linux, así que ejecutas el mismo dialecto y un catálogo de extensiones que se solapa con el de tu servidor —pgvector incluida—, aunque no estén todas las de Postgres. En local-first, esa paridad es lo que evita mantener dos modelos de datos distintos.


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