Category: Arquitectura de Software

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

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

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

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

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

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


    Por qué Karma se queda atrás

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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


    Cómo migrar un proyecto existente

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

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

    Ahora el gotcha que rompe configuraciones cuando nadie lo espera.

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

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

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


    El schematic que automatiza parte del trabajo

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

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

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

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

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

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

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

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


    Mapa de equivalencias: de Jasmine/Jest a Vitest

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

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

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

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

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


    Testing zoneless con Vitest

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

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

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

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

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


    Karma vs Vitest en Angular 22, cara a cara

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

    La tesis

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

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

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


    Preguntas frecuentes sobre Vitest en Angular 22

    ¿Vitest reemplaza completamente a Karma en Angular 22?

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

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

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

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

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

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

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

    ¿Vitest funciona con testing zoneless en Angular 22?

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

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

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


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

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

    Método HTTP QUERY: RFC 10008 explicado para developers

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

    Nada del otro mundo. O eso pensé.

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

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


    El dilema de siempre: GET o POST

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

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

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

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

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

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


    Cómo llegamos hasta aquí

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

    Este problema no es nuevo. Es viejo.

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

    Línea por línea

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

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

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

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

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


    Cacheo y content negotiation: la parte que cambia las reglas

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

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

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

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

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

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


    El detalle que se te va a escapar: CORS

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

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

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

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


    Qué significa esto en la práctica, hoy

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

    Aquí toca ser honesto.

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

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

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

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

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

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

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

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

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


    GET vs QUERY vs POST, en una tabla

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

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

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


    La tesis: esto no es una feature exótica

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

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

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

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


    Preguntas frecuentes sobre el método HTTP QUERY

    ¿Qué es el método HTTP QUERY?

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

    ¿QUERY reemplaza a POST para hacer búsquedas?

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

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

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

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

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

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

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

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

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

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

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


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

  • De callbacks a Signals: la reactividad real del frontend

    De callbacks a Signals: la reactividad real del frontend

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

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

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

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


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

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

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

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

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

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

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

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

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

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

    Era 2: Virtual DOM y el modelo declarativo

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    Los tres paradigmas, uno al lado del otro

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

    Por qué la reactividad fina no es una moda

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

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

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

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

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

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

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

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

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

    Preguntas frecuentes sobre programación reactiva en el frontend

    ¿Qué es la programación reactiva?

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

    ¿El Virtual DOM está muerto?

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

    ¿Los Signals reemplazan a React?

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

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

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

    ¿Angular usa Virtual DOM?

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

    ¿Debo migrar mi app de React a Signals?

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


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

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

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

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

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

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

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


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

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

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

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

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


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

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

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

    Los tres artefactos son:

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

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

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

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


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

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

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

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

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

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

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


    El spec como brújula del agente

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

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

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

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

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


    Por qué el spec te protege del vibe coding

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

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

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

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

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

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


    Cómo empezar con SDD en Claude Code hoy

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

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

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

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

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


    El spec como ventaja competitiva real

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

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

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

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

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


    FAQ

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

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

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

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

    ¿Se puede aplicar SDD a proyectos que ya existen?

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

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

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

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

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

    ¿Es SDD compatible con metodologías ágiles?

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


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

  • El Harness: por qué la spec y la arquitectura no son suficientes

    El Harness: por qué la spec y la arquitectura no son suficientes

    Mi workflow completo: de idea a producto en producción con IA

    Hace un año tardaba 2-3 semanas en tener algo desplegado desde una idea nueva.

    Hoy tardo 2-3 días.

    No porque use mejores modelos. Porque cambié el workflow.

    Acá está el proceso completo, sin omitir nada.


    Fase 1 — Captura (30 minutos)

    Antes de abrir el editor, abro un documento en blanco y respondo tres preguntas:

    1. ¿Qué problema concreto resuelve esto?
    2. ¿Quién lo va a usar y en qué contexto exacto?
    3. ¿Qué tiene que funcionar sí o sí para que sea útil desde el día uno?

    Solo eso. Sin pensar en tech stack. Sin pensar en arquitectura.

    Si no puedo responder las tres en 30 minutos, la idea no está lista para construirse.


    Fase 2 — Spec (1-2 horas)

    Con las respuestas anteriores, genero la spec técnica.

    La spec tiene 6 secciones: Visión, Usuarios, Funcionalidades, Flujos, Arquitectura y NFRs.

    No la escribo yo desde cero. La genero con un agente que toma mis respuestas de la Fase 1 como input.

    Luego la reviso y ajusto lo que el agente asumió mal.

    El output: un documento de 2-3 páginas que define qué se construye, para quién, y cómo debe comportarse.


    Fase 3 — Plan técnico (30 minutos)

    Con la spec lista, otro agente genera el plan de implementación.

    No “empieza a codear”. Define:

    • Las fases del proyecto en orden
    • Qué necesita estar listo antes de cada fase
    • Los riesgos técnicos por módulo

    Reviso el plan. Lo ajusto si algo no tiene sentido. Firma.


    Fase 4 — Implementación (el grueso)

    Aquí entra Claude Code.

    No le doy el prompt “hazme la app”. Le doy la spec + el plan + el task específico a implementar en esa sesión.

    Un task. Una sesión. Un output verificable.

    Si el task es “implementar autenticación con GitHub OAuth”, eso es todo lo que hace esa sesión.

    Al final de cada sesión, verifico que lo que se construyó cumple el criterio de aceptación de la spec.

    Si no lo cumple, corrijo antes de avanzar. No acumulo deuda de contexto.


    Fase 5 — Deploy y validación (1-2 horas)

    Deploy con el stack que use el proyecto (Railway, Vercel, Supabase).

    Luego muestro el producto a 2-3 personas del perfil objetivo y les hago una sola pregunta:

    “¿Qué haría que esto fuera indispensable para ti?”

    No “¿te gusta?” ni “¿qué mejorarías?”.

    Esa pregunta específica te da el siguiente ciclo de iteración o te dice que pivotes.


    Lo que hace que este workflow funcione no es la IA.

    Es que la IA nunca opera sin contexto estructurado.

    Cada agente recibe exactamente lo que necesita para hacer su parte. Nada más. Nada menos.

    Sin eso, la IA improvisa. Y cuando improvisa, construye lo que interpreta, no lo que necesitas.


    Si quieres ver este workflow ejecutado en vivo sobre un proyecto real — Stripe webhook receiver + Supabase, desde la spec hasta el deploy — eso es exactamente lo que hacemos el 9 de julio.

    workshop.dominicode.com

  • sdd-creator: genera spec, plan y tasks con cualquier agente IA

    sdd-creator: genera spec, plan y tasks con cualquier agente IA

    Llevaba tres horas implementando un sistema de autenticación con JWT cuando me di cuenta de que no había especificado nada.

    ¿El token debía expirar en la sesión o persistir entre reinicios? ¿Qué pasaba cuando el refresh token vencía estando el usuario activo? ¿El endpoint de logout invalidaba en servidor o solo limpiaba el cliente?

    Yo respondí esas preguntas sobre la marcha. Sin coherencia, sin registro de decisiones. El código resultó funcional pero arquitectónicamente un desastre.

    Eso no es un problema del agente. Es un problema de proceso. Para eso existe sdd-creator.


    El problema de codear sin especificar

    Los agentes de IA son extremadamente buenos ejecutando instrucciones. También son extremadamente buenos ejecutando instrucciones mal definidas — y el resultado es lo que imaginas.

    Cuando le das a Claude Code o a Cursor un prompt del tipo “implementa login con JWT”, el agente toma decisiones. Muchas. Las toma rápido, sin preguntarte, porque así trabajan. El output es código funcional que responde a una interpretación del problema, no necesariamente a tu interpretación.

    El fallo no está en la IA. Está en que nunca estableciste qué querías exactamente.

    Spec-Driven Development (SDD) resuelve esto con una premisa simple: antes de generar código, genera el spec. Un documento que responde qué hace la feature, por qué existe, quién la usa, qué flujos cubre y bajo qué criterios está terminada.

    El problema es que hacer bien un spec lleva disciplina. Y cuando tienes el agente abierto y las ganas de construir, la tentación de saltártelo es enorme.


    Qué es sdd-creator y cómo funciona

    sdd-creator es un skill para agentes de IA que impone el proceso de especificación antes de ejecutar cualquier implementación. No es un generador de documentos — es un interrogador. El agente no escribe código hasta que el spec esté completo y confirmado.

    A diferencia de pedirle directamente al agente que “genere un spec libre”, sdd-creator impone siempre las mismas 6 secciones y bloquea la implementación hasta recibir confirmación explícita. Sin esa estructura, los specs se convierten en párrafos de texto libre que el agente interpreta como quiere.

    El flujo tiene siete pasos:

    1. Describes el feature o proyecto que quieres construir
    2. sdd-creator detecta la complejidad (LOW / MEDIUM / HIGH)
    3. Te hace una entrevista interactiva — te pregunta lo que no especificaste
    4. Genera spec.md con 6 secciones estructuradas
    5. Espera tu confirmación antes de continuar
    6. Genera plan.md con las decisiones técnicas y la planificación por fases
    7. Genera tasks.md con las tareas ordenadas para TDD — y solo entonces empieza la implementación

    El repositorio está en GitHub: bezael/sdd-creator — MIT, v1.2.0.

    Si quieres entender la metodología detrás con más profundidad, el libro SDD cubre los principios completos, con patrones reales de proyectos en producción.


    Instalación

    Una sola línea:

    npx skills@latest add bezael/sdd-creator

    El CLI detecta tu herramienta y copia el skill al directorio correcto automáticamente. Como referencia, los directorios destino son:

    • Claude Code: ~/.claude/skills/
    • Cursor: .cursor/rules/ del proyecto

    No hay configuración adicional. No hay API keys. No hay dependencias de runtime. El skill vive como un archivo de instrucciones que el agente carga en contexto cuando lo invocas.

    Para instalación manual o integración con otros agentes, consulta la documentación oficial de Claude Code o los docs de tu herramienta.


    Tutorial paso a paso — feature de login con JWT

    Vamos con un ejemplo concreto. Tienes una app NestJS y quieres implementar autenticación con JWT. Sin sdd-creator, abres el agente y escribes: “implementa autenticación con JWT”. Con sdd-creator, el proceso es diferente.

    Paso 1 — Invoca el skill

    En Claude Code o en Cursor, activa sdd-creator. Luego describe tu feature:

    Quiero implementar un sistema de autenticación con JWT para una API NestJS.
    Incluye registro, login, refresh de token y logout.

    Paso 2 — La entrevista interactiva

    sdd-creator detecta complejidad media y empieza a preguntarte:

    • ¿El token de acceso expira en cuánto tiempo?
    • ¿El refresh token se invalida en servidor o solo en cliente?
    • ¿El endpoint de logout invalida todos los dispositivos activos o solo el actual?
    • ¿La app requiere rate limiting en los endpoints de auth?
    • ¿Los usuarios pueden tener múltiples sesiones simultáneas?

    Preguntas incómodas. Preguntas que el agente habría respondido solo — con su mejor criterio — si no le hubieras forzado a preguntarte.

    Paso 3 — Confirmas el spec.md

    El agente genera el spec.md completo. Lo revisas, corriges lo que no cuadra, y confirmas. Solo entonces avanza.

    Paso 4 — plan.md y tasks.md

    sdd-creator genera el plan técnico (decisiones de arquitectura, librerías, estructura de módulos) y la lista de tareas ordenadas para TDD. Primero los tests de los casos de error — token expirado, credenciales inválidas, refresh token revocado. Luego el código que los hace pasar.

    Resultado: el agente implementa exactamente lo que especificaste. Sin sorpresas. Sin decisiones implícitas. Sin “lo hice así porque parecía razonable”.


    Los 3 archivos que genera

    spec.md — La especificación en 6 secciones

    La estructura es fija e invariable:

    1. Visión — qué problema resuelve y por qué existe esta feature
    2. Usuarios — quién la usa y cuáles son sus necesidades reales
    3. Funcionalidades — qué puede hacer el sistema (listado concreto)
    4. Flujos — cómo se comporta el sistema en los escenarios principales
    5. Arquitectura — cómo está organizado técnicamente
    6. NFRs — requisitos no funcionales: performance, seguridad, disponibilidad

    La estructura fija es deliberada. Cuando el spec siempre tiene las mismas 6 secciones, puedes revisarlo en segundos y saber exactamente qué falta. Un spec libre en prosa no tiene esa propiedad.

    Si quieres ver cómo aplicar estas 6 secciones en un proyecto greenfield completo, este post sobre SDD con slices verticales lo cubre en detalle.

    plan.md — Las decisiones técnicas

    El plan responde: ¿cómo vamos a construir esto? Librerías seleccionadas y por qué. Estructura de módulos. Fases de implementación. Dependencias entre componentes. Riesgos identificados.

    No es un documento académico — es el registro de las decisiones que tomarías antes de empezar, aunque fueran en tu cabeza. Externalizar ese razonamiento tiene valor: el agente lo usa como referencia durante la implementación, y tú lo usas para hacer review.

    tasks.md — La lista ordenada para TDD

    Las tareas están ordenadas para Test-Driven Development. Los tests de los contratos del sistema van primero. El código que los satisface, después. Cada tarea es atómica — una sola responsabilidad, verificable por sí sola.

    Cuando tienes esta lista, puedes darle una tarea al agente y pedirle que haga solo esa. Sin divagar. Sin añadir “mejoras” que no pediste. La tarea acotada, con su test, con su criterio de aceptación.

    Esta es exactamente la forma de trabajar que desarrollamos en el curso Construye con IA — de la idea al producto real, con agentes IA y sin perder el control del código.


    Cuándo NO usar sdd-creator

    sdd-creator añade valor cuando el problema tiene suficiente complejidad para merecer una especificación. Hay casos donde el overhead no compensa:

    • Scripts de un solo uso: automatizaciones de 20-30 líneas que se ejecutan una vez y se descartan
    • Prototipos desechables: experimentos para validar si algo es técnicamente posible, sin intención de iterar sobre el código
    • Hotfixes triviales: corregir un typo, cambiar un color, ajustar un literal de texto

    La regla práctica: si el feature va a producción y va a ser mantenido, usa sdd-creator. Si es exploración o descarte, ve directo al código.


    Compatible con cualquier agente de IA

    sdd-creator no está atado a un agente específico. Funciona con todos los entornos de desarrollo con IA más usados:

    Agente Tipo de integración Directorio
    Claude Code Skills nativo ~/.claude/skills/
    Cursor Rules .cursor/rules/ del proyecto
    Codex CLI (OpenAI) AGENTS.md / system prompt Configuración de proyecto
    Gemini CLI System prompt Configuración de proyecto
    Aider Contexto personalizado .aider.conf.yml
    Continue config.json .continue/

    El formato MIT también significa que puedes adaptarlo a tu equipo. Si tienes convenciones de nomenclatura propias, o secciones adicionales en tus specs, puedes forkear el repositorio y ajustarlo.


    FAQ

    ¿Qué es sdd-creator?

    sdd-creator es un skill para agentes de IA que implementa el flujo de Spec-Driven Development. Cuando lo activas, el agente no escribe código directamente — primero te hace una entrevista para entender el problema, luego genera tres documentos estructurados (spec.md, plan.md, tasks.md), y solo después implementa. Es la diferencia entre darle instrucciones a un agente y darle una especificación.

    ¿Con qué agentes de IA funciona sdd-creator?

    Con Claude Code, Cursor, Codex CLI (OpenAI), Gemini CLI, Aider y Continue. El skill es un archivo de instrucciones, no una integración específica — cualquier agente que soporte archivos de contexto puede usarlo. La instalación varía: en Claude Code se copia a ~/.claude/skills/, en Cursor va a .cursor/rules/.

    ¿Cuánto tiempo lleva generar la spec con sdd-creator?

    Entre 5 y 20 minutos, dependiendo de la complejidad del feature. Una feature simple puede especificarse en 5 minutos. Una feature con múltiples flujos, integraciones externas y requisitos de seguridad puede tomar 20. Ese tiempo es siempre menor que el que cuesta refactorizar código que el agente implementó sin especificación.

    ¿Es sdd-creator compatible con proyectos legacy?

    Sí. SDD no requiere empezar desde cero — puedes aplicarlo feature a feature sobre una base de código existente. El spec refleja las restricciones reales del sistema existente: qué puedes cambiar, qué no, y qué deuda técnica tienes que tener en cuenta durante la implementación.

    ¿Puedo usar sdd-creator en equipos?

    Sí, y es donde más valor aporta. El spec.md generado es el contrato de la feature — cualquier miembro del equipo puede revisarlo, cuestionarlo y aprobarlo antes de que empiece la implementación. Elimina el “yo entendí que…” de las reuniones de review.


    Ahora, cuando tengo el agente abierto y las ganas de construir, lo primero que activo es sdd-creator. Los 15 minutos de spec se pagan solos. Esas tres horas de JWT no se van a repetir.

    Si quieres ver cómo SDD encaja en el ciclo completo de desarrollo con IA — desde la idea hasta el producto desplegado — en Dominicode Labs tienes acceso a proyectos reales donde aplicamos este flujo de principio a fin.

    Por Bezael Pérez — Fundador de Dominicode.

  • Neon vs Supabase: comparación técnica honesta (2026)

    Neon vs Supabase: comparación técnica honesta (2026)

    Hace unos meses estaba arrancando un proyecto nuevo. Stack limpio, decisiones por tomar. Y en cuestión de minutos tuve el debate de siempre en el canal de decisiones técnicas: ¿Neon o Supabase?

    Los dos son Postgres. Los dos tienen free tier. Los dos aparecen en casi cualquier lista de "stack moderno para SaaS". Y los dos hacen cosas completamente distintas.

    Este post es la Neon vs Supabase comparación técnica que me hubiera ahorrado dos horas de lectura de docs.


    Qué es cada uno en una línea

    Neon: Postgres serverless puro con branching de base de datos y scale-to-zero.

    Supabase: Plataforma BaaS construida sobre Postgres — incluye Auth, Storage, Realtime y Edge Functions en un solo sitio.

    La diferencia de fondo: Neon es una base de datos. Supabase es un backend completo que usa Postgres como motor.


    Tabla comparativa

    Criterio Neon Supabase
    Tipo Postgres serverless BaaS completo sobre Postgres
    Scale-to-zero Sí, nativo No en producción (pausa en free tier)
    Branching de DB Sí, copy-on-write instantáneo Solo Pro+ (beta); provisiona DB nueva + migraciones
    Auth nativo No Sí (JWT, OAuth, Magic Link)
    Storage nativo No Sí (S3-compatible)
    Realtime No Sí (WebSockets sobre Postgres)
    Edge Functions No Sí (Deno runtime)
    Free tier DB 0.5 GB, 100 CU-h 500 MB, pausa tras 7 días idle
    Plan de pago Desde ~$19/mes (usage-based) $25/mes (Pro, todo incluido)
    ORM compatible Cualquiera (Drizzle, Prisma, pg) Cliente JS/TS propio + cualquier ORM
    Tipado auto-generado Via ORM Sí, con supabase gen types
    Adquirida por Databricks (2025, ~$1B) Independiente

    Arquitectura de Neon vs Supabase: la diferencia que más importa

    Neon separa compute y storage. Cuando no hay peticiones, el compute se apaga solo — y cuando llega la primera query, arranca en milisegundos. El storage usa copy-on-write, lo que hace que crear una rama de base de datos sea instantáneo y casi sin coste.

    Supabase no funciona así. Tu base de datos corre en una instancia dedicada. Si estás en el free tier, Supabase pausa el proyecto tras 7 días sin actividad. En Pro, la instancia corre siempre — pagas compute 24/7 aunque tu app esté durmiendo.

    Para proyectos en producción con tráfico real, esto no es necesariamente un problema. Para proyectos con muchos entornos (staging, feature branches, demos de clientes), la diferencia de coste es brutal.


    Branching: por qué Neon gana en CI/CD

    Esta es la feature que más me ha cambiado el flujo de trabajo.

    Con Neon puedes crear una rama de base de datos por PR. Misma estructura, mismos datos (o un subconjunto). La rama vive mientras dura el PR y desaparece al hacer merge. No hay que mantener un entorno de staging contaminado con datos de otras features. Si tienes un pipeline con code review automático antes del merge, tienes el flujo completo en el post sobre agentic code review con Claude Code.

    # Crear una rama de DB para una PR concreta
    neon branches create --name feature/payment-refactor --parent main
    

    Supabase también tiene branching, pero funciona diferente: aprovisiona una base de datos nueva, ejecuta tus migraciones y carga el seed. Es más lento y consume más recursos. Para un equipo pequeño o un proyecto personal, puede ser suficiente. Para un pipeline de CI/CD que crea y destruye entornos constantemente, Neon gana por goleada.


    SDK y DX: dos filosofías distintas

    Supabase tiene un cliente JS/TS que abstrae casi todo. Queries, auth, storage, realtime — todo desde el mismo objeto.

    // Supabase: cliente unificado con tipado auto-generado
    import { createClient } from '@supabase/supabase-js'
    import type { Database } from './database.types' // generado con supabase gen types
    
    const supabase = createClient<Database>(
      process.env.SUPABASE_URL!,
      process.env.SUPABASE_ANON_KEY!
    )
    
    const { data, error } = await supabase
      .from('products')
      .select('id, name, price')
      .eq('active', true)
    

    Neon apuesta por Postgres nativo. Usas tu ORM de siempre — Drizzle, Prisma, o pg directo — contra un connection string estándar. Sin abstracciones propias, sin vendor lock-in de cliente.

    // Neon: Drizzle sobre el driver serverless de Neon
    import { neon } from '@neondatabase/serverless'
    import { drizzle } from 'drizzle-orm/neon-http'
    import { products } from './schema'
    import { eq } from 'drizzle-orm'
    
    const sql = neon(process.env.DATABASE_URL!)
    const db = drizzle(sql)
    
    const activeProducts = await db
      .select({ id: products.id, name: products.name, price: products.price })
      .from(products)
      .where(eq(products.active, true))
    

    Si ya tienes un ORM configurado en tu proyecto, migrar a Neon es cambiar el connection string. Con Supabase, el cliente propio es más cómodo para proyectos nuevos pero añade una dependencia específica a la plataforma.


    Precios Neon vs Supabase: cuándo cada modelo tiene sentido

    Neon (usage-based):

    • Free: $0 — 100 CU-horas, 0.5 GB storage
    • Launch: ~$19/mes — $0.106/CU-hora, $0.35/GB storage
    • Scale: desde ~$701/mes — con SLA e HIPAA incluidos

    Supabase (plataforma flat + overages):

    • Free: $0 — 500 MB DB, 50K MAU, 1 GB storage (pausa tras 7 días idle)
    • Pro: $25/mes — 8 GB DB, 100K MAU, Auth + Storage + Edge Functions incluidos
    • Team: $599/mes — SSO, SOC 2

    Para un proyecto con tráfico irregular o muchos entornos temporales, el modelo de Neon puede salir significativamente más barato. Para un SaaS en crecimiento que necesita Auth + Storage + DB y quiere una sola factura, el Pro de Supabase a $25 es imbatible en relación precio/funcionalidad.

    Precios verificados en junio 2026. Consulta las páginas oficiales de Neon y Supabase para tarifas actualizadas — los modelos usage-based cambian con frecuencia.


    Cuándo elegir Neon

    • Quieres Postgres puro sin opiniones sobre tu stack de auth o storage.
    • Tu pipeline de CI/CD se beneficia de tener una rama de DB por PR.
    • Tienes cargas de trabajo variables o intermitentes — el scale-to-zero te ahorra dinero real.
    • Ya tienes Drizzle o Prisma configurado y no quieres añadir un cliente propio.
    • Estás construyendo agentes de IA que necesitan provisionar bases de datos efímeras. La arquitectura serverless de Neon (y el respaldo de Databricks) la convierte en la opción natural para cargas de trabajo agénticas — incluidos los pipelines donde el agente lee un ticket, implementa y despliega de forma autónoma, como los que explico en el post sobre automatizar el proceso de desarrollo con IA.

    Cuándo elegir Supabase

    • Necesitas Auth desde el día uno — OAuth, magic link, JWT — sin montar Clerk ni Auth.js.
    • Tu proyecto necesita file storage y no quieres gestionar un bucket S3 por tu cuenta.
    • Quieres Realtime (subscripciones en tiempo real) sin añadir Redis ni WebSockets propios.
    • Valoras tener un solo proveedor para DB + Auth + Storage con una sola factura.
    • El free tier te basta para empezar y no te molesta que el proyecto se pause tras 7 días idle.

    Edge cases que nadie menciona

    Supabase no hace scale-to-zero en producción. Esto es intencionado — una instancia siempre activa garantiza latencia consistente. Pero si tienes 10 entornos de staging o un proyecto que duerme la mayor parte del tiempo, estás pagando compute en vacío.

    Neon no tiene Auth ni Storage nativos. Si los necesitas, tienes que añadirlos tú: Clerk, Better Auth, Auth.js para autenticación; Cloudflare R2, AWS S3 o Uploadthing para ficheros. No es un problema técnico, pero sí es trabajo de integración que con Supabase viene resuelto de fábrica.

    El branching de Supabase requiere CLI y configuración previa. No es tan plug-and-play como en Neon. Si quieres branching en Supabase, necesitas tener migraciones bien organizadas desde el principio.


    FAQ

    ¿Puedo usar Drizzle con Supabase?
    Sí. Supabase es Postgres estándar — puedes conectar cualquier ORM con el connection string del proyecto. El cliente propio de Supabase es opcional, no obligatorio.

    ¿Neon tiene Realtime o Auth?
    No de forma nativa. Puedes añadir LISTEN/NOTIFY de Postgres para eventos básicos, pero no hay un sistema de auth ni storage integrado. Para eso necesitas otra capa.

    ¿El scale-to-zero de Neon afecta a producción?
    Depende de tu configuración. El cold start de Neon suele estar por debajo de 500ms en condiciones normales. Para la mayoría de apps es aceptable. Si tienes requisitos de latencia muy estrictos, puedes configurar un mínimo de compute activo en el plan de pago.

    ¿Qué pasa con la adquisición de Neon por Databricks?
    Databricks compró Neon en 2025 por aproximadamente $1B. La apuesta es que los agentes de IA van a necesitar provisionar bases de datos efímeras a escala. Para el usuario, de momento se traduce en mejoras de precios — el storage bajó de $1.75 a $0.35/GB-mes. El roadmap a largo plazo aún está por verse.

    ¿Puedo migrar de Supabase a Neon (o al revés) más adelante?
    La base de datos en sí migra sin problema — es Postgres estándar en los dos casos. El trabajo real está en reemplazar el cliente de Supabase (auth, storage, realtime) si decides cambiar. Si usas Drizzle o Prisma desde el principio, cambiar el connection string es trivial.


    La decisión no es técnica en el fondo — es de qué quieres gestionar tú y qué quieres que gestione la plataforma. Si quieres Postgres puro con control total y branching en CI/CD, Neon. Si quieres un backend completo sin ensamblar piezas, Supabase.

    Ninguna de las dos es la respuesta correcta en abstracto. Ambas son la respuesta correcta para el problema adecuado.

    Si en tu proyecto estás usando IA para construir features o automatizar flujos, en el curso Construye con IA trabajamos exactamente con este tipo de decisiones de arquitectura — desde la elección de herramientas hasta el producto en producción.

    Si quieres profundizar en cómo tomar este tipo de decisiones de arquitectura en proyectos reales — con IA en el loop — en Dominicode Labs tenemos proyectos completos con el stack detallado y la justificación técnica detrás de cada decisión.


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

  • DuckDB + Obsidian: analiza tu vault con SQL sin exportar nada

    DuckDB + Obsidian: analiza tu vault con SQL sin exportar nada

    Tenía más de 800 notas en Obsidian. Tres años de journaling técnico, decisiones de arquitectura, apuntes de libros, ideas de proyectos. Todo bien organizado, con frontmatter YAML, tags consistentes, links entre notas.

    Y seguía buscando con Cmd+Shift+F como si fuera 2010.

    El buscador de Obsidian es bueno para encontrar una nota específica. Es inútil para responder preguntas como: ¿cuántos proyectos he apuntado en el último trimestre que no tienen ninguna nota de seguimiento? ¿Qué tecnologías aparecen más en mis journals de este año? ¿Cuántas notas de tipo "idea" no tienen ningún link interno hacia otro documento?

    Esas preguntas no son búsquedas de texto. Son queries sobre datos estructurados. Y eso es exactamente lo que hace DuckDB.

    La configuración de DuckDB con Obsidian que voy a enseñarte te permite tratar tu vault como una base de datos relacional — y correr SQL directamente sobre tus archivos .md.

    DuckDB con Obsidian es la combinación de DuckDB — un motor SQL embebido de alto rendimiento — con un vault de Obsidian para tratar los archivos .md como filas de una base de datos consultable. A través de la extensión duckdb-obsidian, puedes ejecutar SQL directamente sobre tu frontmatter YAML, links internos y estructura de headings, sin exportar ni transformar ningún archivo.


    Por qué DuckDB y no algo más "normal"

    Podrías exportar todo tu vault a CSV y abrirlo en SQLite. O parsear el frontmatter con un script en Python y cargarlo en Pandas. Lo he hecho. Funciona, pero tienes que mantener ese pipeline de sincronización, gestionar el esquema cuando cambias tus propiedades, y el resultado no vive dentro de Obsidian — vive en otro sitio.

    DuckDB resuelve esto de dos formas distintas según lo que necesites:

    1. La extensión CLI duckdb-obsidian — una extensión no oficial que expone una función obsidian_notes() que parsea tu vault en tiempo real directamente desde el CLI de DuckDB. Sin exportar nada. Sin pipeline. Lanzas DuckDB, cargas la extensión, y tu vault es una tabla.

    2. El plugin Obsidian DuckDB and MotherDuck — un plugin que vive dentro de Obsidian, corre DuckDB WASM en el navegador, y te permite escribir bloques SQL en tus notas que renderizan como tablas markdown. El resultado se puede "congelar" como texto plano para que persista sin necesidad de re-ejecutar.

    Son dos herramientas distintas para dos flujos distintos. Te explico ambas.


    Opción 1: DuckDB CLI + extensión duckdb-obsidian

    Esta es la opción para cuando quieres hacer análisis ad-hoc desde la terminal, escribir scripts, o conectar los resultados a otras herramientas.

    Instalación

    Primero necesitas DuckDB instalado. La forma más limpia dependiendo de tu sistema:

    # macOS con Homebrew
    brew install duckdb
    
    # Windows con WinGet
    winget install DuckDB.cli
    
    # O directamente desde los binarios de duckdb.org/docs/installation
    

    Verifica que funciona:

    duckdb --version
    # v1.2.2
    # La extensión duckdb-obsidian es compatible con DuckDB 1.1.x y 1.2.x
    

    Ahora descarga la extensión. Ve a github.com/puzan/duckdb-obsidian/releases y descarga el archivo que corresponde a tu versión de DuckDB y tu sistema operativo. El nombre del archivo tiene el formato obsidian.duckdb_extension.

    Configuración

    La extensión no está firmada por DuckDB, así que tienes que lanzar la CLI con extensiones sin firmar habilitadas:

    duckdb --allow-unsigned-extensions
    

    Dentro de la sesión, carga la extensión con la ruta absoluta al archivo que descargaste. Si el archivo viene comprimido (.duckdb_extension.gz), descomprímelo primero antes del LOAD:

    LOAD '/ruta/absoluta/a/obsidian.duckdb_extension';
    

    Si no quieres escribir esto cada vez, crea un archivo .duckdbrc en tu home. DuckDB lo ejecuta automáticamente al arrancar:

    SET allow_unsigned_extensions = true;
    LOAD '/ruta/absoluta/a/obsidian.duckdb_extension';
    

    Para apuntar al vault, tienes dos opciones. La primera: navega al directorio del vault antes de lanzar DuckDB — la función obsidian_notes() sin argumentos escanea el directorio actual:

    cd /Users/bezael/vault
    duckdb --allow-unsigned-extensions
    

    La segunda: pasa la ruta directamente como argumento a la función:

    SELECT * FROM obsidian_notes('/Users/bezael/vault') LIMIT 5;
    

    La única restricción es que el directorio debe contener una carpeta .obsidian — es como la extensión verifica que es un vault válido.

    Esquema disponible

    La función obsidian_notes() expone estas columnas sobre cada nota:

    Columna Tipo Contenido
    basename VARCHAR Nombre del archivo sin .md
    filepath VARCHAR Ruta absoluta al archivo
    first_header VARCHAR Primer H1 de la nota, o NULL
    headers STRUCT[] Todos los headings con su nivel
    properties JSON Frontmatter YAML parseado como JSON
    internal_links STRUCT[] Wikilinks con target, nombre y referencia

    Queries SQL reales sobre tu vault de Obsidian

    Aquí es donde esto se vuelve útil de verdad.

    Analizar la distribución de tags

    ¿Cuáles son los tags que más usas y cuántas notas tienen cada uno?

    SELECT
      tag,
      count(*) AS total_notas
    FROM (
      SELECT unnest(
        from_json(properties->'$.tags', '["VARCHAR"]')
      ) AS tag
      FROM obsidian_notes()
      WHERE properties->>'$.tags' IS NOT NULL
    )
    GROUP BY tag
    ORDER BY total_notas DESC
    LIMIT 20;
    

    Esto parsea el array tags del frontmatter de cada nota y te da un ranking. Tres años de vault analizados en menos de un segundo.

    Encontrar notas huérfanas

    Notas que nadie enlaza — las que más probablemente estés olvidando:

    WITH todas_las_notas AS (
      SELECT basename FROM obsidian_notes()
    ),
    notas_referenciadas AS (
      SELECT DISTINCT unnest(
        list_transform(internal_links, l -> l.target)
      ) AS target
      FROM obsidian_notes()
      WHERE len(internal_links) > 0
    )
    SELECT basename
    FROM todas_las_notas
    WHERE basename NOT IN (SELECT target FROM notas_referenciadas)
    ORDER BY basename;
    

    Cuando corrí esto en mi vault por primera vez encontré 340 notas huérfanas. 340. Notas que había creado, nunca enlazado desde ningún otro sitio, y que esencialmente estaban muertas dentro del vault.

    Grafo de links entre notas

    Para exportar el grafo completo a CSV y procesarlo en otra herramienta:

    COPY (
      SELECT
        basename AS origen,
        unnest(list_transform(internal_links, l -> l.target)) AS destino
      FROM obsidian_notes()
      WHERE len(internal_links) > 0
    ) TO '/tmp/grafo-vault.csv' (HEADER, DELIMITER ',');
    

    Cruzar propiedades del frontmatter

    Si usas frontmatter consistente (por ejemplo status, type, project), puedes hacer queries cruzadas:

    SELECT
      properties->>'$.type' AS tipo,
      properties->>'$.status' AS estado,
      count(*) AS total
    FROM obsidian_notes()
    WHERE properties->>'$.type' IS NOT NULL
    GROUP BY tipo, estado
    ORDER BY total DESC;
    

    Esto responde preguntas como: ¿cuántos documentos de tipo "project" están en estado "in-progress" vs "done"?

    Buscar patrones en journals

    Si guardas journals diarios con una estructura de frontmatter predecible, puedes cruzarlos:

    SELECT
      basename,
      properties->>'$.mood' AS mood,
      properties->>'$.energia' AS energia
    FROM obsidian_notes()
    WHERE properties->>'$.type' = 'journal'
      AND properties->>'$.date' >= '2026-01-01'
    ORDER BY properties->>'$.date' DESC;
    

    Opción 2: Plugin DuckDB + MotherDuck dentro de Obsidian

    Si prefieres no salir de Obsidian, el plugin oficial te mete DuckDB directamente en el editor.

    Instalación

    Abre Obsidian → Settings → Community plugins → Browse. Busca "DuckDB and MotherDuck". Instala y activa.

    El plugin corre DuckDB WASM — no necesita instalación local de DuckDB, corre directamente en memoria dentro de Obsidian.

    Cómo escribir queries

    Crea un bloque de código con el lenguaje duckdb:

    ```duckdb
    SELECT
      o_orderpriority AS priority,
      count(*) AS orders,
      round(sum(o_totalprice), 2) AS revenue
    FROM read_parquet('https://shell.duckdb.org/data/tpch/0_01/parquet/orders.parquet')
    GROUP BY 1
    ORDER BY revenue DESC
    ```
    

    Cuando ejecutas la query (con el comando "Refresh query at cursor"), el resultado se renderiza como una tabla markdown directamente debajo del bloque.

    La feature más útil del plugin es Freeze: convierte el resultado en texto plano markdown que persiste en la nota sin necesidad de re-ejecutar. El archivo .md queda con la query y su resultado como texto estático — lo que significa que funciona en cualquier plataforma que renderice markdown, incluyendo tu vault público en Quartz.

    Limitaciones reales del plugin vs. CLI

    El plugin tiene un caso de uso claro: consultas sobre datos externos o remotos (CSV, Parquet, JSON en URLs, MotherDuck) que quieres mostrar como tablas vivas dentro de tus notas.

    Para consultar el vault en sí — los archivos .md, el frontmatter, los links — la extensión CLI es más potente. El plugin no expone obsidian_notes() porque DuckDB WASM no tiene acceso al sistema de archivos local de Obsidian de la misma forma.

    Para análisis del vault completo: CLI. Para datos externos integrados en notas: plugin.


    Cuándo usas esto en la práctica

    Llevo usando este setup tres meses. Estos son los casos donde realmente lo abro:

    Revisiones semanales. Tengo un script .sql que me lista todas las notas con status: in-progress que no he modificado en más de siete días. Corro el script los viernes. Lo que aparece en esa lista o lo priorizo o lo cierro.

    Auditoría del vault cada dos meses. La query de notas huérfanas + la query de distribución de tags me da una foto de hacia dónde está derivando mi sistema de notas sin que me haya dado cuenta.

    Análisis de proyectos. Cuando empiezo a trabajar en un cliente nuevo, tengo notas del sector o la tecnología dispersas por el vault. Una query me saca todo lo que he apuntado sobre ese dominio aunque no recuerde los nombres exactos de las notas.

    Exportar datos a otras herramientas. DuckDB puede escribir directamente a CSV, Parquet, JSON. Si quiero hacer un análisis más visual en una hoja de cálculo o en una herramienta de BI, exporto el resultado de la query y lo importo. Sin copiar a mano.


    Este enfoque encaja exactamente con la filosofía que aplicamos en el curso Construye con IA: tratar tus propios datos como un asset que puedes interrogar, no como un archivo que tienes que recordar dónde dejaste. Tu vault no es una colección de texto — es una base de conocimiento que merece una capa de consulta real.


    Lo que puedes hacer hoy

    Instala DuckDB. Descarga la extensión duckdb-obsidian. Corre la query de notas huérfanas sobre tu vault.

    Eso solo ya te va a dar información que no tienes ahora mismo: qué parte de tu vault está desconectada del resto. A partir de ahí decides si quieres ir más lejos con análisis de tags, patterns en journals, o cruzar propiedades del frontmatter.

    No necesitas un pipeline. No necesitas exportar nada. Lanzas DuckDB, cargas la extensión, y en un minuto estás corriendo SQL sobre años de notas.

    Si quieres ver más flujos donde los datos y las herramientas de IA se conectan así de forma directa, en Dominicode Labs tenemos proyectos completos que aplican exactamente esta filosofía — tratar tu entorno de trabajo como datos consultables, no como texto disperso.

    El patrón es el mismo que aplicamos cuando conectamos Claude a una fuente de datos externa: datos estructurados + una herramienta que los entiende = velocidad real. Si quieres ver cómo funciona ese loop completo desde el lado de la API, mira Claude API: Crash Course para developers con TypeScript.


    FAQ

    ¿Necesito tener DuckDB instalado localmente para el plugin de Obsidian?

    No. El plugin DuckDB and MotherDuck usa DuckDB WASM, que corre completamente en memoria dentro de Obsidian sin instalación adicional. La extensión CLI (duckdb-obsidian) sí requiere DuckDB instalado localmente porque necesita acceder al sistema de archivos.

    ¿La extensión duckdb-obsidian funciona con vaults grandes?

    Sí, con matices. DuckDB está diseñado para procesar grandes volúmenes de datos de forma eficiente. En un vault de 2.000-3.000 notas las queries simples tardan menos de un segundo. Para vaults muy grandes (5.000+ notas) con queries que cruzan múltiples propiedades JSON, puede ser recomendable usar SET threads TO 4; dentro de la sesión para aprovechar todos los cores.

    ¿El frontmatter tiene que seguir algún formato específico?

    Solo tiene que ser YAML válido al inicio del archivo, delimitado por ---. La extensión lo parsea como JSON en la columna properties. Anidamiento, arrays de tags, fechas — todo funciona. La única restricción es que las propiedades que quieras consultar deben existir en el frontmatter de esas notas.

    ¿Puedo conectar los resultados de estas queries a un agente de IA?

    Sí, y es uno de los casos de uso más interesantes. DuckDB puede exportar a JSON con COPY (...) TO '/tmp/output.json' (FORMAT JSON). Ese JSON lo puedes pasar como contexto a un agente o a la API de Claude para que razone sobre el estado de tu vault. MotherDuck tiene incluso documentación oficial sobre cómo usar el vault de Obsidian como fuente de datos para agentes de IA. También puedes conectar DuckDB directamente a un servidor MCP para que el agente ejecute queries de forma autónoma — ese es el siguiente nivel de este setup. Para entender la base de cómo funciona ese patrón con la API de Claude, tienes la guía completa en Claude API: Crash Course para developers con TypeScript. Si te interesa explorar esa dirección con proyectos en producción, tenemos más en Dominicode Labs.

    ¿Por qué DuckDB y no SQLite para esto?

    SQLite requiere que crees y mantengas el esquema manualmente. DuckDB infiere el esquema sobre la marcha: lee los archivos .md a través de la extensión, parsea el frontmatter como JSON, y expone todo como columnas consultables sin que hayas definido nada. Además, DuckDB tiene soporte nativo para arrays, structs y JSON anidado — que es exactamente el tipo de datos que tienes en el frontmatter de Obsidian. Con SQLite tendrías que escribir código de parseo adicional. Con DuckDB la extensión se encarga de todo.


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

  • Algoritmos de machine learning que todo developer web debería entender

    Algoritmos de machine learning que todo developer web debería entender

    Hace un año integré una búsqueda semántica en un proyecto SaaS. El cliente quería que los usuarios encontraran artículos aunque escribieran con sinónimos, con errores ortográficos, o en un idioma distinto al del contenido.

    La solución: tres líneas de TypeScript llamando a algoritmos de machine learning vía la API de OpenAI. Funcionó en una tarde.

    Pero el cliente preguntó algo que me dejó sin respuesta inmediata: "¿Qué hace exactamente ese modelo por dentro?". Y yo, con 15 años de experiencia en desarrollo, tuve que admitir que no tenía una respuesta clara más allá de "convierte texto en números".

    Ese hueco me molestó. No porque necesitara implementar los algoritmos desde cero, sino porque cuando no entiendes qué hay debajo del capó, tomas peores decisiones: eliges el modelo equivocado, debuggeas en la dirección incorrecta, o diseñas una arquitectura que no escala.

    Este post es lo que me hubiera gustado leer ese día. Los algoritmos de machine learning explicados para developers web — sin fórmulas, sin Python, sin pretender que vas a ser data scientist.

    Tres familias que lo explican todo

    Los algoritmos de machine learning son procedimientos que permiten a un sistema aprender patrones a partir de datos, sin que un programador defina explícitamente las reglas. En lugar de escribir if (spam) { ... }, le muestras miles de emails al modelo y él deduce las reglas solo.

    Hay tres formas fundamentales en que ocurre ese aprendizaje:

    Aprendizaje supervisado. Le das al modelo ejemplos con respuesta correcta. "Este email es spam. Este otro no lo es." El modelo aprende el patrón. Cuando llega un email nuevo, predice a cuál categoría pertenece. Úsalo cuando tienes datos etiquetados y una tarea de predicción o clasificación concreta.

    Aprendizaje no supervisado. No hay respuestas correctas. Le das datos sin etiquetar y el modelo encuentra estructura por sí solo. "Estos usuarios tienen comportamiento parecido. Estos otros también. Hay tres grupos." Úsalo cuando quieres descubrir patrones que no conoces de antemano — clustering de usuarios, detección de anomalías.

    Reinforcement learning. El modelo aprende por ensayo y error: hace una acción, recibe una recompensa o penalización, ajusta. Es cómo funcionan los modelos de juegos, pero también cómo se afinan los LLMs para que sus respuestas sean más útiles (RLHF — Reinforcement Learning from Human Feedback).

    Con esto en mente, los algoritmos concretos tienen contexto.

    Los algoritmos de machine learning que te importan como developer

    Resumen antes de entrar en detalle — ninguno lo vas a implementar tú:

    Algoritmo Tipo Cuándo lo usas en web ¿Lo implementas?
    Regresión logística Supervisado Scoring, predicción de churn No — API
    Random Forest Supervisado Moderación, detección de fraude No — API
    K-Means No supervisado Clustering de usuarios No — API
    Redes neuronales Supervisado Base de embeddings, clasificación No — modelos preentrenados
    Embeddings Supervisado Búsqueda semántica, recomendaciones No — OpenAI/HuggingFace
    Transformers Supervisado LLMs, generación, clasificación avanzada No — API

    Regresión lineal y logística

    Son los más simples. La regresión lineal predice un número: "¿Cuánto va a costar este apartamento?" La logística predice una probabilidad: "¿Hay un 87% de probabilidad de que este usuario cancele su suscripción este mes?"

    No las vas a implementar, pero las vas a encontrar en APIs de scoring, en features de predicción de churn, en sistemas de precios dinámicos. Cuando una API te devuelve un score: 0.87, probablemente hay una regresión logística detrás.

    Árboles de decisión y Random Forest

    Imagina una serie de preguntas de sí/no encadenadas. "¿El usuario tiene más de 30 días de cuenta? ¿Ha hecho al menos una compra? ¿Abrió el último email?" Cada camino lleva a una predicción. Eso es un árbol de decisión.

    Random Forest toma cientos de árboles distintos y combina sus respuestas. El resultado es más robusto y menos propenso a overfitting que un solo árbol.

    Son los algoritmos detrás de sistemas de moderación de contenido basados en reglas aprendidas, de detección de fraude, de sistemas de recomendación básicos.

    K-Means (clustering)

    K-Means agrupa datos en K clusters. Tú dices cuántos grupos quieres (K), el algoritmo encuentra cuáles puntos de datos pertenecen a cada grupo.

    Como developer web, esto aparece en sistemas de personalización: "Usuarios que actúan como tú también compraron esto." No hay etiquetas previas — el modelo descubre los segmentos solo.

    Redes neuronales

    Aquí empieza lo que la gente llama "deep learning". Una red neuronal es una cadena de capas matemáticas que transforman una entrada (texto, imagen, audio) en una salida (una clasificación, un número, un vector).

    Lo importante para entenderlas no es la matemática — es el concepto de representación. Cada capa aprende a representar la entrada de una forma más abstracta que la anterior. La primera capa de un modelo de visión detecta bordes. La siguiente detecta formas. La siguiente detecta objetos. Ningún programador definió esas representaciones: emergieron del entrenamiento.

    Embeddings — el algoritmo que ya usas

    Los embeddings son el resultado de pasar texto (o imágenes, o audio) por una red neuronal especializada. La salida es un vector de números — típicamente de 768 a 3072 dimensiones.

    La magia es que los vectores capturan significado semántico. "Perro" y "can" producen vectores muy cercanos en ese espacio de alta dimensión. "Perro" y "hipoteca" producen vectores lejanos.

    Esto es lo que permite la búsqueda semántica: conviertes tu query en un vector, comparas contra los vectores de tu base de datos, y devuelves los más cercanos. No importa si el usuario escribió "gato" y el documento dice "felino" — los vectores están cerca.

    Transformers — la arquitectura detrás de los LLMs

    Un Transformer es una arquitectura de red neuronal diseñada para procesar secuencias. El mecanismo clave se llama "atención" (attention): permite que el modelo, al procesar una palabra, preste atención a otras palabras del contexto según su relevancia.

    "El banco estaba lleno de peces" vs "El banco rechazó mi solicitud". La misma palabra "banco", significado completamente distinto. El mecanismo de atención resuelve esto mirando el contexto completo de la frase.

    GPT, Claude, Llama y Gemini usan Transformers como arquitectura base. Los modelos de embeddings de OpenAI también son Transformers, pero optimizados para producir buenas representaciones vectoriales en lugar de generar texto.

    Cuándo le importan al developer web

    No necesitas un data scientist para beneficiarte de ML. Estas son las integraciones más comunes en proyectos web reales. Puedes ver más ejemplos aplicados en el blog de Dominicode.

    Búsqueda semántica. Reemplaza o complementa la búsqueda por palabras clave. Los embeddings convierten queries y documentos en vectores, y una base de datos vectorial (Pinecone, pgvector, Supabase Vector) hace el matching por similitud coseno.

    Moderación de contenido. Clasifica si un texto es tóxico, si una imagen es apropiada, si un comentario viola normas. HuggingFace tiene modelos de clasificación listos para usar via API — zero setup del lado del ML.

    Recomendaciones. Clustering de usuarios por comportamiento o embeddings de productos para "productos similares". No necesitas construir un sistema de recomendación desde cero — embeddings + similitud coseno es suficiente para empezar.

    Extracción de información. Parsear emails, facturas, formularios en lenguaje natural. Un LLM con un prompt bien estructurado hace esto mejor que cualquier regex que vayas a escribir.

    TypeScript en la práctica

    Aquí es donde todo esto se vuelve concreto. No vas a implementar K-Means. Vas a llamar a una API que usa K-Means internamente. Pero entender qué hace el algoritmo te ayuda a saber qué esperar y qué debuggear.

    Embeddings con OpenAI

    import OpenAI from "openai";
    
    const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
    
    async function getEmbedding(text: string): Promise<number[]> {
      const response = await client.embeddings.create({
        model: "text-embedding-3-small",
        input: text,
      });
    
      return response.data[0].embedding; // Vector de 1536 dimensiones
    }
    
    // Similitud coseno entre dos vectores
    function cosineSimilarity(a: number[], b: number[]): number {
      const dot = a.reduce((sum, val, i) => sum + val * b[i], 0);
      const magA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0));
      const magB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0));
      return dot / (magA * magB);
    }
    
    // Búsqueda semántica básica
    // En producción: usar batching o rate limiting para evitar errores 429 de la API
    async function semanticSearch(query: string, documents: string[]) {
      const queryVector = await getEmbedding(query);
      const docVectors = await Promise.all(documents.map(getEmbedding));
    
      const scores = docVectors.map((vec, i) => ({
        document: documents[i],
        similarity: cosineSimilarity(queryVector, vec),
      }));
    
      return scores.sort((a, b) => b.similarity - a.similarity);
    }
    
    const docs = [
      "Cómo configurar un servidor NestJS",
      "Recetas de cocina italiana",
      "Deploying Node.js to production",
    ];
    
    const results = await semanticSearch("backend con Node", docs);
    console.log(results[0]); // { document: "Deploying Node.js...", similarity: 0.89 }
    

    Referencia oficial: OpenAI Embeddings API.

    Clasificación con HuggingFace Inference API

    const HF_TOKEN = process.env.HF_TOKEN;
    const MODEL = "cardiffnlp/twitter-roberta-base-sentiment-latest";
    
    interface ClassificationResult {
      label: string;
      score: number;
    }
    
    async function classifySentiment(text: string): Promise<ClassificationResult[]> {
      const response = await fetch(
        `https://api-inference.huggingface.co/models/${MODEL}`,
        {
          method: "POST",
          headers: {
            Authorization: `Bearer ${HF_TOKEN}`,
            "Content-Type": "application/json",
          },
          body: JSON.stringify({ inputs: text }),
        }
      );
    
      // Si el modelo lleva tiempo sin uso, la primera respuesta puede tardar
      // 20-30 segundos con { error: "Model is currently loading" } — reintentar.
      const data = await response.json();
      return data[0] as ClassificationResult[];
    }
    
    const result = await classifySentiment("Este producto es increíble!");
    // [{ label: "POSITIVE", score: 0.97 }, { label: "NEUTRAL", score: 0.02 }, ...]
    
    if (result[0].label === "NEGATIVE" && result[0].score > 0.85) {
      // Marcar para revisión manual
    }
    

    Referencia oficial: HuggingFace Inference API.

    Dos ejemplos, dos APIs reales, cero instalación de librerías de ML. El algoritmo corre en la nube. Tú consumes el resultado y construyes producto.

    Si quieres explorar estas integraciones dentro de un flujo completo — desde la idea hasta el producto funcionando — en el curso Construye con IA cubrimos exactamente esta capa: cómo conectar modelos de ML reales a una arquitectura de producto sin convertirte en data scientist. También publicamos tutoriales y ejemplos en el canal de YouTube de Dominicode.

    La decisión que cambia todo

    Entender estos algoritmos no significa que vayas a entrenar modelos. Significa que cuando eliges entre una búsqueda por palabras clave y una búsqueda semántica, sabes exactamente qué estás eligiendo y por qué.

    Significa que cuando un modelo de clasificación te devuelve un score bajo, sabes si el problema está en el modelo, en los datos de entrada, o en cómo estás interpretando el output.

    Significa que cuando alguien en tu equipo dice "usemos ML para esto", puedes hacer las preguntas correctas: ¿supervisado o no supervisado? ¿Tienes datos etiquetados? ¿Qué métrica defines como éxito?

    Los modelos los entrenan los data scientists. El producto lo construyes tú. Saber qué hay debajo del capó es lo que hace la diferencia entre un developer que consume IA y uno que la integra de forma inteligente.

    En Dominicode Labs tenemos proyectos completos donde aplicamos estas integraciones en contextos reales — búsqueda semántica, pipelines con embeddings, agentes que usan clasificadores como herramientas. Si quieres ver el código funcionando, es donde empieza.


    FAQ

    ¿Necesito saber matemáticas para usar algoritmos de machine learning como developer?

    No para usarlos, sí para entenderlos en profundidad. La mayoría de las integraciones que harás como developer web consumen modelos ya entrenados via API. Saber qué hace el algoritmo — qué tipo de problema resuelve y qué output produce — es suficiente para tomar buenas decisiones de arquitectura. Si en algún momento necesitas afinar un modelo o interpretar métricas de entrenamiento, entonces sí vale la pena profundizar en la matemática.

    ¿Cuál es la diferencia entre un LLM y un modelo de embeddings?

    Un LLM (como GPT-4 o Claude) está entrenado para generar texto: toma una secuencia de tokens y predice los siguientes. Un modelo de embeddings está optimizado para producir representaciones vectoriales del texto, capturando su significado semántico en un espacio de alta dimensión. Ambos usan arquitectura Transformer, pero con objetivos de entrenamiento distintos. Para búsqueda semántica, usa modelos de embeddings — son más baratos y específicos para esa tarea.

    ¿Cuándo debería usar TensorFlow.js en lugar de una API de ML?

    TensorFlow.js tiene sentido cuando necesitas ejecutar inferencia en el cliente (sin enviar datos al servidor, por privacidad), cuando tienes latencia muy baja como requisito, o cuando quieres evitar costos de API a escala. El tradeoff es que los modelos disponibles para el navegador son más pequeños y menos potentes. Para la mayoría de proyectos web, una API de HuggingFace o OpenAI es la opción correcta hasta que tengas una razón específica para moverse al cliente.

    ¿Qué es el overfitting y por qué le importa al developer que consume modelos?

    El overfitting ocurre cuando un modelo aprende demasiado bien los datos de entrenamiento y pierde capacidad de generalizar a datos nuevos. Como developer que consume un modelo ya entrenado, el overfitting se manifiesta como comportamiento inesperado: el modelo funciona bien en ejemplos estándar pero falla en casos edge de tu dominio específico. Si ves esto, la solución no es ajustar el código — es cambiar de modelo, hacer fine-tuning, o cambiar cómo preparas el input (prompt engineering, preprocesado de texto).

    ¿Qué base de datos debo usar para guardar y consultar embeddings?

    Depende de tu stack. Si ya usas PostgreSQL o Supabase, la extensión pgvector añade soporte nativo para búsqueda por similitud coseno sin infraestructura adicional. Si necesitas escala masiva (millones de vectores con latencia sub-50ms), Pinecone o Weaviate son las opciones especializadas. Para prototipos o proyectos pequeños, guardar vectores en memoria con una búsqueda lineal es perfectamente válido mientras no superes los 10k documentos.


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

  • Testing en Angular con IA: tests que protegen de verdad

    Testing en Angular con IA: tests que protegen de verdad

    Le pedí a Claude que escribiera los tests de un componente de login. Me devolvió 14 tests. Todos verdes. El CI pasó sin problema.

    Dos semanas después, un bug llegó a producción. El formulario aceptaba contraseñas vacías si el campo estaba touched pero sin valor. Ninguno de esos 14 tests lo detectó.

    Los tests no fallaron porque el bug no existía para ellos. Los tests comprobaban que el componente existía, que el formulario se renderizaba, que el método onSubmit() se llamaba. No comprobaban el comportamiento. Eran tests de que el código había sido escrito, no de que el código hacía lo correcto.

    Este es el problema número uno del testing en Angular con IA: la IA genera tests que pasan, no tests que protegen.


    El problema real de los tests generados por IA

    Cuando le das a un modelo un componente Angular y le pides “escribe los tests”, le estás pidiendo que haga ingeniería inversa de tu implementación. Y eso es exactamente lo que hace.

    Lee el código. Ve que hay un loginForm con dos controles. Ve que hay un método onSubmit(). Ve que hay un AuthService. Y escribe tests que verifican que esas cosas existen y se llaman entre sí.

    El resultado son tests acoplados a la implementación, no al comportamiento. Si renombras onSubmit() a handleSubmit(), los tests fallan. Si cambias el nombre de una variable interna, los tests fallan. Pero si introduces un bug lógico — como que el formulario se envíe con campos vacíos — los tests siguen verdes.

    Esto no es un fallo del modelo. Es un fallo del prompt. Le preguntaste lo que no debías preguntar.

    Sin contexto del comportamiento esperado, la IA no tiene forma de saber qué casos importan. No sabe cuándo debería bloquearse el submit. No sabe qué errores deben mostrarse. Así que copia lo que ve: la implementación.


    El cambio de mentalidad que lo arregla todo

    No le pidas a la IA que escriba tests. Pídele que te ayude a pensar qué testear.

    Son dos tareas completamente distintas. La primera produce código. La segunda produce criterios. Y los criterios son lo que hace que un test sea útil.

    Un test útil parte de una pregunta: “¿qué debería pasar cuando X?” No de “¿qué hace este código?”

    El flujo correcto es este:

    1. Describe el comportamiento, no el código. No copies el componente en el prompt. Describe qué hace desde fuera. Qué ve el usuario. Qué espera. Qué debe pasar si hace algo incorrecto.
    2. Pídele que liste los casos de test. Solo los casos, sin código todavía.
    3. Revisa y aprueba esa lista. Añades los que faltan. Eliminas los redundantes. Este paso es el más valioso de todo el flujo — y es el que la mayoría de devs salta.
    4. Pide el código de test para cada caso. Con Jest y Testing Library, una vez que los criterios están claros.

    Ejemplo práctico con Angular 22

    Este es el componente. Un formulario de login con Reactive Forms en Angular 22:

    // login.component.ts
    import { Component, inject, signal } from '@angular/core';
    import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
    import { Router } from '@angular/router';
    import { firstValueFrom } from 'rxjs';
    import { AuthService } from '../services/auth.service';
    
    @Component({
      selector: 'app-login',
      standalone: true,
      imports: [ReactiveFormsModule],
      template: `
        <form [formGroup]="form" (ngSubmit)="onSubmit()">
          <input formControlName="email" type="email" placeholder="Email" />
          <input formControlName="password" type="password" placeholder="Contraseña" />
          @if (errorMessage()) {
            <p class="error">{{ errorMessage() }}</p>
          }
          <button type="submit" [disabled]="form.invalid || isLoading()">
            {{ isLoading() ? 'Cargando...' : 'Entrar' }}
          </button>
        </form>
      `
    })
    export class LoginComponent {
      private fb = inject(FormBuilder);
      private auth = inject(AuthService);
      private router = inject(Router);
    
      form = this.fb.group({
        email: ['', [Validators.required, Validators.email]],
        password: ['', Validators.required]
      });
    
      errorMessage = signal('');
      isLoading = signal(false);
    
      async onSubmit() {
        if (this.form.invalid) return;
        this.isLoading.set(true);
        this.errorMessage.set('');
        try {
          await firstValueFrom(this.auth.login(this.form.value as { email: string; password: string }));
          this.router.navigate(['/dashboard']);
        } catch (err: any) {
          if (err.status === 401) {
            this.errorMessage.set('Credenciales incorrectas');
          }
        } finally {
          this.isLoading.set(false);
        }
      }
    }

    El prompt malo que genera tests inútiles:

    "Escribe los tests para este componente Angular."

    El prompt bueno, siguiendo el flujo de cuatro pasos:

    "Tengo un componente de login en Angular 22 con Reactive Forms.
    El comportamiento esperado es:
    - El botón está deshabilitado si el formulario es inválido o si está cargando
    - Al enviar credenciales válidas, se llama a AuthService.login()
    - Si AuthService lanza un error 401, se muestra 'Credenciales incorrectas'
    - Si tiene éxito, el router navega a /dashboard
    
    Lista primero los casos de test. Sin código todavía."

    Y estos son los tests resultantes con Jest y Testing Library para Angular:

    // login.component.spec.ts
    import { render, screen } from '@testing-library/angular';
    import userEvent from '@testing-library/user-event';
    import { LoginComponent } from './login.component';
    import { AuthService } from '../services/auth.service';
    import { provideRouter } from '@angular/router';
    import { of, throwError } from 'rxjs';
    
    describe('LoginComponent', () => {
      const mockAuthService = { login: jest.fn() };
    
      async function setup() {
        await render(LoginComponent, {
          providers: [
            { provide: AuthService, useValue: mockAuthService },
            provideRouter([{ path: 'dashboard', component: {} as any }])
          ]
        });
        return userEvent.setup();
      }
    
      beforeEach(() => jest.clearAllMocks());
    
      it('deshabilita el botón cuando el formulario está vacío', async () => {
        await setup();
        expect(screen.getByRole('button', { name: /entrar/i })).toBeDisabled();
      });
    
      it('deshabilita el botón con email inválido aunque haya contraseña', async () => {
        const user = await setup();
        await user.type(screen.getByPlaceholderText('Email'), 'no-es-email');
        await user.type(screen.getByPlaceholderText('Contraseña'), '123456');
        expect(screen.getByRole('button', { name: /entrar/i })).toBeDisabled();
      });
    
      it('habilita el botón con credenciales válidas', async () => {
        const user = await setup();
        await user.type(screen.getByPlaceholderText('Email'), 'user@test.com');
        await user.type(screen.getByPlaceholderText('Contraseña'), '123456');
        expect(screen.getByRole('button', { name: /entrar/i })).not.toBeDisabled();
      });
    
      it('llama a AuthService.login al hacer submit con datos válidos', async () => {
        mockAuthService.login.mockReturnValue(of({}));
        const user = await setup();
        await user.type(screen.getByPlaceholderText('Email'), 'user@test.com');
        await user.type(screen.getByPlaceholderText('Contraseña'), '123456');
        await user.click(screen.getByRole('button', { name: /entrar/i }));
        expect(mockAuthService.login).toHaveBeenCalledWith({
          email: 'user@test.com',
          password: '123456'
        });
      });
    
      it('muestra mensaje de error cuando el servicio responde 401', async () => {
        mockAuthService.login.mockReturnValue(throwError(() => ({ status: 401 })));
        const user = await setup();
        await user.type(screen.getByPlaceholderText('Email'), 'user@test.com');
        await user.type(screen.getByPlaceholderText('Contraseña'), 'wrong');
        await user.click(screen.getByRole('button', { name: /entrar/i }));
        expect(await screen.findByText('Credenciales incorrectas')).toBeInTheDocument();
      });
    });

    La clave está en userEvent.type en lugar de fireEvent.input — con Reactive Forms en Angular, solo userEvent actualiza el FormControl correctamente en el entorno de test. Y el mock usa of({}) y throwError() de RxJS porque AuthService.login() devuelve un Observable.

    Esto es exactamente el enfoque que trabajamos en el curso de Testing en Angular con Jest y Testing Library: probar comportamiento, no implementación.


    Tests de servicios con IA: qué mockear y cómo describirlo

    Los servicios son donde más fácil es equivocarse al usar IA para testing.

    El error más común: pedirle a la IA que mockee el propio servicio para testearlo. Si mockeas AuthService en el test de AuthService, estás probando el mock, no el servicio.

    Lo que debes describirle a la IA es esto:

    "Tengo un AuthService en Angular 22 que inyecta HttpClient.
    El método login() hace POST a /api/auth/login con email y password.
    Devuelve un Observable<User>. En caso de error HTTP lo relanza tal cual.
    Escribe los tests usando provideHttpClient() + provideHttpClientTesting() y HttpTestingController.
    No mockees el servicio. Mockea solo el HttpClient."

    Con ese prompt, la IA sabe exactamente qué nivel de la pila debe sustituir:

    // auth.service.spec.ts
    import { TestBed } from '@angular/core/testing';
    import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
    import { provideHttpClient } from '@angular/common/http';
    import { AuthService } from './auth.service';
    
    describe('AuthService', () => {
      let service: AuthService;
      let httpMock: HttpTestingController;
    
      beforeEach(() => {
        TestBed.configureTestingModule({
          providers: [AuthService, provideHttpClient(), provideHttpClientTesting()]
        });
        service = TestBed.inject(AuthService);
        httpMock = TestBed.inject(HttpTestingController);
      });
    
      afterEach(() => httpMock.verify());
    
      it('hace POST a /api/auth/login con las credenciales', () => {
        const credentials = { email: 'user@test.com', password: '123456' };
        service.login(credentials).subscribe();
        const req = httpMock.expectOne('/api/auth/login');
        expect(req.request.method).toBe('POST');
        expect(req.request.body).toEqual(credentials);
        req.flush({ id: 1, email: 'user@test.com' });
      });
    
      it('devuelve el usuario cuando el servidor responde con éxito', () => {
        const mockUser = { id: 1, email: 'user@test.com' };
        let result: any;
        service.login({ email: 'user@test.com', password: '123456' })
          .subscribe(user => (result = user));
        httpMock.expectOne('/api/auth/login').flush(mockUser);
        expect(result).toEqual(mockUser);
      });
    
      it('relanza el error HTTP cuando el servidor responde 401', () => {
        let error: any;
        service.login({ email: 'user@test.com', password: 'wrong' })
          .subscribe({ error: err => (error = err) });
        httpMock.expectOne('/api/auth/login').flush(
          { message: 'Unauthorized' },
          { status: 401, statusText: 'Unauthorized' }
        );
        expect(error.status).toBe(401);
      });
    });

    La clave está en la instrucción: “mockea solo el HttpClient”. Esa precisión es lo que separa un prompt que genera tests útiles de uno que genera ruido.

    Si quieres ver cómo aplicar este patrón a servicios más complejos — con interceptores, state management y Signals — en el curso de Angular Moderno tienes la arquitectura base sobre la que todo esto encaja.


    Lo que la IA no puede hacer por ti

    La IA puede generar el código de test más rápido de lo que tú lo escribirías. No puede decirte qué casos importan en tu dominio de negocio.

    No sabe que en tu aplicación una contraseña vacía tiene un tratamiento especial. No sabe que hay un edge case cuando el usuario tiene sesión expirada y reintenta. No sabe que el botón de carga es crítico porque en producción la red va lenta y los usuarios hacen doble click.

    Ese conocimiento solo lo tienes tú. Tu trabajo es trasladarlo al prompt antes de pedir código. La IA amplifica lo que le das — si le das una descripción de comportamiento, amplifica eso. Si le das solo el código de implementación, amplifica eso.

    El flujo de cuatro pasos no es burocracia. Es el mínimo para que la IA genere tests que protejan algo.

    Si quieres llevar esta forma de trabajar más lejos — combinando especificaciones previas al código con IA para que los tests sean parte del diseño — eso es lo que construimos en el curso Construye con IA: de la Idea al Producto. Y si quieres acceso a los proyectos completos con suites de tests reales, los encontrarás en Dominicode Labs.


    FAQ

    ¿Puedo usar cualquier modelo de IA o Claude es el mejor para esto?

    El flujo de cuatro pasos funciona con cualquier modelo — Claude, GPT-4o, Gemini. La calidad del output depende mucho más de la calidad del prompt que del modelo. Dicho esto, Claude tiene ventaja en identificar casos borde cuando describes comportamientos complejos con muchas condiciones.

    ¿La IA puede generar tests TDD, es decir, antes de escribir el componente?

    Sí, y es el flujo ideal. Describes el comportamiento, pides los casos, apruebas la lista, pides el código de test — y luego le pides que implemente el componente para que esos tests pasen. Es TDD asistido por IA, y es especialmente potente para componentes nuevos.

    ¿Testing Library o Spectator para Angular?

    Testing Library porque te obliga a pensar en términos de comportamiento desde el principio. getByRole, getByPlaceholderText, findByText — todas esas queries buscan lo que el usuario ve, no lo que el código tiene internamente. Spectator facilita demasiado el acceso directo a la instancia del componente, lo que lleva a tests acoplados a implementación.

    ¿Cómo sé si un test generado por IA es bueno?

    Una heurística sencilla: introduce manualmente el bug más obvio en el componente y corre los tests. Si los tests siguen verdes, no valen nada. Por ejemplo, en el componente de login, pon if (true) return; al principio de onSubmit() — si el test de “llama a AuthService.login” sigue pasando, ese test no prueba nada. Esta técnica se llama mutation testing.

    ¿Vale la pena testear componentes de presentación puros?

    Depende de la complejidad. Un componente que solo muestra datos sin lógica condicional no necesita tests exhaustivos. Pero si tiene lógica de visualización — mostrar un badge según el estado, calcular clases CSS condicionalmente — esa lógica sí merece tests. Pregúntale a la IA: “¿qué comportamientos condicionales tiene este template que merecen ser testados?”


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