<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Blog de Yohangel Ramos</title>
    <link>https://yohangel.com/blog/</link>
    <atom:link href="https://yohangel.com/rss.xml" rel="self" type="application/rss+xml"/>
    <description>Notas de un builder: IA aplicada al desarrollo, arquitectura serverless en AWS y frontend moderno.</description>
    <language>es-ES</language>
    <copyright>© 2026 Yohangel Ramos</copyright>
    <managingEditor>yohangelr@gmail.com (Yohangel Ramos)</managingEditor>
    <lastBuildDate>Tue, 18 Aug 2026 00:00:00 GMT</lastBuildDate>
    <image>
      <url>https://yohangel.com/icon-512.png</url>
      <title>Blog de Yohangel Ramos</title>
      <link>https://yohangel.com/blog/</link>
    </image>
    <item>
      <title>Lambda contra PostgreSQL: el día que te quedas sin conexiones</title>
      <link>https://yohangel.com/blog/lambda-postgres-conexiones-rds-proxy/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/lambda-postgres-conexiones-rds-proxy/</guid>
      <description>Un pool de conexiones y una función efímera son dos ideas que se contradicen. Por qué se agotan las conexiones, qué arregla RDS Proxy y qué se arregla gratis antes de pagarlo.</description>
      <pubDate>Tue, 18 Aug 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>AWS</category>
      <content:encoded><![CDATA[<p><strong>El error llega siempre en el peor momento: <code>remaining connection slots are reserved for non-replication superuser connections</code>. La aplicación llevaba semanas funcionando, nadie tocó el código de base de datos, y de repente un pico de tráfico deja media API devolviendo 500. Lo primero que hace todo el mundo es subir <code>max_connections</code> en la instancia. Funciona un rato, y luego vuelve a pasar. El problema no es el número: es que un pool de conexiones y una función efímera son dos ideas que se contradicen.</strong></p>
<h2>Un pool por entorno, no un pool por aplicación</h2>
<p>Un pool existe para amortizar algo caro. Abrir una conexión a PostgreSQL cuesta: handshake TCP, TLS, autenticación, y el servidor levanta un proceso dedicado por conexión. En un backend de larga vida —un Nest en Fargate, por ejemplo— abres diez conexiones al arrancar y las reutilizas durante días. El coste se paga una vez.</p>
<p>En Lambda no existe ese &quot;al arrancar&quot; en el sentido que asumes. Hay un arranque por <strong>entorno de ejecución</strong>, y AWS crea tantos entornos como invocaciones concurrentes tengas. Si tu pico son 200 invocaciones a la vez, tienes 200 procesos Node independientes, cada uno con su propio pool. Un pool de 10 por entorno son 2.000 conexiones llamando a la puerta de una base de datos que aguanta 100.</p>
<p>La cuenta es de una simplicidad brutal:</p>
<pre><code>conexiones = concurrencia de Lambda × tamaño del pool por entorno
</code></pre>
<p>Una <code>db.t4g.medium</code> ronda las 220 <code>max_connections</code>. Con 50 invocaciones concurrentes y un pool de 5 ya estás en el límite. Y a diferencia de un servidor, aquí la concurrencia no la decides tú: la decide el tráfico.</p>
<h2>El fallo que multiplica el daño: abrir el pool dentro del handler</h2>
<p>Antes de tocar infraestructura, mira dónde se crea el cliente. Este patrón es el que más veces me he encontrado, y convierte un problema de escala en un problema inmediato:</p>
<pre><code class="language-typescript">// MAL: un pool nuevo por invocación, y muchas conexiones quedan colgando
export const handler = async (event) =&gt; {
  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
  const { rows } = await pool.query(&#39;SELECT id FROM users WHERE email = $1&#39;, [event.email]);
  return rows[0];
};
</code></pre>
<p>Cada invocación abre un pool nuevo. Si además falta el <code>end()</code> —y casi siempre falta— la conexión queda ocupada hasta que PostgreSQL la recicla por timeout. Acabas de convertir cada petición en una fuga.</p>
<p>Lo correcto es sacarlo del handler, al ámbito del módulo. Ese código corre una vez por entorno de ejecución, y el entorno se reutiliza entre invocaciones mientras siga caliente:</p>
<pre><code class="language-typescript">// El pool vive en el entorno de ejecución y sobrevive entre invocaciones
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 1,                       // un entorno atiende una invocación a la vez
  idleTimeoutMillis: 30000,     // suelta la conexión si el entorno queda ocioso
  connectionTimeoutMillis: 5000,
});

export const handler = async (event) =&gt; {
  const { rows } = await pool.query(&#39;SELECT id FROM users WHERE email = $1&#39;, [event.email]);
  return rows[0];
};
</code></pre>
<p><code>max: 1</code> sorprende a quien viene de servidores, pero es exactamente lo que quieres: un entorno de Lambda procesa <strong>una</strong> invocación cada vez, así que un pool de 10 son nueve conexiones que nunca trabajan en paralelo y que sí cuentan contra tu límite.</p>
<h2>Reserved concurrency: el techo que sí controlas</h2>
<p>Con <code>max: 1</code>, tu número de conexiones pasa a ser exactamente tu concurrencia. Y esa sí se puede acotar, función por función:</p>
<pre><code class="language-hcl">resource &quot;aws_lambda_function&quot; &quot;api&quot; {
  function_name                  = &quot;api-handler&quot;
  reserved_concurrent_executions = 40   # techo duro de 40 conexiones
  # ...
}
</code></pre>
<p>Es una decisión incómoda porque significa aceptar throttling: pasadas 40 invocaciones simultáneas, Lambda rechaza. Pero prefiero mil veces un error acotado en una función concreta que una base de datos que deja de aceptar conexiones y tumba de paso a los workers, las migraciones y el panel de administración, que no tenían nada que ver. Un límite no es una restricción: es decidir de antemano quién sufre primero cuando algo se rompe.</p>
<h2>RDS Proxy: qué resuelve y qué no</h2>
<p>RDS Proxy se sienta entre Lambda y la base de datos, y mantiene su propio conjunto de conexiones ya abiertas. Tus funciones hablan con el proxy, y él multiplexa: muchos clientes efímeros contra unas pocas conexiones reales reutilizadas.</p>
<p>Lo que gana de verdad:</p>
<ul>
<li><strong>Absorbe los picos.</strong> Cuando la base de datos está al límite, el proxy encola en vez de rechazar.</li>
<li><strong>Elimina el coste de abrir conexión</strong> en cada arranque en frío. Se nota más de lo que parece en la latencia p99.</li>
<li><strong>Failover más limpio.</strong> Mantiene viva la conexión del cliente mientras el motor conmuta.</li>
</ul>
<p>Lo que conviene saber antes de meterlo:</p>
<ul>
<li><strong>No es gratis.</strong> Se factura por vCPU de la instancia de base de datos, y en cargas pequeñas puede salir más caro que subir de tamaño la propia base.</li>
<li><strong>El pinning te arruina la fiesta.</strong> Si usas transacciones largas, sentencias preparadas a nivel de sesión, <code>SET</code> de variables o tablas temporales, el proxy fija una conexión a tu sesión y deja de multiplexar. Terminas pagando por un proxy que no reutiliza nada. Se ve en CloudWatch con <code>DatabaseConnectionsCurrentlySessionPinned</code>, y es lo primero que miro cuando alguien dice que el proxy &quot;no hizo nada&quot;.</li>
<li><strong>Vive dentro de la VPC.</strong> Lo que implica meter la Lambda en subredes privadas, con todo lo que eso arrastra en arranques y en factura de red.</li>
</ul>
<h2>Lo que haría hoy</h2>
<p>El orden importa, y casi nadie lo respeta:</p>
<ol>
<li><strong>Saca el pool del handler y pon <code>max: 1</code>.</strong> Cinco minutos, cero coste, y resuelve la mayoría de los casos.</li>
<li><strong>Pon <code>reserved_concurrent_executions</code></strong> en las funciones que tocan la base de datos. También gratis, y convierte una caída total en una degradación acotada.</li>
<li><strong>Mide antes de comprar.</strong> Mira <code>DatabaseConnections</code> en CloudWatch durante un pico real. Si estás lejos del techo, no necesitas un proxy.</li>
<li><strong>Añade RDS Proxy</strong> cuando el tráfico sea genuinamente irregular y hayas comprobado que tus consultas no provocan pinning.</li>
</ol>
<p>Y una nota que no es técnica: si el 90% de tu aplicación es un CRUD contra PostgreSQL con tráfico razonablemente constante, es probable que la respuesta correcta no sea ninguna de estas cuatro, sino un contenedor de larga vida con un pool normal y aburrido. Lambda es espectacular para trabajo a ráfagas. Contra una base relacional bajo carga sostenida, muchas veces estás pagando complejidad para resolver un problema que no tenías.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Estados de carga: ese spinner centrado es una decisión de producto</title>
      <link>https://yohangel.com/blog/estados-de-carga-skeletons-streaming/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/estados-de-carga-skeletons-streaming/</guid>
      <description>Mientras cargan los datos hay una pantalla que casi nadie diseña. Skeletons que no saltan, streaming con Suspense y el truco de retrasar el spinner para que la app se sienta rápida.</description>
      <pubDate>Mon, 10 Aug 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>Frontend</category>
      <content:encoded><![CDATA[<p><strong>Hay una pantalla que casi nadie diseña y que todos los usuarios ven: la que aparece mientras cargan los datos. En Figma llega la maqueta con los datos ya puestos, bonita y completa, y el estado de carga se resuelve en el último commit con un <code>if (loading) return &lt;Spinner /&gt;</code>. Ese if es una decisión de producto tomada a las once de la noche por la persona que tenía prisa. Y se nota: la app se siente lenta, la pantalla salta cuando llegan los datos, y el usuario no sabe si esperar o recargar. He pasado años arreglando eso en dashboards, en un planificador de comidas y en paneles internos, y mi conclusión es que los estados de carga se ganan casi toda la percepción de velocidad que el usuario tiene de tu producto.</strong></p>
<h2>El spinner centrado es la peor respuesta por defecto</h2>
<p>Un spinner en medio de la pantalla dice una sola cosa: &quot;algo está pasando, no sé qué, no sé cuánto falta&quot;. Borra toda la estructura que ya conocías y la reemplaza por una ruedita. Cuando los datos llegan, la interfaz aparece de golpe en una posición distinta y tu ojo tiene que volver a orientarse.</p>
<p>El problema de fondo es que trata la pantalla como una unidad atómica: o está todo, o no hay nada. Pero casi nunca es cierto. En un dashboard típico, el título, la navegación, los filtros y la estructura de las tarjetas se conocen antes de pedir un solo byte al servidor. Lo único que falta son los números. Bloquear el 90% de la interfaz que ya podías pintar para esperar al 10% que no, es tirar rendimiento percibido a la basura.</p>
<p>La regla que aplico: <strong>pinta todo lo que ya sabes, y marca como pendiente solo lo que de verdad depende del servidor.</strong></p>
<h2>Skeletons que no saltan: reservar el espacio real</h2>
<p>Un skeleton es un placeholder con la forma del contenido final. Funciona por dos razones: comunica qué va a aparecer y, sobre todo, reserva el espacio para que nada salte cuando llegue. Si tu skeleton mide 40px y la fila real mide 72px, has cambiado un spinner por un salto de layout, que es peor.</p>
<p>Por eso los construyo a partir del mismo componente, no como una maqueta paralela que se desincroniza a la primera:</p>
<pre><code class="language-jsx">function FilaCandidato({ datos }) {
  return (
    &lt;li className=&quot;fila&quot;&gt;
      &lt;span className=&quot;avatar&quot;&gt;{datos ? &lt;img src={datos.avatar} alt=&quot;&quot; /&gt; : null}&lt;/span&gt;
      &lt;span className=&quot;nombre&quot;&gt;{datos ? datos.nombre : &lt;Bloque w=&quot;60%&quot; /&gt;}&lt;/span&gt;
      &lt;span className=&quot;puesto&quot;&gt;{datos ? datos.puesto : &lt;Bloque w=&quot;35%&quot; /&gt;}&lt;/span&gt;
    &lt;/li&gt;
  );
}
</code></pre>
<p>La fila tiene la misma altura y el mismo grid en ambos estados, así que el paso de skeleton a datos es un cambio de contenido, no de layout. El CSS hace el resto del trabajo pesado:</p>
<pre><code class="language-css">.fila { display: grid; grid-template-columns: 40px 1fr 1fr; min-height: 72px; }
.avatar { aspect-ratio: 1; border-radius: 50%; background: var(--gris-200); }

@media (prefers-reduced-motion: no-preference) {
  .bloque { animation: pulso 1.4s ease-in-out infinite; }
}
</code></pre>
<p>Dos detalles que se olvidan siempre: <code>min-height</code> en la fila (evita el CLS cuando el contenido real es más alto) y respetar <code>prefers-reduced-motion</code>, porque una pantalla llena de bloques pulsando es exactamente el tipo de animación que marea a alguna gente.</p>
<h2>Streaming con Suspense: no esperes al dato más lento</h2>
<p>Cuando una página pide cuatro cosas, lo normal es que tarden 80ms, 120ms, 150ms… y 900ms. Si esperas a todas para pintar, tu página tarda 900ms. Con streaming, el servidor manda el HTML de lo que ya está listo y el resto llega después por la misma conexión.</p>
<p>En React esto se expresa con fronteras de <code>Suspense</code>. Cada frontera es una promesa al usuario: &quot;esta parte va a llegar más tarde, el resto ya está aquí&quot;.</p>
<pre><code class="language-jsx">export default function Panel() {
  return (
    &lt;Layout&gt;
      &lt;Cabecera /&gt;                        {/* instantáneo, sin datos */}
      &lt;Suspense fallback={&lt;KpisSkeleton /&gt;}&gt;
        &lt;Kpis /&gt;                          {/* rápido */}
      &lt;/Suspense&gt;
      &lt;Suspense fallback={&lt;TablaSkeleton filas={8} /&gt;}&gt;
        &lt;TablaInformes /&gt;                 {/* el lento: 900ms */}
      &lt;/Suspense&gt;
    &lt;/Layout&gt;
  );
}
</code></pre>
<p>Lo importante no es la sintaxis, es dónde pones las fronteras. Una frontera por página no sirve de nada: vuelves al spinner global con más pasos. Una frontera por componente diminuto tampoco: acabas con una pantalla que parpadea por trozos durante dos segundos y se siente rota. Mi criterio es poner la frontera <strong>alrededor de bloques que el usuario percibe como una unidad</strong> —una tarjeta, una tabla, un panel lateral— y sobre todo aislar lo lento para que no contagie a lo rápido.</p>
<h2>El detalle que más quejas me quitó: retrasar y sostener</h2>
<p>Si una petición tarda 90ms y muestras un skeleton, el usuario ve un parpadeo: aparece y desaparece antes de que el ojo lo procese. Ese flash se percibe como un glitch, no como velocidad. Y si el skeleton se muestra 40ms, molesta más que si no hubiera aparecido.</p>
<p>La solución no es técnica, es de tiempos: <strong>no muestres el estado de carga antes de ~200ms, y si lo muestras, sostenlo al menos ~400ms.</strong></p>
<pre><code class="language-ts">export function useCargaVisible(cargando: boolean) {
  const [visible, setVisible] = useState(false);

  useEffect(() =&gt; {
    if (!cargando) return;
    const espera = setTimeout(() =&gt; setVisible(true), 200);
    return () =&gt; clearTimeout(espera);      // terminó antes: nunca se vio
  }, [cargando]);

  useEffect(() =&gt; {
    if (cargando || !visible) return;
    const minimo = setTimeout(() =&gt; setVisible(false), 400);
    return () =&gt; clearTimeout(minimo);      // ya visible: aguanta 400ms
  }, [cargando, visible]);

  return visible;
}
</code></pre>
<p>Con esto, las respuestas rápidas no muestran nada —que es lo que quieres, porque ya son rápidas— y las lentas muestran un estado estable en vez de un parpadeo. Es el cambio más pequeño que he hecho con más impacto directo en &quot;oye, la app va mucho mejor ahora&quot;.</p>
<h2>Lo que hago hoy en cada pantalla nueva</h2>
<p>Antes de escribir el primer <code>fetch</code>, me hago tres preguntas: qué puedo pintar sin datos (eso va fuera de cualquier frontera), qué bloques percibe el usuario como unidades (ahí van las fronteras de Suspense y sus skeletons), y cuál es el dato lento (ese se aísla siempre, aunque sea el más importante de la pantalla).</p>
<p>El error mental que me costó años corregir fue pensar en la carga como un momento binario —cargando o cargado— cuando en realidad es una secuencia que puedes coreografiar. La misma petición de 900ms puede sentirse como una app rota o como una app viva, y la diferencia no está en el backend: está en qué decides mostrar durante esos 900ms. Optimizar la petición de 900 a 600 cuesta una tarde de trabajo y casi nadie lo nota; diseñar bien lo que se ve mientras tanto cuesta una hora y lo nota todo el mundo.</p>
]]></content:encoded>
    </item>
    <item>
      <title>CloudFront delante de tu app: por qué tu hit rate es del 30%</title>
      <link>https://yohangel.com/blog/cloudfront-cache-key-invalidaciones/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/cloudfront-cache-key-invalidaciones/</guid>
      <description>Poner un CDN no es cachear. La cache key, el Cache-Control del origen y las invalidaciones deciden si CloudFront te ahorra dinero o solo te añade un salto más.</description>
      <pubDate>Mon, 03 Aug 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>AWS</category>
      <content:encoded><![CDATA[<p><strong>Poner CloudFront delante de una app es de esas decisiones que todo el mundo aplaude en la reunión y casi nadie verifica después. Se crea la distribución, el dominio apunta ahí, el primer test da mejor latencia y se asume que &quot;ya está cacheado&quot;. Meses más tarde abres las métricas y el hit rate está en 30%: siete de cada diez peticiones siguen llegando a tu origen, pagas transferencia dos veces y encima tienes una capa más donde depurar cuando algo sale raro. No es que el CDN no funcione. Es que cachear no es activar una casilla: es decidir tres cosas —qué identifica a una respuesta, cuánto vive y cómo se sustituye— y esas tres decisiones son tuyas, no de AWS.</strong></p>
<h2>La cache key es el 90% del problema</h2>
<p>Un CDN guarda respuestas indexadas por una clave. Si dos peticiones generan la misma clave, la segunda es un hit. Todo lo que metas en esa clave multiplica el número de entradas posibles y divide tu hit rate.</p>
<p>El caso que más veces he visto: alguien reenvía la cookie de sesión al origen &quot;porque la app la necesita&quot; y, sin querer, la mete también en la cache key. A partir de ahí cada usuario tiene su propia copia de cada recurso. Técnicamente hay caché; en la práctica no cachea nada. Lo mismo pasa con reenviar todas las query strings: los <code>utm_source</code> de una campaña convierten una URL en cincuenta.</p>
<p>La pieza que arregla esto es entender que CloudFront tiene dos políticas distintas y separadas: la <strong>cache policy</strong> define qué entra en la clave, y la <strong>origin request policy</strong> define qué se le manda al origen. No son lo mismo. La cookie puede viajar al origen sin fragmentar la caché.</p>
<pre><code class="language-hcl">resource &quot;aws_cloudfront_cache_policy&quot; &quot;estaticos&quot; {
  name        = &quot;static-assets&quot;
  min_ttl     = 0
  default_ttl = 86400
  max_ttl     = 31536000

  parameters_in_cache_key_and_forwarded_to_origin {
    enable_accept_encoding_brotli = true
    enable_accept_encoding_gzip   = true

    cookies_config { cookie_behavior = &quot;none&quot; }
    headers_config { header_behavior = &quot;none&quot; }

    query_strings_config {
      query_string_behavior = &quot;whitelist&quot;
      query_strings { items = [&quot;v&quot;] }
    }
  }
}
</code></pre>
<p>Regla que aplico sin excepciones: la cache key empieza vacía y se le añade lo que se pueda justificar. Nunca al revés.</p>
<h2>El <code>Cache-Control</code> manda; la distribución solo pone límites</h2>
<p>Los TTL de la distribución confunden a mucha gente porque parecen la configuración principal, y no lo son. <code>default_ttl</code> solo se aplica cuando el origen <strong>no</strong> manda cabeceras de caché. <code>max_ttl</code> es un techo. <code>min_ttl</code>, un suelo. El que decide de verdad es tu origen, ruta por ruta.</p>
<p>Y ahí está la cabecera que más rendimiento me ha dado por carácter escrito: <code>s-maxage</code>.</p>
<pre><code class="language-http"># Bundle con hash en el nombre: inmutable de verdad
Cache-Control: public, max-age=31536000, immutable

# HTML de una página que cambia: el navegador no lo guarda, el CDN sí
Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400
</code></pre>
<p><code>max-age</code> habla con el navegador; <code>s-maxage</code> habla con las cachés compartidas —el CDN— y tiene prioridad sobre el anterior. Esa separación es lo que te permite publicar cambios rápido: el navegador del usuario no retiene nada, el edge retiene cinco minutos, y purgar el edge está bajo tu control. Purgar el navegador de alguien no lo está, nunca.</p>
<blockquote>
<p>💡 Si tu HTML sale del origen con <code>max-age=3600</code>, no tienes un problema de CDN: tienes usuarios con una versión vieja durante una hora y ninguna forma de arreglarlo. <code>max-age=0, s-maxage=3600</code> cachea igual de bien y sí se puede revertir.</p>
</blockquote>
<h2>Invalidar es el plan B; versionar es el plan A</h2>
<p>Las invalidaciones son la herramienta que todo el mundo usa primero y la que menos debería hacer falta. Son asíncronas, tardan, tienen las primeras 1.000 rutas al mes gratis y luego se facturan, y sobre todo: un <code>/*</code> en cada despliegue vacía la caché entera y manda todo el tráfico a tu origen a la vez, justo en el momento en el que acabas de desplegar. Es la peor combinación posible.</p>
<p>La alternativa es que la invalidación deje de ser necesaria. Si tus assets llevan el hash del contenido en el nombre, un despliegue no modifica ficheros: publica ficheros nuevos, con URLs nuevas, que nadie tiene cacheadas. No hay nada que invalidar. Lo único que cambia de sitio es el documento de entrada, y ese ya tiene un <code>s-maxage</code> corto.</p>
<p>Cuando aun así hace falta, la acoto:</p>
<pre><code class="language-bash"># Plan B, y acotado: solo los documentos de entrada
aws cloudfront create-invalidation \
  --distribution-id E2XXXXXXXXXXXX \
  --paths &#39;/index.html&#39; &#39;/blog/*&#39;
</code></pre>
<h2><code>stale-while-revalidate</code>: que el origen deje de sufrir</h2>
<p>Sin <code>stale-while-revalidate</code>, el momento en que expira el TTL es un acantilado: la petición que llega justo después espera a que el origen responda entero. Con tráfico alto, varias peticiones llegan a la vez y todas van al origen por la misma URL.</p>
<p>Con SWR, el edge sirve la copia caducada inmediatamente y revalida por detrás. El usuario nunca paga la latencia del refresco, y tu p99 deja de tener picos periódicos que no correlacionan con nada. Añade <code>stale-if-error</code> y además tienes un modo degradado gratis: si el origen devuelve 5xx, se sigue sirviendo lo último bueno en lugar de propagar el error.</p>
<p>Si el origen sigue notando el tráfico de revalidación, Origin Shield añade una capa de caché intermedia que consolida esas peticiones. Es un coste extra: lo activo cuando los datos lo piden, no por defecto.</p>
<h2>Cómo lo mido antes de tocar nada</h2>
<p>La métrica <code>CacheHitRate</code> de la consola sirve para saber que tienes un problema, no para saber dónde. Para eso están los logs: cada línea trae <code>x-edge-result-type</code> con <code>Hit</code>, <code>RefreshHit</code>, <code>Miss</code>, <code>Error</code>. Agrupar por URI y por ese campo te da el diagnóstico en una consulta.</p>
<pre><code class="language-sql">SELECT uri, x_edge_result_type, count(*) AS n
FROM cloudfront_logs
WHERE date &gt;= current_date - interval &#39;7&#39; day
GROUP BY 1, 2
ORDER BY n DESC
LIMIT 50;
</code></pre>
<p>Si la misma URI aparece arriba con <code>Miss</code> y mucho volumen, solo hay dos explicaciones: la cache key la está fragmentando, o el origen manda un <code>Cache-Control</code> que impide cachearla. Las dos se arreglan en diez minutos cuando sabes cuál de las dos es.</p>
<h2>Lo que haría hoy, en este orden</h2>
<p>Decidir el <code>Cache-Control</code> en el origen por tipo de ruta antes que nada. Empezar la cache key vacía y añadir solo lo justificable. Poner hash en el nombre de los assets para que invalidar sea excepcional. <code>stale-while-revalidate</code> en todo lo que sea HTML. Y medir con <code>x-edge-result-type</code> antes de tocar la siguiente palanca.</p>
<p>Un CDN mal configurado no es neutral: añade un salto, una capa donde el bug se esconde y una factura de transferencia que pagas dos veces. Bien configurado es de las pocas cosas en AWS donde el ahorro y la latencia mejoran en la misma dirección. La diferencia entre las dos versiones no es el producto: son tres decisiones que caben en una tarde.</p>
]]></content:encoded>
    </item>
    <item>
      <title>INP: por qué tu app se siente lenta aunque cargue rápido</title>
      <link>https://yohangel.com/blog/inp-responsividad-interacciones/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/inp-responsividad-interacciones/</guid>
      <description>El LCP en verde no significa que tu app responda bien. INP mide lo que el usuario siente al hacer clic, y arreglarlo cambia la percepción de calidad.</description>
      <pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>Frontend</category>
      <content:encoded><![CDATA[<p><strong>Tienes el LCP en verde, el bundle está optimizado, Lighthouse te da 95 y aun así alguien del equipo dice &quot;la app se siente lenta&quot;. No está loco. Está describiendo algo que tu métrica de carga no ve: lo que pasa cuando toca un botón y la interfaz tarda en reaccionar. Esa sensación tiene nombre y desde 2024 es un Core Web Vital: INP, Interaction to Next Paint. Mide el peor caso de latencia entre que el usuario interactúa y la pantalla responde. Un producto puede cargar rapidísimo y sentirse basura, porque cargar y responder son dos problemas distintos. Este es el que más me ha costado explicarle a gente que solo mira el número de carga.</strong></p>
<h2>Qué mide INP y por qué el LCP no lo cubre</h2>
<p>El LCP (Largest Contentful Paint) responde a &quot;¿cuánto tardó en aparecer el contenido principal?&quot;. Es un problema de arranque: sucede una vez, al principio. INP es otra cosa: durante toda la sesión, cada clic, cada tap, cada tecla dispara un ciclo de &quot;el navegador recibe el evento → corre tu JavaScript → repinta&quot;. INP se queda con la peor de esas latencias y esa es tu nota.</p>
<p>La diferencia importa porque las apps modernas viven en la interacción, no en la carga. Un dashboard, un editor, un panel de filtros: el usuario carga una vez y luego interactúa mil. Si cada filtro que activa congela la interfaz 300ms, tu LCP perfecto no lo salva. El umbral bueno de INP es 200ms; por encima de 500ms se siente roto.</p>
<h2>El culpable casi siempre es el mismo: tareas largas</h2>
<p>El navegador es de un solo hilo para tu JavaScript. Cuando corre una función, no puede repintar ni atender otro evento hasta que esa función termina. Si al hacer clic disparas un cálculo o un render que tarda 250ms, durante esos 250ms la interfaz está muerta: el botón no muestra su estado activo, nada se mueve. Eso es una &quot;tarea larga&quot; y es el 90% de los problemas de INP que he depurado.</p>
<p>El patrón típico: un input de búsqueda que en cada tecla filtra y re-renderiza una lista de 2.000 elementos. Cada pulsación reconstruye el árbol entero antes de dejar que el navegador repinte el propio texto que estás escribiendo. El resultado es un input que va a tirones aunque el filtrado sea trivial.</p>
<pre><code class="language-js">// Mide dónde se va el tiempo: registra cualquier tarea que bloquee &gt;50ms
new PerformanceObserver((list) =&gt; {
  for (const entry of list.getEntries()) {
    console.warn(&#39;Tarea larga:&#39;, Math.round(entry.duration), &#39;ms&#39;, entry);
  }
}).observe({ type: &#39;longtask&#39;, buffered: true });
</code></pre>
<p>Antes de optimizar nada, mido. La <code>PerformanceObserver</code> de <code>longtask</code> te dice qué interacciones bloquean el hilo y cuánto. No adivines: casi nunca es lo que crees.</p>
<h2>Arreglarlo en React: separar lo urgente de lo que puede esperar</h2>
<p>La clave conceptual es que no todo lo que dispara una interacción es igual de urgente. Cuando escribes en un buscador, mostrar la letra que tecleaste es urgentísimo; recalcular la lista filtrada puede esperar 50ms sin que nadie lo note. React 18 me dio las herramientas para decir exactamente eso.</p>
<p><code>useDeferredValue</code> marca un valor como &quot;de baja prioridad&quot;: React pinta primero la entrada urgente (el texto del input) y recalcula lo derivado después, sin bloquear.</p>
<pre><code class="language-jsx">function Buscador({ items }) {
  const [query, setQuery] = useState(&#39;&#39;);
  const diferido = useDeferredValue(query);

  // Se recalcula con el valor diferido, no con cada tecla
  const filtrados = useMemo(
    () =&gt; items.filter((i) =&gt; i.nombre.includes(diferido)),
    [items, diferido],
  );

  return (
    &lt;&gt;
      &lt;input value={query} onChange={(e) =&gt; setQuery(e.target.value)} /&gt;
      &lt;Lista items={filtrados} /&gt;
    &lt;/&gt;
  );
}
</code></pre>
<p>El input responde instantáneo porque su render no espera al filtrado. Para acciones más pesadas —cambiar de pestaña, aplicar un filtro que reordena media pantalla— uso <code>useTransition</code>, que envuelve la actualización costosa y deja que el clic muestre su feedback antes de arrancar el trabajo:</p>
<pre><code class="language-jsx">const [pending, startTransition] = useTransition();

function onFiltrar(nuevo) {
  setFiltroActivo(nuevo);              // urgente: el botón se marca ya
  startTransition(() =&gt; {
    setResultados(recalcular(nuevo));  // no urgente: no bloquea el clic
  });
}
</code></pre>
<h2>Cuando no hay React de por medio: cede el hilo</h2>
<p>A veces el trabajo pesado no es un render, es un bucle: procesar un CSV, transformar un JSON grande. Ahí la solución es cortar la tarea larga en trozos y devolverle el control al navegador entre uno y otro, para que pueda atender clics y repintar. La forma moderna es <code>scheduler.yield()</code>; el fallback de siempre es un <code>setTimeout(0)</code>.</p>
<pre><code class="language-js">async function procesar(filas) {
  for (let i = 0; i &lt; filas.length; i++) {
    trabajar(filas[i]);
    if (i % 100 === 0) await yieldAlHilo(); // respira cada 100 filas
  }
}

const yieldAlHilo = () =&gt;
  &#39;scheduler&#39; in window &amp;&amp; &#39;yield&#39; in scheduler
    ? scheduler.yield()
    : new Promise((r) =&gt; setTimeout(r, 0));
</code></pre>
<p>Si el trabajo es de verdad CPU-intensivo y no necesita el DOM, la respuesta correcta no es trocear sino sacarlo a un Web Worker: otro hilo, cero bloqueo del principal. Pero para la mayoría de casos, ceder el hilo cada N iteraciones ya convierte un congelamiento en algo imperceptible.</p>
<h2>Lo que aprendí midiendo, no adivinando</h2>
<p>INP me enseñó a dejar de confundir &quot;rápido de cargar&quot; con &quot;rápido de usar&quot;. Son ejes distintos y el usuario siente sobre todo el segundo, porque pasa el 99% del tiempo interactuando, no cargando. La buena noticia es que casi siempre se arregla sin reescribir nada: encuentras la tarea larga con <code>longtask</code>, decides qué es urgente y qué puede esperar, y usas la herramienta que toca —<code>useDeferredValue</code>, <code>useTransition</code>, ceder el hilo o un worker—. Mi regla es simple: si una interacción bloquea el hilo más de 200ms, no es una micro-optimización opcional, es un bug de percepción de calidad. Y los bugs de percepción son los que hacen que la gente diga &quot;no sé, se siente lento&quot; y no vuelva.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Embeddings más baratos: recortar dimensiones y cuantizar sin perder recall</title>
      <link>https://yohangel.com/blog/embeddings-cuantizacion-dimensiones/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/embeddings-cuantizacion-dimensiones/</guid>
      <description>Cómo decido cuántas dimensiones guardar y en qué precisión, y por qué casi siempre acabo en un esquema de dos fases con vectores pequeños arriba y precisos abajo.</description>
      <pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>IA</category>
      <content:encoded><![CDATA[<p><strong>Un índice vectorial se vuelve caro mucho antes de lo que la gente espera. No por el modelo de embeddings, que ya cuesta céntimos, sino por la memoria: si guardas vectores de 3072 dimensiones en float32, cada uno ocupa 12 KB, y un millón de vectores son 12 GB solo de datos, sin contar el grafo del índice. Lo que aprendí construyendo el matching de JXBS es que ese tamaño es negociable en dos ejes independientes —cuántas dimensiones guardas y con qué precisión guardas cada una— y que recortar en ambos degrada el recall mucho menos de lo que la intuición sugiere, siempre que uses el vector pequeño para buscar y el grande para decidir.</strong></p>
<h2>Los dos ejes: dimensiones y precisión</h2>
<p>Cuando quieres que un embedding ocupe menos, hay exactamente dos palancas. La primera es truncar dimensiones: quedarte con las primeras N componentes del vector en lugar de todas. La segunda es cuantizar: bajar la precisión de cada componente, de <code>float32</code> a <code>int8</code> o incluso a un solo bit.</p>
<p>Estas palancas se multiplican. Un vector de 1024 dimensiones en int8 ocupa 1 KB frente a los 4 KB del mismo vector en float32, y frente a los 12 KB del vector completo de 3072 en float32. Doce veces menos memoria. La pregunta no es si eso ahorra —evidentemente sí—, sino cuánto recall pierdes por el camino.</p>
<h2>Truncar dimensiones no es tan bárbaro como suena</h2>
<p>Truncar un vector arbitrario destruye información de forma impredecible: no hay ninguna garantía de que las primeras componentes sean las importantes. Lo que cambió el panorama es el entrenamiento tipo <em>matryoshka</em>, donde el modelo se entrena explícitamente para que los prefijos del vector sean, por sí solos, representaciones útiles. Las primeras dimensiones cargan la señal gruesa; las últimas afinan matices.</p>
<p>Si tu modelo soporta este esquema —los principales de OpenAI y varios abiertos lo hacen—, truncar es literalmente cortar el array y renormalizar:</p>
<pre><code class="language-typescript">function truncar(vector: number[], dims: number): number[] {
  const corte = vector.slice(0, dims);
  const norma = Math.hypot(...corte);
  return corte.map((v) =&gt; v / norma); // renormalizar es obligatorio
}
</code></pre>
<p>Ese <code>renormalizar</code> no es opcional. Si usas similitud coseno con vectores normalizados y truncas sin volver a normalizar, las magnitudes dejan de ser comparables y las distancias se ensucian. Es el error más común y el más silencioso: no revienta nada, solo empeora los resultados.</p>
<p>Si tu modelo <strong>no</strong> está entrenado con matryoshka, no trunques. Ahí la reducción honesta pasa por PCA o por un proyector aprendido, y eso ya es un componente más que mantener, versionar y reindexar.</p>
<h2>Cuantizar: int8 casi gratis, binario con matices</h2>
<p>Bajar de float32 a int8 conserva un recall sorprendentemente alto —en mis pruebas la pérdida ha sido marginal— a cambio de un cuarto de la memoria. El truco es la calibración: necesitas conocer el rango real de tus componentes para mapearlo al rango de int8, y ese rango se calcula sobre una muestra representativa de tu corpus, no sobre el primer lote que tengas a mano.</p>
<p>La cuantización binaria es otra historia. Reduces cada componente a un bit según su signo, ocupas 32 veces menos y comparas con distancia de Hamming, que es un XOR y un popcount: brutalmente rápido. Pero pierdes recall de forma visible. Nadie serio usa binario como respuesta final; se usa como filtro.</p>
<blockquote>
<p>💡 La pregunta correcta no es &quot;¿qué precisión uso?&quot; sino &quot;¿qué precisión uso <em>en cada fase</em>?&quot;. Buscar y decidir son problemas distintos y merecen representaciones distintas.</p>
</blockquote>
<h2>El patrón que uso: buscar barato, decidir caro</h2>
<p>En lugar de elegir un único formato, guardo dos. Un vector pequeño y cuantizado para recorrer el índice, y el vector completo en float32 —o algo cercano— para reordenar los pocos candidatos que sobreviven.</p>
<p>La primera fase recupera, digamos, los 200 candidatos más cercanos usando el vector barato. Es una fase de <em>recall</em>: solo tiene que garantizar que los buenos estén dentro de esos 200, no que estén bien ordenados. La segunda fase toma esos 200, calcula la similitud con el vector de precisión completa y los ordena de verdad. Esa segunda fase toca 200 vectores, no un millón, así que la memoria del índice no la limita: los vectores completos pueden vivir en disco o en una tabla normal de Postgres.</p>
<pre><code class="language-sql">-- Fase 1: recall barato sobre el vector cuantizado (indexado)
WITH candidatos AS (
  SELECT id
  FROM documentos
  ORDER BY embedding_int8 &lt;=&gt; $1::vector
  LIMIT 200
)
-- Fase 2: reordenar los 200 con el vector de precisión completa
SELECT d.id, d.titulo, d.embedding_full &lt;=&gt; $2::vector AS distancia
FROM documentos d
JOIN candidatos c ON c.id = d.id
ORDER BY distancia
LIMIT 10;
</code></pre>
<p>Esto es exactamente la misma idea que aplicar un reranker encima de la búsqueda semántica, pero un piso más abajo: en vez de un modelo caro reordenando, es un vector caro reordenando. Y se pueden combinar los dos.</p>
<h2>Cómo decido el punto de corte</h2>
<p>No hay número mágico. Lo que hago es medir. Congelo un conjunto de consultas con sus resultados relevantes conocidos, defino como referencia el recall@10 con el vector completo en float32, y voy probando configuraciones: 3072/float32, 1024/float32, 1024/int8, 512/int8, binario+rerank. Cada una da un par (recall, memoria). Con esa tabla delante la decisión deja de ser una discusión de opiniones y pasa a ser una elección de trade-off explícita.</p>
<p>Mi sesgo por defecto, cuando el corpus es grande: dimensiones medias con int8 en el índice, vector completo guardado aparte para la segunda fase. Elegí eso porque el coste de memoria del índice cae casi un orden de magnitud, a cambio de una consulta con dos pasos en vez de uno y de mantener dos columnas sincronizadas cuando reindexas. Ese es el precio real, y hay que decirlo: <strong>cada esquema de compresión es una migración pendiente</strong>. El día que cambies de modelo de embeddings, reindexas ambas columnas.</p>
<p>Y una advertencia final: no optimices esto antes de tener el problema. Con cien mil vectores, float32 completo cabe en memoria sin despeinarse y no vas a notar la diferencia. La compresión es una respuesta a una restricción concreta, no una virtud en sí misma.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Formularios que funcionan sin JavaScript (y mejor con él)</title>
      <link>https://yohangel.com/blog/formularios-progressive-enhancement/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/formularios-progressive-enhancement/</guid>
      <description>El patrón de mejora progresiva que uso en formularios: la plataforma resuelve el caso base, el JavaScript solo añade capas, y ninguna de las dos rutas es de segunda.</description>
      <pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>Frontend</category>
      <content:encoded><![CDATA[<p><strong>Un formulario es la pieza donde más se nota la diferencia entre una web construida sobre la plataforma y una construida contra ella. El navegador ya sabe enviar datos, mostrar errores de validación, gestionar el estado de foco y navegar al resultado. Lleva décadas sabiéndolo. Sin embargo el reflejo por defecto de casi todo el mundo, yo incluido durante años, es interceptar el <code>submit</code>, <code>preventDefault()</code>, montar un estado de React y reimplementar todo eso peor. Este artículo es sobre el patrón contrario: escribir el formulario para que funcione sin una línea de JavaScript, y luego usar JavaScript para hacerlo mejor —no para hacerlo posible.</strong></p>
<h2>El caso base: un <code>&lt;form&gt;</code> que ya funciona</h2>
<p>Un formulario con <code>action</code> y <code>method</code> envía datos al servidor y navega al resultado. Sin JS. Los inputs con <code>required</code>, <code>type=&quot;email&quot;</code>, <code>minlength</code> o <code>pattern</code> validan en el cliente sin una línea de código. El <code>&lt;label&gt;</code> asociado da accesibilidad gratis. El botón de submit muestra un estado de carga nativo del navegador.</p>
<pre><code class="language-html">&lt;form action=&quot;/api/contacto&quot; method=&quot;post&quot;&gt;
  &lt;label for=&quot;email&quot;&gt;Correo&lt;/label&gt;
  &lt;input id=&quot;email&quot; name=&quot;email&quot; type=&quot;email&quot; required /&gt;
  &lt;button type=&quot;submit&quot;&gt;Enviar&lt;/button&gt;
&lt;/form&gt;
</code></pre>
<p>Eso funciona en un móvil con la red a medias, mientras tu bundle todavía se descarga, con JS deshabilitado, o cuando un script de terceros revienta y se lleva por delante la hidratación. En un sitio Astro, donde la mayoría de las islas ni siquiera se hidratan, esto no es una hipótesis: es el comportamiento normal de la página.</p>
<h2>La validación no puede vivir en un solo sitio</h2>
<p>Aquí es donde suele romperse la disciplina. La gente valida en el cliente con una librería, y en el servidor vuelve a validar con otra lógica escrita a mano. Dos fuentes de verdad, que divergen en cuanto alguien cambia una regla.</p>
<p>Lo que hago es tener un único esquema —Zod— y usarlo en los dos lados. En el servidor es la verdad; el cliente es una cortesía para dar feedback rápido. Y crucialmente: si el JavaScript no cargó, el servidor sigue validando y devolviendo una página con los errores. El caso base nunca queda desprotegido.</p>
<pre><code class="language-typescript">// compartido
export const contacto = z.object({
  email: z.string().email(&#39;Correo no válido&#39;),
  mensaje: z.string().min(10, &#39;Cuéntame un poco más&#39;),
});

// servidor: la única verdad
export async function POST({ request }: { request: Request }) {
  const datos = Object.fromEntries(await request.formData());
  const res = contacto.safeParse(datos);
  if (!res.success) {
    // sin JS: renderiza la página de vuelta con los errores
    return renderConErrores(datos, res.error.flatten().fieldErrors);
  }
  await guardar(res.data);
  return new Response(null, { status: 303, headers: { Location: &#39;/gracias&#39; } });
}
</code></pre>
<p>Fíjate en el <code>303</code> con <code>Location</code>. Ese redirect después de un POST exitoso es el patrón POST/Redirect/GET, y evita que refrescar la página reenvíe el formulario. Es de 1998 y sigue siendo correcto.</p>
<blockquote>
<p>💡 Si tu formulario se rompe cuando falla el JavaScript, no tenías un formulario. Tenías un widget que se parecía a uno.</p>
</blockquote>
<h2>Añadir JavaScript encima, no debajo</h2>
<p>Con el caso base sólido, el JS entra a mejorar. Y lo hace escuchando el <code>submit</code> del formulario que <em>ya existe</em>, no construyendo uno nuevo.</p>
<pre><code class="language-typescript">form.addEventListener(&#39;submit&#39;, async (e) =&gt; {
  e.preventDefault(); // solo se ejecuta si este script cargó
  const datos = new FormData(form);
  const res = contacto.safeParse(Object.fromEntries(datos));
  if (!res.success) return pintarErrores(res.error.flatten().fieldErrors);

  boton.disabled = true;
  const r = await fetch(form.action, { method: &#39;POST&#39;, body: datos });
  boton.disabled = false;
  if (r.redirected) location.assign(r.url);
});
</code></pre>
<p>La clave está en el orden mental: el <code>preventDefault()</code> es una <em>optimización opcional</em> que solo ocurre si el script se ejecutó. Si no se ejecuta, el navegador hace su trabajo de siempre. No hay ruta rota, hay una ruta menos pulida.</p>
<p>Y fíjate en algo más: uso <code>new FormData(form)</code> en vez de leer un estado de React. El DOM ya es el estado del formulario. Mantener una copia en <code>useState</code> sincronizada con cada <code>onChange</code> es trabajo que la plataforma hace gratis, y es la causa de la mitad de los bugs de formularios que he depurado: inputs controlados que pierden el cursor, autocompletado del navegador que no dispara el evento, gestores de contraseñas que rellenan campos y el estado de React no se entera.</p>
<h2>Lo que gano y lo que pago</h2>
<p>Gano robustez real: el formulario funciona antes de la hidratación, con red mala, con JS caído. Gano accesibilidad casi gratis, porque los elementos nativos ya traen su semántica. Gano menos código: no hay reducer de estado de formulario, no hay librería de formularios, no hay validación duplicada.</p>
<p>Lo que pago es que ciertas interacciones muy ricas cuestan más. Un campo con autocompletado remoto, arrastrar y soltar archivos, o un asistente de varios pasos con estado complejo no salen de un <code>&lt;form&gt;</code> pelado. Ahí sí monto un componente controlado, pero de forma localizada: la isla compleja es controlada, y el resto del formulario sigue siendo la plataforma. Elegí esto porque la complejidad se queda contenida donde la necesito, a cambio de tener dos estilos de manejo de campos conviviendo en el mismo archivo, lo cual es feo si no lo documentas.</p>
<p>También pago que el caso &quot;sin JS&quot; hay que <em>probarlo</em>. Si no lo pruebas, se pudre. En los proyectos donde esto me importa, tengo un test que envía el formulario con una petición HTTP plana, sin navegador, y comprueba que la respuesta trae los errores renderizados. Es un test barato y es el único que garantiza que el caso base sigue vivo.</p>
<p>La conclusión no es &quot;no uses JavaScript&quot;. Es que el JavaScript debería ser la segunda capa, no los cimientos. Un formulario que solo existe cuando el bundle carga es un formulario con una dependencia que nunca acordaste asumir.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Qué va a pasar con el desarrollo de software: predicciones con fechas (julio 2026)</title>
      <link>https://yohangel.com/blog/futuro-desarrollo-predicciones-2026/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/futuro-desarrollo-predicciones-2026/</guid>
      <description>Qué esperar en los próximos meses, en 2027 y hacia 2030: agentes, el futuro de las webs y las apps, y qué pasará con los programadores. Con datos, no vibes.</description>
      <pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>IA</category>
      <content:encoded><![CDATA[<p><strong>Predecir el futuro del software se ha vuelto un deporte de riesgo: los mejores del mundo llevan tres años fallando en público. Así que antes de hacer mis apuestas, empecemos por auditar las suyas — porque en los errores de los que más saben está la mejor pista de lo que viene.</strong></p>
<h2>Primero: el historial de los profetas</h2>
<p>Repasemos predicciones famosas y su estado real a julio de 2026:</p>
<table>
<thead>
<tr>
<th>Quién y cuándo</th>
<th>Predicción</th>
<th>Estado hoy</th>
</tr>
</thead>
<tbody><tr>
<td>Dario Amodei, mar 2025</td>
<td>«IA escribirá el 90% del código en 3–6 meses»</td>
<td>Cierto dentro de Anthropic (~90–100%); falso en la industria en ese plazo</td>
</tr>
<tr>
<td>Zuckerberg, ene 2025</td>
<td>«En 2025 tendremos IA como ingeniero mid-level»</td>
<td>No como empleado autónomo; Meta recortó 8.000 puestos y movió 7.000 a IA</td>
</tr>
<tr>
<td>Jensen Huang, feb 2024</td>
<td>«No aprendas a programar»</td>
<td>La demanda de devs con skills de IA no ha parado de crecer</td>
</tr>
<tr>
<td>Sam Altman, ene 2025</td>
<td>«Los agentes se unirán a la fuerza laboral en 2025»</td>
<td>No como &quot;empleados&quot;; sí como herramientas omnipresentes</td>
</tr>
<tr>
<td>Sundar Pichai, 2024–26</td>
<td>25% → 50% → 75% de código nuevo por IA en Google</td>
<td>Se cumplió, con fechas</td>
</tr>
</tbody></table>
<p>El patrón es clarísimo y es la clave de todo este post: <strong>los optimistas aciertan la dirección y fallan el plazo, sistemáticamente por exceso en lo social y por defecto en lo técnico.</strong> Nadie predijo que los agentes escribirían código tan bien tan pronto; todos sobreestimaron la velocidad a la que las organizaciones lo absorberían.</p>
<h2>El dato que ancla cualquier predicción seria</h2>
<p>Si solo puedes mirar una métrica, mira la de METR: la duración de las tareas que un agente completa de forma autónoma (con 50% de éxito) se duplica cada pocos meses — 196 días de media histórica, pero acelerando hacia ~3 meses en los datos desde 2024. En enero de 2026, los mejores modelos completaban tareas de ~5 horas de trabajo humano; trackers no oficiales ya sitúan a los modelos actuales por encima de las 14 horas.</p>
<p>Si la tendencia aguanta — y lleva seis años aguantando — a mediados de 2027 hablamos de agentes completando tareas de una semana de trabajo humano. Esa única curva explica casi todo lo que sigue.</p>
<h2>Próximos meses (resto de 2026): consolidación agéntica</h2>
<p>Lo que veo prácticamente seguro de aquí a diciembre, porque ya está en marcha:</p>
<ul>
<li><strong>La orquestación se vuelve el trabajo.</strong> Karpathy lo bautizó «agentic engineering» y acertó: la conversación ya no es «qué editor usas» sino «cuántos agentes diriges en paralelo». Las herramientas de flotas (workflows dinámicos, tareas remotas, agentes en la nube) se convierten en estándar.</li>
<li><strong>El shock de las facturas.</strong> Los cambios de billing de este año (créditos de Copilot, límites por uso en todos lados) son el síntoma: el coste del compute agéntico se vuelve una línea seria del presupuesto de cualquier equipo. Aparecerá tooling de FinOps para agentes.</li>
<li><strong>MCP se cierra como estándar universal.</strong> La spec de julio (apps embebidas, tareas de larga duración, OAuth serio) más la gobernanza de la Linux Foundation lo convierten en el TCP/IP de los agentes. Si tu producto no expone MCP en 2027, no existe para los agentes.</li>
<li><strong>La verificación se convierte en el cuello de botella oficial.</strong> Con benchmarks como SWE-bench saturados (95% el mejor modelo), el problema ya no es generar código: es revisarlo. El 66% de los devs dice que pierde más tiempo arreglando código de IA «casi correcto» que escribiendo. Ahí está la próxima ola de herramientas.</li>
</ul>
<h2>2027: el año de la interfaz</h2>
<p>Mi apuesta central para 2027 no va de código, va de interfaces. Las piezas ya están sobre la mesa: navegadores agénticos (Atlas, Comet, Dia) con decenas de millones de usuarios, ChatGPT como plataforma de apps con checkout integrado, y WebMCP para que los sitios expongan acciones directamente a los agentes.</p>
<p>Lo que eso significa en la práctica:</p>
<ul>
<li><strong>Tu web tendrá dos públicos: humanos y agentes.</strong> Igual que hubo mobile-first, viene el agent-first: APIs limpias, semántica explícita, acciones expuestas por MCP/WebMCP. Una parte creciente de tus «visitas» no verá tu CSS jamás.</li>
<li><strong>La UI generativa encontrará su sitio — que no es todas partes.</strong> El sueño de «la app se genera al vuelo y se borra» chocará con lo que Nielsen lleva años avisando: una interfaz que cambia cada vez es una interfaz que no se puede aprender. El punto de equilibrio: tu UI como implementación de referencia, con los datos y acciones abiertos para que el agente del usuario componga la suya.</li>
<li><strong>El e-commerce agéntico se normaliza.</strong> Con cientos de millones de usuarios semanales en asistentes y protocolos de compra estandarizados, «se lo pedí a mi agente» deja de sonar raro. El SEO muta: de posicionar páginas a posicionar acciones y datos.</li>
</ul>
<blockquote>
<p>💡 Si mantienes un producto web, la pregunta para 2027 no es «¿tengo app móvil?» sino «¿puede un agente usar mi producto sin pantalla?». El que tenga buena API y buena semántica gana el canal nuevo gratis.</p>
</blockquote>
<h2>2027–2028: qué pasará con los programadores</h2>
<p>Aquí es donde más ruido hay y donde los datos dicen algo más matizado que los titulares:</p>
<ul>
<li><strong>La pirámide se reestructura, no desaparece.</strong> Los ingenieros de software son hoy el 55% de las contrataciones de Big Tech — más que en 2019 (46%). Pero los recién graduados son solo el 7% de esas contrataciones, la mitad que antes de la pandemia. Se contrata más senior y menos junior: la pirámide se está invirtiendo.</li>
<li><strong>El empleo se movió, no se esfumó.</strong> Las vacantes de dev en EE.UU. suben 14% interanual, pero el 71% de esa subida es de roles senior y el 37% lleva «IA» en el título. La compensación de un AI engineer ronda los $242K de media y los perfiles de agentes crecen +136% interanual.</li>
<li><strong>La escasez de 2029 se está fabricando hoy.</strong> Matrícula de informática cayendo 8% anual, bootcamps cerrando en cadena, empresas sin contratar juniors. Nadie está formando a los seniors de dentro de cinco años. Predicción concreta: hacia 2028–2029, pánico por escasez de talento senior y programas de «AI apprenticeship» por todas partes, contratando juniors otra vez — con otro perfil: diseño de sistemas y verificación, no sintaxis.</li>
<li><strong>Seguirán los recortes «por IA» que no son solo por IA.</strong> 2026 lleva ~120.000 despidos tech con la IA como excusa parcial — mezcla de automatización real, sobrecontratación pasada y presión de márgenes. Distinguir cuánto es cada cosa será imposible, y el relato «la IA me quitó el trabajo» convivirá con récords de demanda de ingenieros que sepan dirigirla.</li>
</ul>
<h2>2028–2030: los tres escenarios</h2>
<p>A más de dos años vista, lo honesto es hablar de escenarios con probabilidades, no de certezas. Las mías:</p>
<ul>
<li><strong>Aceleración continua (~35%).</strong> La curva de METR aguanta sin techo: agentes con semanas de autonomía en 2027, «empleado digital» funcional hacia 2028–29. Es el escenario de los laboratorios (aunque hasta Amodei y Altman han suavizado el discurso apocalíptico este año — curiosamente, camino de sus salidas a bolsa).</li>
<li><strong>Meseta útil (~50%).</strong> El que veo más probable. La generación de código sigue mejorando pero los problemas duros — memoria persistente, aprendizaje continuo, coherencia en horizontes largos — resultan resistentes, como avisan Marcus y LeCun (que se fue de Meta a fundar una startup de world models precisamente por esto). Los agentes se vuelven infraestructura como lo fue la nube: transformadora, no apocalíptica. Los pronósticos tipo «AI 2027» ya se han deslizado a «principios de los 2030», y Metaculus da 50% a AGI en 2033.</li>
<li><strong>Invierno parcial (~15%).</strong> El gasto en compute no encuentra retorno al ritmo prometido, corrección fuerte de valoraciones, consolidación. Ojo: incluso aquí, las capacidades ya desplegadas no se van — nadie va a volver a escribir CRUD a mano.</li>
</ul>
<h2>Mis predicciones concretas, para poder fallar en público</h2>
<p>Como me he pasado el post auditando las predicciones de otros, lo justo es dejar las mías por escrito, con fecha, para que me las auditen:</p>
<pre><code class="language-text">// Predicciones — julio 2026 (revisar en julio 2027)
// 1. Para dic 2026: los agentes completan tareas de ~2 días
//    de trabajo humano de forma fiable.            [80%]
// 2. Para jun 2027: &gt;50% del código nuevo en empresas tech
//    es generado por IA (media industria, no solo labs). [70%]
// 3. Para 2027: al menos una app del top-100 elimina su UI
//    tradicional en favor de interfaz agéntica/conversacional. [60%]
// 4. Para 2028: la contratación de juniors se recupera con
//    formato aprendiz-de-IA tras el pánico de escasez.  [65%]
// 5. Para 2030: sigue habiendo MÁS gente trabajando en
//    software que en 2025 (contando nuevos roles).      [75%]
// 6. AGI/trabajador digital drop-in antes de 2030.      [25%]
</code></pre>
<h2>El cierre honesto</h2>
<p>La predicción número 5 es la que de verdad importa y la que más convencido estoy de que se cumple. Cada vez que el coste de crear software ha caído — compiladores, open source, la nube — el mundo no ha querido menos software: ha querido órdenes de magnitud más. La demanda de software siempre ha estado limitada por la oferta, y acabamos de hacer la oferta casi infinita.</p>
<p>Lo que desaparece no es el programador; es el programador definido como «persona que traduce especificaciones a sintaxis». Lo que nace es algo más parecido a un director de sistemas que decide, verifica y responde. Los próximos tres años van a ser incómodos, desiguales y llenos de titulares exagerados en ambas direcciones. Pero si tuviera que elegir un momento de la historia para saber construir software, elegiría exactamente este.</p>
]]></content:encoded>
    </item>
    <item>
      <title>El NAT Gateway que nadie pidió: la factura silenciosa de tu VPC</title>
      <link>https://yohangel.com/blog/nat-gateway-vpc-endpoints-costes/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/nat-gateway-vpc-endpoints-costes/</guid>
      <description>Por qué el componente más caro de muchas cuentas de AWS es una pieza de red que nadie recuerda haber diseñado, y cómo los VPC endpoints cambian esa ecuación.</description>
      <pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>AWS</category>
      <content:encoded><![CDATA[<p><strong>En casi todas las cuentas de AWS que he revisado hay una línea en la factura que sorprende a todo el mundo: el NAT Gateway. No es un servicio que alguien eligiera conscientemente. Aparece porque el asistente de creación de VPC lo pone por defecto, porque tus tareas de Fargate viven en subredes privadas y necesitan salir a internet, y porque nadie miró después. Cobra por hora <em>y</em> por gigabyte procesado, en ambas direcciones. Y lo peor: buena parte de ese tráfico ni siquiera va a internet —va a S3, a DynamoDB, a Secrets Manager— y podrías haberlo sacado de ahí con una pieza que cuesta cero.</strong></p>
<h2>Por qué existe el NAT Gateway</h2>
<p>Una subred privada, por definición, no tiene ruta a un Internet Gateway. Eso es lo que la hace privada: nada de fuera puede iniciar una conexión hacia dentro. Pero tus contenedores sí necesitan salir: a descargar una imagen de ECR, a leer un secreto, a llamar a una API de terceros.</p>
<p>El NAT Gateway resuelve eso: vive en una subred pública, las privadas enrutan su tráfico saliente hacia él, y él traduce las direcciones para que las respuestas vuelvan. Funciona, es gestionado, es altamente disponible dentro de su AZ. Y por eso todo el mundo lo pone y se olvida.</p>
<p>El problema es su modelo de precios. Pagas una tarifa por hora por cada NAT Gateway —y como cada uno vive en una AZ, la práctica recomendada de alta disponibilidad te pide uno por AZ, así que multiplica— más una tarifa por cada gigabyte que lo atraviesa. Ese cargo por gigabyte se aplica al tráfico <em>procesado</em>, y se acumula sin que nadie lo esté mirando.</p>
<h2>El descubrimiento incómodo: buena parte de ese tráfico es interno</h2>
<p>Aquí está el detalle que cambia el análisis. Cuando tu tarea de Fargate en una subred privada escribe un objeto en S3, ese tráfico sale por el NAT Gateway, va al endpoint público de S3 y vuelve. Estás pagando transferencia por hablar con un servicio de AWS que está en la misma región que tú.</p>
<p>Lo mismo con DynamoDB, con SQS, con Secrets Manager, con CloudWatch Logs —y CloudWatch Logs es el gran sospechoso silencioso, porque si tienes logs verbosos, cada línea es tráfico facturado— y con ECR cada vez que arranca una tarea y baja capas de imagen.</p>
<p>En arquitecturas serverless o de contenedores bien pobladas, la mayoría del tráfico saliente no va a internet de verdad. Va a AWS.</p>
<h2>VPC endpoints: dos sabores, precios muy distintos</h2>
<p>Un VPC endpoint permite que tu subred privada hable con un servicio de AWS sin pasar por internet ni por el NAT. Hay dos tipos, y confundirlos es caro.</p>
<p>Los <strong>gateway endpoints</strong> existen solo para S3 y DynamoDB. Son una entrada en la tabla de rutas. <strong>No cuestan nada</strong>: ni por hora, ni por gigabyte. No hay ninguna razón defendible para no tenerlos si usas S3 o DynamoDB desde subredes privadas. Es dinero tirado.</p>
<p>Los <strong>interface endpoints</strong> (PrivateLink) son para todo lo demás: SQS, Secrets Manager, ECR, CloudWatch, KMS. Crean una interfaz de red en tu subred con una IP privada. Estos sí cuestan: una tarifa por hora por endpoint y por AZ, más un cargo por gigabyte —bastante más barato que el del NAT, pero no cero.</p>
<blockquote>
<p>💡 Los gateway endpoints de S3 y DynamoDB son gratis. Si tus subredes privadas no los tienen, estás pagando NAT Gateway por hablar con S3. Ese es el primer sitio donde mirar, y suele ser el más rentable.</p>
</blockquote>
<h2>Cómo decido qué endpoints crear</h2>
<p>Con interface endpoints la cuenta no es automática, porque tienen coste fijo por hora y por AZ. Un endpoint vale la pena cuando el ahorro en tráfico NAT supera esa tarifa fija. La forma de saberlo es mirar los datos, no adivinar: activo Flow Logs en la interfaz elástica del NAT Gateway y agrupo por IP destino, resolviendo esas IPs contra los rangos publicados de AWS para saber qué servicio es cuál.</p>
<p>Con esa tabla delante la decisión es aritmética. Si el 60% de tu tráfico NAT es a S3, un gateway endpoint gratis se lo lleva entero. Si otro 20% es CloudWatch Logs, el interface endpoint se paga solo. Si hay un 5% a KMS, probablemente no compense el coste fijo.</p>
<p>En OpenTofu es tan poco código que casi da vergüenza no tenerlo:</p>
<pre><code class="language-hcl"># Gratis. Sin excusas.
resource &quot;aws_vpc_endpoint&quot; &quot;s3&quot; {
  vpc_id            = aws_vpc.main.id
  service_name      = &quot;com.amazonaws.${var.region}.s3&quot;
  vpc_endpoint_type = &quot;Gateway&quot;
  route_table_ids   = aws_route_table.private[*].id
}

# De pago: crea una ENI por subred. Justifícalo con datos.
resource &quot;aws_vpc_endpoint&quot; &quot;logs&quot; {
  vpc_id              = aws_vpc.main.id
  service_name        = &quot;com.amazonaws.${var.region}.logs&quot;
  vpc_endpoint_type   = &quot;Interface&quot;
  subnet_ids          = aws_subnet.private[*].id
  security_group_ids  = [aws_security_group.endpoints.id]
  private_dns_enabled = true # sin esto, tu SDK sigue yendo al endpoint público
}
</code></pre>
<p>Ese <code>private_dns_enabled</code> es el que hace que funcione sin tocar el código: resuelve el nombre público del servicio a la IP privada del endpoint. Si lo dejas en <code>false</code>, creas el endpoint, pagas por él, y tu aplicación sigue saliendo por el NAT tan tranquila. Es un error que solo se ve en la factura.</p>
<h2>La pregunta más incómoda: ¿necesitas el NAT?</h2>
<p>Antes de optimizar el NAT Gateway conviene preguntarse si hace falta. Si tus contenedores solo hablan con servicios de AWS y nunca llaman a una API de terceros, puedes cubrir todo con endpoints y quitarlo. Es la única optimización que lleva ese coste a cero.</p>
<p>En la práctica casi siempre hay <em>algo</em> que sale a internet: un webhook, una pasarela de pagos, un proveedor de LLM. Entonces el NAT se queda, pero con la mayoría del tráfico desviado, y su factura pasa de ser un misterio a ser una línea pequeña y explicable.</p>
<p>El trade-off honesto: añades componentes de red que hay que mantener, versionar en tu IaC y depurar cuando un security group mal configurado hace que un endpoint tire timeouts en vez de errores claros. A cambio, el tráfico interno deja de tocar internet —lo que además es mejor postura de seguridad— y la factura deja de crecer con tu volumen de logs. Para mí ha compensado siempre. Pero decídelo con Flow Logs, no con este artículo.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Cómo sobrevivir como programador en la era de la IA (sin volverse cínico ni ingenuo)</title>
      <link>https://yohangel.com/blog/sobrevivir-programador-era-ia/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/sobrevivir-programador-era-ia/</guid>
      <description>La IA no viene a por tu trabajo: viene a por tus tareas. Qué cambia de verdad, qué habilidades suben de precio y cuáles se están devaluando.</description>
      <pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>IA</category>
      <content:encoded><![CDATA[<p><strong>Llevo años escribiendo software y nunca había visto al gremio tan dividido: la mitad jura que en dos años no quedará ni un programador, la otra mitad jura que todo esto es humo. Los dos bandos se equivocan, y mientras discuten, el suelo se está moviendo debajo de todos.</strong></p>
<p>Esto no es un post motivacional ni una lista de «5 cursos para no quedarte atrás». Es lo que veo desde dentro — trabajando cada día con agentes que escriben la mayor parte de mi código — sobre qué está cambiando de verdad, qué se devalúa, qué sube de precio y qué haría yo hoy según el punto de la carrera en el que estés.</p>
<h2>Lo primero: separar el ruido de la señal</h2>
<p>La confusión viene de mezclar dos preguntas distintas. «¿Puede la IA escribir código?» — sí, desde hace tiempo, y mejor que la mayoría de nosotros en tareas acotadas. «¿Puede la IA hacer el trabajo de un ingeniero?» — no, porque el trabajo nunca fue escribir código.</p>
<p>El trabajo siempre fue otro: entender un problema ambiguo, decidir qué construir (y qué no), negociar restricciones, detectar que el ticket pide una cosa pero el negocio necesita otra, y responder por lo que se despliega. Nada de eso ha sido automatizado. Lo que se automatizó es la parte que estaba en medio: traducir decisiones a sintaxis.</p>
<blockquote>
<p>💡 La frase que me ordena la cabeza: la IA no viene a por tu trabajo, viene a por tus tareas. Si tu trabajo era la suma de esas tareas, sí tienes un problema. Si tu trabajo era el criterio que las ordenaba, acabas de multiplicarte.</p>
</blockquote>
<h2>Qué se devalúa (y duele decirlo)</h2>
<p>Seamos honestos con lo que ya vale menos en el mercado:</p>
<ul>
<li><strong>Escribir código correcto y limpio a mano.</strong> Era nuestra identidad. Hoy es la línea base que produce cualquier agente bien dirigido.</li>
<li><strong>Saberse un framework de memoria.</strong> El conocimiento enciclopédico de una API era una ventaja competitiva; ahora está a un prompt de distancia de cualquiera.</li>
<li><strong>El junior que solo ejecuta tickets.</strong> Este es el golpe más duro y el más real: el trabajo de «toma esta tarea bien definida y tráemela hecha» es exactamente lo que mejor hace un agente.</li>
<li><strong>La velocidad de tecleo como métrica de productividad.</strong> Producir más líneas ya no distingue a nadie. Distingue producir menos líneas que sobrevivan más tiempo.</li>
</ul>
<h2>Qué sube de precio</h2>
<p>La lista contraria es más interesante, porque es donde hay que invertir:</p>
<ul>
<li><strong>Criterio de revisión.</strong> El código generado por IA se ve impecable — está formateado, nombrado con sentido, comentado. El peligro vive en lo que <em>parece</em> correcto. Saber leer código con sospecha vale hoy más que saber escribirlo.</li>
<li><strong>Arquitectura y descomposición.</strong> Un agente rinde en proporción directa a lo bien acotado que esté el problema. Trocear un sistema en piezas que una IA pueda ejecutar sin romper nada es la nueva habilidad senior.</li>
<li><strong>Contexto de negocio.</strong> Entender por qué se construye algo es lo único que la IA no puede inferir de tu repo. El ingeniero que habla con producto y con clientes se vuelve imposible de reemplazar.</li>
<li><strong>Verificación.</strong> Tests, evals, observabilidad, entornos de staging que se parecen a producción. Cuando el volumen de código se multiplica, el cuello de botella se muda a la confianza: ¿cómo sé que esto funciona?</li>
<li><strong>Responsabilidad.</strong> Alguien tiene que firmar el despliegue. La IA no va a la reunión de postmortem. Ese «alguien» cotiza al alza.</li>
</ul>
<h2>El nuevo flujo de trabajo (el mío, al menos)</h2>
<p>Mi día a día ya no se parece al de hace tres años. El patrón que me funciona tiene tres fases, y ninguna es «escribir código»:</p>
<pre><code class="language-text">// Mi división del trabajo con agentes
// 1. ANTES  — invertir en contexto: specs claras, convenciones
//             documentadas, criterios de aceptación explícitos.
// 2. DURANTE — dirigir, no dictar: el agente propone, yo corto
//              alcance, corrijo rumbo, pido alternativas.
// 3. DESPUÉS — verificar con hostilidad: leer el diff como si
//              lo hubiera escrito alguien que quiere engañarme,
//              correr los tests, probar los bordes.
</code></pre>
<p>La proporción sorprende a quien no lo ha vivido: paso más o menos un 40% del tiempo en la fase 1. La calidad de lo que sale de un agente es una función casi lineal de la calidad del contexto que entra. Los equipos que adoptan IA y no ven mejora casi siempre fallan ahí: delegan la escritura pero no invierten en la especificación.</p>
<h2>Si estás empezando: el consejo incómodo</h2>
<p>El escalón de entrada se rompió, y no ayuda a nadie negarlo. Pero fíjate en el matiz: se rompió el escalón, no la escalera. Las empresas siguen necesitando seniors, y los seniors no nacen — se hacen. Tarde o temprano el mercado tendrá que reconstruir la cantera, y los juniors que sobrevivan a esta transición serán los que llegaron distinto:</p>
<ul>
<li><strong>Usa la IA para aprender, no para evitar aprender.</strong> Pídele que te explique cada línea que genera. La diferencia entre «funciona y no sé por qué» y «funciona y sé por qué» es tu carrera entera.</li>
<li><strong>Construye cosas completas.</strong> Un side project desplegado, con usuarios reales y bugs reales, enseña lo que ningún tutorial: la parte del oficio que la IA no cubre.</li>
<li><strong>Aprende a depurar de verdad.</strong> Cuando el agente se atasca — y se atasca — la persona que sabe bajar al log, al breakpoint y al protocolo es la que desbloquea al equipo.</li>
<li><strong>Los fundamentos no caducan.</strong> Redes, sistemas operativos, bases de datos, complejidad. Los frameworks que la IA domina cambian cada año; aquello sobre lo que se apoyan, no.</li>
</ul>
<h2>Si llevas años en esto: tu riesgo es otro</h2>
<p>El senior no compite contra la IA; compite contra el senior que la usa bien. Y ahí veo dos errores simétricos. El primero es el rechazo: «yo reviso mejor que cualquier modelo» — cierto hoy, irrelevante en dos ciclos de mejora. El segundo es la rendición: aceptar todo lo que genera el agente sin leerlo, hasta que un incidente en producción te recuerda quién firmaba.</p>
<p>El punto medio tiene nombre aburrido: gestión. Diriges una pequeña flota de agentes igual que antes coordinabas personas — con specs, con revisión, con estándares. Si alguna vez pensaste en pasar a management pero no querías dejar de tocar código, la buena noticia es que ese puesto híbrido acaba de inventarse y nadie tiene diez años de experiencia en él.</p>
<h2>Lo que no cambia</h2>
<p>Después de todo el vértigo, hay algo que me tranquiliza: el software sigue siendo la disciplina de decidir qué debe pasar y asegurarse de que pasa. Las herramientas para lograrlo han cambiado más en tres años que en los veinte anteriores, pero la naturaleza del oficio — criterio, responsabilidad, traducción entre lo que la gente necesita y lo que la máquina hace — sigue intacta.</p>
<p>Sobrevivir a esta era no va de correr más que la IA. Va de moverse un nivel por encima de ella, que es exactamente el mismo movimiento que hizo quien pasó del ensamblador al compilador, y del servidor físico a la nube. Los que se quedaron abrazados a la capa que se automatizaba lo pasaron mal. Los que subieron de capa, no dieron abasto con tanto trabajo.</p>
]]></content:encoded>
    </item>
    <item>
      <title>El stack para startups en julio de 2026: qué usaría hoy para construir un SaaS</title>
      <link>https://yohangel.com/blog/stack-startups-julio-2026/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/stack-startups-julio-2026/</guid>
      <description>Mapa honesto del ecosistema: agentes de código, modelos, frameworks, infra y la capa de IA. Qué elegiría hoy y por qué, con precios reales.</description>
      <pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>IA</category>
      <content:encoded><![CDATA[<p><strong>Si hoy tuviera que montar un SaaS desde cero, la parte difícil no sería construirlo — sería elegir con qué. El ecosistema se mueve tan rápido que cualquier comparativa de hace seis meses ya está mal. Este es mi mapa a julio de 2026: qué usaría, qué evitaría y dónde están las trampas de precios.</strong></p>
<p>Aviso previo: no hay stack perfecto, hay stacks adecuados a tu equipo y a tu etapa. Lo que sigue está sesgado hacia lo que optimiza una startup pequeña: velocidad de iteración, poco mantenimiento y facturas predecibles.</p>
<h2>Agentes de código: la decisión más importante</h2>
<p>La herramienta con la que escribes código define tu velocidad más que cualquier framework. El panorama actual:</p>
<table>
<thead>
<tr>
<th>Herramienta</th>
<th>Precio base</th>
<th>Lo distintivo en 2026</th>
</tr>
</thead>
<tbody><tr>
<td>Claude Code</td>
<td>$20–200/mes</td>
<td>Dynamic Workflows: orquesta decenas de subagentes en paralelo desde una sesión</td>
</tr>
<tr>
<td>Cursor</td>
<td>$20–200/mes</td>
<td>Composer 2.5 (modelo propio) incluido en Pro; ~$4B de ARR en junio</td>
</tr>
<tr>
<td>GitHub Copilot</td>
<td>$10–100/mes</td>
<td>Modo agente GA en VS Code y JetBrains; ojo al nuevo billing por créditos</td>
</tr>
<tr>
<td>Devin Desktop</td>
<td>$20/mes + uso</td>
<td>El antiguo Windsurf, ahora hub de gestión de agentes de Cognition</td>
</tr>
<tr>
<td>Codex (OpenAI)</td>
<td>Incluido en ChatGPT</td>
<td>CLI open source + tareas en la nube; más de 5M de usuarios semanales</td>
</tr>
<tr>
<td>Antigravity (Google)</td>
<td>Incluido en planes Gemini</td>
<td>Sustituyó a Gemini CLI en junio; workflows asíncronos en background</td>
</tr>
</tbody></table>
<p>Mi lectura: la categoría ya no compite en «autocompletar mejor», compite en orquestación — cuántos agentes puedes dirigir a la vez y con qué confianza. Claude Code es mi driver diario por eso mismo; Cursor sigue siendo el mejor editor si quieres IDE tradicional con agente dentro.</p>
<blockquote>
<p>💡 Trampa del mes: GitHub cambió en junio los Premium Requests por «AI Credits» y hay equipos reportando facturas 10 veces mayores en flujos agénticos. Sea cual sea tu herramienta, pon alertas de gasto antes de soltar agentes en paralelo.</p>
</blockquote>
<h2>Modelos: precios de julio de 2026</h2>
<p>Si construyes producto sobre LLMs, esto es lo que cuesta hoy el millón de tokens (entrada/salida):</p>
<table>
<thead>
<tr>
<th>Modelo</th>
<th>Entrada</th>
<th>Salida</th>
<th>Nota</th>
</tr>
</thead>
<tbody><tr>
<td>Claude Fable 5</td>
<td>$10</td>
<td>$50</td>
<td>El tope de gama de Anthropic; contexto 1M</td>
</tr>
<tr>
<td>Claude Opus 4.8</td>
<td>$5</td>
<td>$25</td>
<td>El caballo de batalla para código</td>
</tr>
<tr>
<td>Claude Sonnet 5</td>
<td>$2</td>
<td>$10</td>
<td>Precio intro hasta agosto; luego $3/$15</td>
</tr>
<tr>
<td>GPT-5.5</td>
<td>$5</td>
<td>$30</td>
<td>Contexto 1M</td>
</tr>
<tr>
<td>GPT-5.6 (Sol/Terra/Luna)</td>
<td>$1–5</td>
<td>$6–30</td>
<td>Lanzado literalmente hoy</td>
</tr>
<tr>
<td>Gemini 3.1 Pro</td>
<td>$2</td>
<td>$12</td>
<td>Hasta 200K de contexto; sube después</td>
</tr>
<tr>
<td>DeepSeek V4 / Qwen 3.5</td>
<td>~open source</td>
<td>—</td>
<td>Si puedes self-hostear, el coste cambia de liga</td>
</tr>
</tbody></table>
<p>La estrategia sensata para un SaaS: modelo barato (Sonnet 5, Terra, Gemini) para el 90% de las llamadas, y escalar al tope de gama solo cuando el caso lo justifique. Un router de modelos de 30 líneas te ahorra miles de dólares al mes.</p>
<h2>Frameworks web: menos drama del que parece</h2>
<ul>
<li><strong>Next.js 16.2</strong> sigue siendo el default racional para SaaS: Turbopack ya es estable y por defecto, React Compiler integrado, y el ecosistema de ejemplos es imbatible. Aburrido y correcto.</li>
<li><strong>Astro</strong> — recién adquirido por Cloudflare en enero — sigue siendo mi elección para todo lo que sea contenido (este blog corre en Astro). Sigue MIT y con gobernanza abierta.</li>
<li><strong>TanStack Start</strong> llegó a v1.0 estable en marzo y es la alternativa seria si quieres type-safety extremo sin la magia de Next.</li>
<li><strong>SvelteKit y React Router 7</strong> están maduros y son excelentes; elegirlos es más una cuestión de gusto de equipo que de capacidades.</li>
</ul>
<p>La verdad incómoda: con agentes escribiendo la mayoría del código, la elección de framework importa menos que hace tres años. Los agentes rinden mejor en frameworks con más corpus público — otro punto para Next y Astro.</p>
<h2>Backend e infra: el dato del año</h2>
<p>El dato que mejor resume 2026: Supabase reportó que la mayoría de sus bases de datos nuevas ya las despliegan agentes de IA, no humanos — creación de bases de datos creciendo 600% interanual. Tu infraestructura ya no la elige solo tu equipo; la «eligen» también los agentes, y van a lo que saben usar.</p>
<ul>
<li><strong>Postgres gana por goleada</strong>: Supabase (acaba de levantar $500M a valoración de $10.5B) como backend completo, o Neon (ya dentro de Databricks) si solo quieres la base de datos.</li>
<li><strong>Vercel</strong> para el despliegue si vas con Next: Fluid Compute eliminó los cold starts de verdad.</li>
<li><strong>Cloudflare Workers + D1 + R2</strong> es la alternativa cost-effective, y con Astro en casa, cada vez más pulida.</li>
<li><strong>Runtimes</strong>: Node 24 sigue siendo el default empresarial. Bun — adquirido por Anthropic en diciembre — completó su reescritura en Rust en mayo y es mi elección para proyectos nuevos: velocidad y tooling integrado.</li>
</ul>
<h2>La capa de IA de tu producto</h2>
<p>Aquí es donde veo más errores de sobre-ingeniería. Lo que de verdad necesita una startup:</p>
<pre><code class="language-text">// La capa de IA mínima viable en 2026
// 1. SDK: Vercel AI SDK 6 (TypeScript) o Claude Agent SDK
//    si el producto ES un agente. LangGraph 1.0 si necesitas
//    grafos de estado complejos (Uber, Klarna lo usan así).
// 2. Contexto: MCP. Ya es estándar de la Linux Foundation,
//    97M de descargas mensuales. No inventes tu protocolo.
// 3. Vectores: pgvector hasta ~10M de vectores (~$30/mes).
//    No pagues un vector DB dedicado antes de tener el problema.
// 4. Evals desde el día uno: si no mides la calidad del LLM,
//    cada deploy es una apuesta.
</code></pre>
<p>El punto 3 merece énfasis: pgvector en tu Postgres de siempre cubre a casi cualquier startup con latencias de 8–25ms. Notion recortó ~60% su coste de búsqueda migrando de vector DB dedicado a almacenamiento sobre objetos; tú probablemente no necesitas ni eso todavía.</p>
<h2>Vibe coding: úsalo para lo que es</h2>
<p>Lovable ($400M ARR en febrero, en conversaciones para levantar a valoración de dos dígitos en miles de millones), Replit ($9B de valoración en marzo), v0, Bolt. Son máquinas de validar ideas: de prompt a prototipo desplegado en una tarde. Lo que no son — todavía — es la base de un producto que tenga que escalar con requisitos serios de seguridad y datos. El patrón que veo funcionar: prototipa en Lovable o v0, valida con usuarios reales, y reconstruye sobre tu stack cuando haya señal.</p>
<h2>Mi receta concreta</h2>
<p>Si mañana empezara un SaaS B2B, sin más contexto que «quiero validar rápido y no quemar dinero»:</p>
<ul>
<li><strong>Código:</strong> Claude Code como agente principal + revisión humana de todo lo que toque dinero, permisos o datos.</li>
<li><strong>App:</strong> Next.js 16 + TypeScript en Vercel. <strong>Contenido/marketing:</strong> Astro.</li>
<li><strong>Datos:</strong> Supabase (Postgres + auth + storage + pgvector).</li>
<li><strong>IA:</strong> Vercel AI SDK 6, Sonnet 5 por defecto con escalado a Opus 4.8, MCP para integraciones, evals con casos reales desde la primera semana.</li>
<li><strong>Runtime:</strong> Bun en local y CI; Node 24 donde la plataforma lo pida.</li>
</ul>
<p>¿Es el stack más potente posible? No. Es el que te deja iterar cada día con dos personas y media, que en 2026 es exactamente el juego: la ventaja ya no está en la infraestructura que montas, sino en la velocidad con la que aprendes qué construir.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Startups hechas con IA en 2026: los números reales detrás del hype</title>
      <link>https://yohangel.com/blog/startups-ia-casos-exito-2026/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/startups-ia-casos-exito-2026/</guid>
      <description>Cursor, Lovable, Cognition, Base44, Cal AI y los datos de YC: cuánto facturan de verdad, con cuánta gente, y qué patrones se repiten. Con gráficas.</description>
      <pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>IA</category>
      <content:encoded><![CDATA[<p><strong>Del hype de la IA se habla todos los días; de los números, casi nunca. Así que junté las cifras públicas — verificadas en prensa financiera, blogs oficiales y rondas confirmadas — de lo que va de 2026. La conclusión corta: las curvas de crecimiento que estamos viendo no habían existido nunca en la historia del software.</strong></p>
<p>Antes de empezar, transparencia metodológica: uso solo cifras con fuente pública. Cuando un dato es estimación de terceros (y no cifra confirmada por la empresa) lo marco como tal. Y aun con ese filtro, lo que queda es difícil de creer.</p>
<h2>La curva que resume la era: Cursor</h2>
<p>Anysphere (Cursor) fue en su momento el SaaS más rápido de la historia en llegar a $100M de ARR. Eso fue en enero de 2025. Lo que pasó después es la gráfica que define esta era:</p>
<svg viewBox="0 0 700 300" width="100%" role="img" aria-label="ARR de Cursor: de 100 millones en enero 2025 a 4.000 millones en junio 2026" style="margin:0 0 22px; font-family: var(--font-mono);">
  <text x="20" y="24" fill="var(--color-ink)" font-size="14" font-weight="700">Cursor — ARR en millones de $</text>
  <line x1="30" y1="250" x2="670" y2="250" stroke="var(--hairline)" stroke-width="1" />
  <rect x="45" y="245" width="66" height="5" rx="2" fill="var(--color-violet)" />
  <rect x="150" y="225" width="66" height="25" rx="3" fill="var(--color-violet)" />
  <rect x="255" y="200" width="66" height="50" rx="4" fill="var(--color-violet)" />
  <rect x="360" y="150" width="66" height="100" rx="4" fill="var(--color-violet)" />
  <rect x="465" y="100" width="66" height="150" rx="4" fill="var(--color-violet-light)" />
  <rect x="570" y="50" width="66" height="200" rx="4" fill="var(--color-violet-light)" />
  <text x="78" y="238" fill="var(--color-body)" font-size="12" text-anchor="middle">100</text>
  <text x="183" y="218" fill="var(--color-body)" font-size="12" text-anchor="middle">500</text>
  <text x="288" y="193" fill="var(--color-body)" font-size="12" text-anchor="middle">1.000</text>
  <text x="393" y="143" fill="var(--color-body)" font-size="12" text-anchor="middle">2.000</text>
  <text x="498" y="93" fill="var(--color-body)" font-size="12" text-anchor="middle">3.000</text>
  <text x="603" y="43" fill="var(--color-ink)" font-size="13" font-weight="700" text-anchor="middle">4.000</text>
  <text x="78" y="270" fill="var(--color-faint)" font-size="11" text-anchor="middle">ene 25</text>
  <text x="183" y="270" fill="var(--color-faint)" font-size="11" text-anchor="middle">jun 25</text>
  <text x="288" y="270" fill="var(--color-faint)" font-size="11" text-anchor="middle">nov 25</text>
  <text x="393" y="270" fill="var(--color-faint)" font-size="11" text-anchor="middle">feb 26</text>
  <text x="498" y="270" fill="var(--color-faint)" font-size="11" text-anchor="middle">abr 26</text>
  <text x="603" y="270" fill="var(--color-faint)" font-size="11" text-anchor="middle">jun 26</text>
</svg><p>De $100M a ~$4.000M de ingresos anualizados en 17 meses, con unos $2.600M viniendo de empresa (B2B). Su Serie D de noviembre de 2025 la valoró en $29.300M, y en abril de 2026 se reportaban conversaciones para levantar a ~$50.000M. Para calibrar: Salesforce tardó más de una década en facturar lo que Cursor alcanzó en tres años.</p>
<h2>Los multiplicadores de 2026</h2>
<p>Cursor no es un caso aislado. Esta es la tabla de lo que va de año:</p>
<table>
<thead>
<tr>
<th>Empresa</th>
<th>Hace ~1 año</th>
<th>Ahora (2026)</th>
<th>Valoración</th>
</tr>
</thead>
<tbody><tr>
<td>Cognition (Devin)</td>
<td>$37M ARR (may 25)</td>
<td>$492M ARR (may 26) — 13x</td>
<td>$26.000M (ronda de $1.000M, may 26)</td>
</tr>
<tr>
<td>Lovable</td>
<td>$100M ARR (~jul 25)</td>
<td>$400M ARR (feb 26); ~$500M est.</td>
<td>$6.600M (dic 25); en conversaciones por ~$12.000M</td>
</tr>
<tr>
<td>ElevenLabs</td>
<td>$3.300M val. (ene 25)</td>
<td>~$500M ARR est.</td>
<td>$11.000M (Serie D, feb 26)</td>
</tr>
<tr>
<td>Harvey (legal)</td>
<td>$100M ARR (ago 25)</td>
<td>$300M+ ARR (jun 26)</td>
<td>$11.000M (mar 26)</td>
</tr>
<tr>
<td>Mercor</td>
<td>—</td>
<td>$1.000M ARR (inicios 26), rentable</td>
<td>$10.000M (oct 25)</td>
</tr>
<tr>
<td>Replit</td>
<td>$150M anualizado (sep 25)</td>
<td>~$525M est. (abr 26)</td>
<td>$9.000M (mar 26)</td>
</tr>
</tbody></table>
<p>Y el contexto de fondo: Anthropic pasó de un run-rate de $9.000M a final de 2025 a $47.000M en mayo de 2026, y Claude Code por sí solo facturaba $1.000M anualizados a los seis meses de salir. La demanda de construir con IA es la marea que sube todos estos barcos.</p>
<h2>El dato más disruptivo: ingresos por empleado</h2>
<p>Lo que de verdad rompe los modelos mentales no es cuánto facturan, sino con cuánta gente. Facturación anual por empleado, en millones de dólares:</p>
<svg viewBox="0 0 700 210" width="100%" role="img" aria-label="Ingresos por empleado: Cursor 13 millones, Midjourney 4,7, Lovable 2, Gamma 2" style="margin:0 0 22px; font-family: var(--font-mono);">
  <text x="20" y="24" fill="var(--color-ink)" font-size="14" font-weight="700">Ingresos anuales por empleado ($M)</text>
  <text x="160" y="62" fill="var(--color-body)" font-size="12" text-anchor="end">Cursor</text>
  <rect x="172" y="48" width="440" height="20" rx="4" fill="var(--color-violet-light)" />
  <text x="622" y="62" fill="var(--color-ink)" font-size="12" font-weight="700">~13</text>
  <text x="160" y="100" fill="var(--color-body)" font-size="12" text-anchor="end">Midjourney</text>
  <rect x="172" y="86" width="159" height="20" rx="4" fill="var(--color-violet)" />
  <text x="341" y="100" fill="var(--color-body)" font-size="12">4,7</text>
  <text x="160" y="138" fill="var(--color-body)" font-size="12" text-anchor="end">Lovable</text>
  <rect x="172" y="124" width="68" height="20" rx="4" fill="var(--color-sky)" />
  <text x="250" y="138" fill="var(--color-body)" font-size="12">~2</text>
  <text x="160" y="176" fill="var(--color-body)" font-size="12" text-anchor="end">Gamma</text>
  <rect x="172" y="162" width="68" height="20" rx="4" fill="var(--color-mint)" />
  <text x="250" y="176" fill="var(--color-body)" font-size="12">~2</text>
</svg><p>Para poner esto en perspectiva: una empresa de software tradicional bien gestionada ronda los $200.000–400.000 por empleado. Los casos de arriba están entre 5 y 40 veces por encima:</p>
<ul>
<li><strong>Gamma</strong> pasó los $100M de ARR con unas 50 personas, rentable desde hace más de dos años.</li>
<li><strong>Midjourney</strong> factura ~$500M con menos de 110 empleados y sin haber levantado un dólar de capital riesgo. Nunca.</li>
<li><strong>Mercor</strong> cruzó los $1.000M de ARR con ~200 personas y flujo de caja positivo.</li>
<li><strong>Cursor</strong> opera ~$4.000M con unos 300 empleados (estimación; la cifra no es oficial).</li>
</ul>
<h2>Los pequeños: fundadores en solitario que salieron por la puerta grande</h2>
<p>Los titulares se los llevan los unicornios, pero las historias más replicables son otras:</p>
<ul>
<li><strong>Base44 (Maor Shlomo).</strong> Un solo fundador, bootstrapped, construyendo con IA. Lanzó a principios de 2025, llegó a $1,5M de ARR en 4 semanas y a $3,5M con 300.000 usuarios en 6 meses. Wix la compró por <strong>$80M en cash</strong> a los seis meses del lanzamiento — y los hitos posteriores le pagaron $38M adicionales. Ocho empleados en el momento de la venta.</li>
<li><strong>Cal AI (Zach Yadegari y Henry Langmack).</strong> Dos fundadores que empezaron con 17 años: una app que cuenta calorías a partir de fotos con IA. $30M de ingresos en 2025, $5,7M solo en enero de 2026, y en marzo — con $50M de ARR — la compró MyFitnessPal.</li>
</ul>
<blockquote>
<p>💡 El patrón común de ambos: no inventaron tecnología nueva. Aplicaron modelos que cualquiera puede usar por API a un problema aburrido y concreto, y ejecutaron más rápido que nadie. La ventaja ya no es el acceso a la IA — es la velocidad de iteración sobre un caso de uso.</p>
</blockquote>
<h2>Y por debajo de todo: el código ya lo escribe la IA</h2>
<p>Estos éxitos flotan sobre un cambio estructural medible. Google lo ha ido publicando con fechas:</p>
<svg viewBox="0 0 700 260" width="100%" role="img" aria-label="Porcentaje de código nuevo de Google generado por IA: 25 por ciento en 2024, 50 a finales de 2025, 75 en abril de 2026" style="margin:0 0 22px; font-family: var(--font-mono);">
  <text x="20" y="24" fill="var(--color-ink)" font-size="14" font-weight="700">Google — % de código nuevo generado por IA</text>
  <line x1="30" y1="215" x2="670" y2="215" stroke="var(--hairline)" stroke-width="1" />
  <rect x="90" y="160" width="120" height="55" rx="4" fill="var(--color-sky)" />
  <rect x="290" y="105" width="120" height="110" rx="4" fill="var(--color-violet)" />
  <rect x="490" y="50" width="120" height="165" rx="4" fill="var(--color-violet-light)" />
  <text x="150" y="150" fill="var(--color-body)" font-size="13" text-anchor="middle">25%</text>
  <text x="350" y="95" fill="var(--color-body)" font-size="13" text-anchor="middle">50%</text>
  <text x="550" y="40" fill="var(--color-ink)" font-size="14" font-weight="700" text-anchor="middle">75%</text>
  <text x="150" y="237" fill="var(--color-faint)" font-size="11" text-anchor="middle">inicios 2024</text>
  <text x="350" y="237" fill="var(--color-faint)" font-size="11" text-anchor="middle">finales 2025</text>
  <text x="550" y="237" fill="var(--color-faint)" font-size="11" text-anchor="middle">abril 2026</text>
</svg><p>En Anthropic la cifra interna reportada supera el 90%. Y en Y Combinator, que es donde mejor se ve el futuro inmediato: ya en el batch W25 un 25% de las startups tenía el 95% del código generado por IA; en el Demo Day de W26 (marzo 2026), <strong>14 startups llegaron con más de $1M de ARR antes del Demo Day</strong> — el triple que el año anterior y récord histórico — con un crecimiento medio del 14% semanal, el más alto que YC ha registrado en 20 años.</p>
<h2>Los patrones que se repiten</h2>
<p>Después de mirar todos estos casos, los denominadores comunes que veo:</p>
<ul>
<li><strong>Equipos diminutos por diseño, no por falta de dinero.</strong> Lovable levantó cientos de millones y sigue en ~250 personas. La restricción de headcount es estrategia: menos coordinación, más agentes.</li>
<li><strong>Velocidad como único foso.</strong> Casi ninguno tiene tecnología defendible per se; tienen ritmo de shipping que la competencia no iguala.</li>
<li><strong>Distribución primero.</strong> Cal AI creció por redes sociales antes de tener producto maduro; Lovable convirtió a cada usuario en marketing con el «mira lo que construí».</li>
<li><strong>El B2B paga las curvas.</strong> La aceleración de Cursor y Devin viene de contratos enterprise (Goldman Sachs, Citi y la NASA son clientes de Cognition), no de developers individuales.</li>
</ul>
<h2>La letra pequeña</h2>
<p>Para ser justos con los datos: varias cifras de ARR de 2026 (Lovable ~$500M, Replit ~$525M, Claude Code) son estimaciones de trackers como Sacra, no confirmaciones oficiales; la ronda de Lovable a ~$12.000M seguía en negociación al cierre de esto; y por cada caso de éxito hay cientos de wrappers de GPT que murieron sin titular — el sesgo de supervivencia en esta lista es máximo.</p>
<p>Pero incluso descontando todo eso, la señal es clara: en 2026 ya no hay correlación entre tamaño de equipo y tamaño de negocio. Y esa, para cualquiera que sepa construir software, es la mejor noticia en décadas: nunca ha sido tan barato intentarlo.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Fine-tuning, RAG o prompting: cómo decido cuál usar</title>
      <link>https://yohangel.com/blog/fine-tuning-vs-rag-decision/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/fine-tuning-vs-rag-decision/</guid>
      <description>Un árbol de decisión honesto para elegir entre afinar un modelo, montar RAG o quedarte en prompting, con los costes reales de cada rama.</description>
      <pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>IA</category>
      <content:encoded><![CDATA[<p><strong>Casi todas las veces que alguien me dice &quot;necesitamos fine-tuning&quot;, en realidad necesita mejor contexto. El fine-tuning es la primera palabra que sale porque suena a la solución seria, la que &quot;enseña&quot; al modelo. Pero en producto he cambiado muy pocos comportamientos con fine-tuning y muchísimos con un buen prompt o con RAG bien montado. La regla que me ha funcionado es empezar por lo barato y reversible, y solo subir de escalón cuando el escalón anterior demuestra estar techado. Este artículo es ese árbol de decisión, con los trade-offs que acepto en cada rama y las señales que me dicen cuándo he elegido mal.</strong></p>
<h2>Primero pregunto: ¿es un problema de conocimiento o de comportamiento?</h2>
<p>Esta es la bifurcación que ordena todo lo demás. Si el modelo falla porque no sabe algo —datos de tu dominio, documentación interna, hechos posteriores a su corte de entrenamiento— es un problema de conocimiento, y el conocimiento se inyecta por contexto, no se hornea en pesos. Ahí RAG es la respuesta por defecto.</p>
<p>Si el modelo sabe lo que necesita pero responde con el formato, el tono o la estructura equivocados de forma consistente, es un problema de comportamiento. Y el comportamiento sí es candidato a fine-tuning… pero solo después de que el prompting se quede corto, que es más tarde de lo que la gente cree.</p>
<p>Confundir estas dos ramas es el error más caro que veo. Nadie arregla con fine-tuning que el modelo no conozca tu catálogo de productos: lo entrenas hoy y mañana el catálogo cambió. Y nadie arregla con RAG que el modelo se niegue a devolver JSON limpio: por muchos documentos que le metas, el problema es de forma, no de datos.</p>
<h2>Prompting: el escalón que casi siempre subestimo</h2>
<p>Mi punto de partida es siempre prompting, porque es el único cambio que despliego en minutos y revierto en segundos. Antes de tocar nada más, exprimo instrucciones claras, ejemplos few-shot representativos y una estructura de salida explícita. En JXBS, buena parte de lo que parecía pedir un modelo afinado se resolvió con tres ejemplos bien elegidos en el prompt y un esquema de salida estricto.</p>
<p>El coste de esta rama es el contexto: cada ejemplo que metes ocupa tokens que pagas en cada llamada, y un prompt de 20 ejemplos se vuelve caro y lento a escala. Ese es exactamente el síntoma de que quizá toca subir de escalón: cuando necesitas tantos ejemplos que el prompt es la mitad de tu factura, el patrón ya está ahí y puede valer la pena hornearlo.</p>
<blockquote>
<p>💡 Si no has intentado resolverlo con prompting durante al menos una tarde, no tienes datos para justificar fine-tuning. &quot;Probamos un prompt y no salió&quot; no es prompting, es una anécdota.</p>
</blockquote>
<h2>RAG: para conocimiento que cambia y debe ser citable</h2>
<p>Cuando el problema es de conocimiento, RAG gana casi siempre por una razón que va más allá de lo técnico: la trazabilidad. Un sistema RAG puede decirte <em>de qué documento</em> salió la respuesta. Un modelo afinado te da la respuesta desde dentro de sus pesos, sin fuente, y cuando alucina no tienes dónde mirar.</p>
<pre><code class="language-typescript">// El conocimiento vive fuera del modelo y se actualiza sin reentrenar
const contexto = await buscarFragmentos(pregunta, { topK: 5 });
const respuesta = await llm.completar({
  system: &#39;Responde solo con la información de &lt;contexto&gt;. Si no está, dilo.&#39;,
  contexto,
  pregunta,
});
</code></pre>
<p>El trade-off que acepto con RAG es operativo: ahora mantengo un pipeline de ingesta, embeddings, un índice vectorial y una estrategia de chunking. Es más infraestructura que un prompt. A cambio, actualizo el conocimiento cambiando documentos, no reentrenando, y eso en un dominio que se mueve vale oro.</p>
<h2>Fine-tuning: el último escalón, y sé por qué subo</h2>
<p>Llego a fine-tuning solo cuando se cumplen tres cosas a la vez: el comportamiento que quiero es estable y repetido, prompting me lo da pero a un coste de tokens insostenible, y tengo suficientes ejemplos reales de calidad para entrenar. Si me falta cualquiera de las tres, no subo.</p>
<p>Lo que compro con fine-tuning es un modelo que hace lo que quiero con un prompt corto, más barato y más rápido por llamada. Lo que pago es rigidez: cada cambio de comportamiento es un reentrenamiento, el dataset se convierte en un activo que hay que versionar y cuidar, y he introducido un artefacto que puede quedar obsoleto en silencio. El fine-tuning no elimina RAG, además: lo más potente que he montado combina un modelo afinado en <em>cómo</em> responder con RAG para <em>qué</em> saber.</p>
<h2>El error de saltarse escalones</h2>
<p>El antipatrón que más me ha costado es empezar por el final. Equipos que montan fine-tuning para un problema que un prompt resolvía, y acaban con un modelo caro de mantener que no rinde mejor que la línea base que nunca midieron. Empezar barato no es ser conservador: es generar la evidencia que justifica el siguiente paso. Cuando por fin haces fine-tuning tras agotar prompting y RAG, sabes exactamente qué estás comprando y por qué. Esa claridad es la diferencia entre una decisión de ingeniería y una compra por moda.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Step Functions: cuándo un orquestador le gana a encadenar Lambdas</title>
      <link>https://yohangel.com/blog/step-functions-orquestacion/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/step-functions-orquestacion/</guid>
      <description>Por qué muevo la lógica de coordinación de procesos largos fuera del código y dentro de una máquina de estados, y los trade-offs que eso implica.</description>
      <pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>AWS</category>
      <content:encoded><![CDATA[<p><strong>Durante un tiempo mi forma de encadenar pasos en AWS fue la que sale sola: una Lambda que hace su parte y al terminar invoca a la siguiente, que invoca a la siguiente. Funciona con dos pasos. Con seis, con reintentos, con un paso que a veces tarda diez minutos y con la necesidad de saber en qué punto se quedó un proceso que falló anoche, ese enfoque se convierte en una madeja imposible de depurar. Step Functions cambió eso para mí: saqué la lógica de coordinación del código y la puse en una máquina de estados explícita. Elegí este modelo en varios flujos porque el coste de no saber dónde estaba cada ejecución me superaba; a cambio acepté aprender un lenguaje de definición nuevo y pagar por transición de estado. Este artículo es cuándo compensa y cuándo no.</strong></p>
<h2>El problema: la orquestación escondida en el código</h2>
<p>Cuando una Lambda invoca a la siguiente, la lógica del proceso —el orden, los reintentos, qué hacer si el paso tres falla— vive repartida entre funciones. No hay un sitio donde leer &quot;así funciona este flujo completo&quot;. Para entenderlo abres cinco archivos y reconstruyes el grafo en tu cabeza.</p>
<p>El día que ese proceso falla en producción a mitad de camino, la pregunta clave es &quot;¿en qué paso se quedó y con qué datos?&quot;. Con Lambdas encadenadas, la respuesta está enterrada en logs de cinco funciones distintas que tienes que correlacionar a mano. Con un orquestador, la respuesta es una pantalla: ves la ejecución, el estado exacto donde murió y el input que recibió.</p>
<h2>La máquina de estados como documentación ejecutable</h2>
<p>Lo que más valoro de Step Functions es que la definición del flujo <em>es</em> el diagrama. No hay un doc en Confluence que se desactualiza: la fuente de verdad del proceso es la máquina de estados que corre en producción.</p>
<pre><code class="language-json">{
  &quot;StartAt&quot;: &quot;ValidarPedido&quot;,
  &quot;States&quot;: {
    &quot;ValidarPedido&quot;: {
      &quot;Type&quot;: &quot;Task&quot;,
      &quot;Resource&quot;: &quot;arn:aws:lambda:...:validar&quot;,
      &quot;Retry&quot;: [{ &quot;ErrorEquals&quot;: [&quot;Timeout&quot;], &quot;MaxAttempts&quot;: 3 }],
      &quot;Next&quot;: &quot;CobrarPago&quot;
    },
    &quot;CobrarPago&quot;: {
      &quot;Type&quot;: &quot;Task&quot;,
      &quot;Resource&quot;: &quot;arn:aws:lambda:...:cobrar&quot;,
      &quot;Catch&quot;: [{ &quot;ErrorEquals&quot;: [&quot;States.ALL&quot;], &quot;Next&quot;: &quot;Compensar&quot; }],
      &quot;Next&quot;: &quot;Confirmar&quot;
    },
    &quot;Compensar&quot;: { &quot;Type&quot;: &quot;Task&quot;, &quot;Resource&quot;: &quot;arn:aws:lambda:...:revertir&quot;, &quot;End&quot;: true },
    &quot;Confirmar&quot;: { &quot;Type&quot;: &quot;Task&quot;, &quot;Resource&quot;: &quot;arn:aws:lambda:...:confirmar&quot;, &quot;End&quot;: true }
  }
}
</code></pre>
<p>Fíjate en dónde vive la política de reintentos y la de compensación: en la definición, declarativa, no repartida por <code>try/catch</code> en cada función. Cada Lambda vuelve a hacer una sola cosa y el orquestador se encarga del resto. Ese es el reparto de responsabilidades que perseguía.</p>
<h2>Reintentos y compensación sin escribirlos a mano</h2>
<p>El motivo por el que más migro flujos a Step Functions es el manejo de fallos. En Lambdas encadenadas, reintentar con backoff, distinguir errores transitorios de permanentes y compensar pasos ya ejecutados cuando algo falla a mitad es código que escribes, testeas y mantienes tú, mal, en cada función.</p>
<p>En la máquina de estados, <code>Retry</code> y <code>Catch</code> son parte de la definición. Un timeout se reintenta tres veces con backoff exponencial sin que yo escriba un bucle. Y el patrón saga —si el pago falla tras reservar inventario, revierte la reserva— se expresa con un estado de compensación al que saltas con <code>Catch</code>, en vez de con banderas repartidas por medio backend.</p>
<blockquote>
<p>💡 La pregunta que uso para decidir: ¿necesito saber en qué paso exacto se quedó una ejecución que falló, semanas después? Si la respuesta es sí, la orquestación explícita se paga sola en el primer incidente.</p>
</blockquote>
<h2>Dónde NO uso Step Functions</h2>
<p>Ser honesto con los trade-offs es lo que separa una decisión de un acto de fe. No meto Step Functions en todo.</p>
<p>Para un flujo síncrono y simple donde el usuario espera una respuesta en la pantalla, un orquestador solo añade latencia y una capa que nadie pidió; una Lambda directa es lo correcto. Tampoco lo uso para procesos de altísimo volumen y baja duración donde cada transición de estado se factura: el modelo Standard cobra por transición y a millones de ejecuciones cortas eso escala en tu factura de formas que sorprenden. Para ese caso miro Express, que cambia el modelo de precio y la garantía de durabilidad, o directamente una cola SQS con un consumidor que hace todo el trabajo de una vez.</p>
<p>Y hay un coste humano: el lenguaje de definición es una cosa más que el equipo tiene que aprender y leer. Si tu flujo tiene dos pasos y nunca crecerá, esa curva no vale la pena.</p>
<h2>El precio real que pagué</h2>
<p>Lo más caro de adoptar Step Functions no fue el servicio, fue reescribir la mentalidad del equipo. Pasamos de &quot;la lógica está en el código&quot; a &quot;la lógica está en la definición del estado&quot;, y durante un tiempo la gente seguía metiendo coordinación dentro de las Lambdas por costumbre, duplicando lo que la máquina ya hacía. La regla que fijé fue clara: las Lambdas hacen trabajo, la máquina de estados decide el orden. Cuando el equipo interiorizó esa frontera, depurar un proceso largo dejó de ser arqueología de logs y pasó a ser mirar un diagrama que te dice exactamente dónde estás. Ese cambio, y no la sintaxis JSON, es lo que hace que valga la pena.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Web Workers: sacar el trabajo pesado del hilo principal</title>
      <link>https://yohangel.com/blog/web-workers-hilo-principal/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/web-workers-hilo-principal/</guid>
      <description>Cuándo mover cómputo a un worker de verdad mejora la UI y cuándo solo añade complejidad, con el patrón que uso para no pelearme con postMessage.</description>
      <pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>Frontend</category>
      <content:encoded><![CDATA[<p><strong>El hilo principal del navegador hace demasiadas cosas: ejecuta tu JavaScript, calcula el layout, pinta y responde a los clics. Todo en la misma cola. Cuando metes ahí un cálculo pesado —parsear un CSV de miles de filas, filtrar un dataset grande, procesar una imagen— bloqueas ese hilo y la interfaz se congela: el scroll se traba, los botones no responden, el spinner ni siquiera gira porque el hilo que lo animaría está ocupado. Los Web Workers existen para eso: mover ese cómputo a otro hilo y devolverle al principal su única responsabilidad importante, que es mantener la UI viva. He usado workers en varias PWAs y la mejora en percepción es real, pero también he visto meterlos donde no tocaba. Este artículo es cuándo valen la pena.</strong></p>
<h2>El síntoma que delata que necesitas un worker</h2>
<p>No todo cómputo va a un worker. La señal concreta que busco es un <em>long task</em>: una función síncrona que ocupa el hilo principal más de unos 50 ms y durante la cual la página deja de responder. Si abres el panel de rendimiento y ves bloques largos amarillos justo cuando la UI se traba, ahí tienes al culpable.</p>
<p>Casos típicos que me han llevado a un worker: parsear y transformar archivos grandes que el usuario sube, ordenar o filtrar en cliente un dataset de decenas de miles de filas, cálculos de geometría o imagen, y correr modelos ligeros en el navegador. Lo que tienen en común es que son CPU pura y prolongada, no esperas de red. Para lo que es esperar a un servidor no necesitas un worker: <code>async/await</code> ya libera el hilo. El worker es para trabajo que <em>quema CPU</em>, no para trabajo que <em>espera</em>.</p>
<h2>El coste que la gente olvida: la frontera de mensajes</h2>
<p>Un worker no comparte memoria con el hilo principal. Se comunican pasando mensajes, y esos datos se <em>copian</em> al cruzar la frontera (structured clone). Ese detalle es el que decide si el worker compensa o no: si mandas un objeto enorme al worker, esperas un cálculo trivial y recibes otro objeto enorme de vuelta, el coste de serializar y copiar se come la ganancia.</p>
<p>La regla que sigo es meter <em>mucho</em> trabajo por cada cruce de frontera. Un worker rinde cuando le mandas datos una vez y hace un cálculo grande, no cuando lo llamas en un bucle apretado para operaciones pequeñas. Cuando los datos son de verdad grandes, uso objetos transferibles: un <code>ArrayBuffer</code> se <em>transfiere</em> en vez de copiarse, cambiando de dueño sin duplicar memoria.</p>
<pre><code class="language-typescript">// Hilo principal: transfiere el buffer, no lo copia
const worker = new Worker(new URL(&#39;./procesar.ts&#39;, import.meta.url), { type: &#39;module&#39; });
worker.postMessage({ buffer }, [buffer]); // el segundo arg transfiere la propiedad
worker.onmessage = (e) =&gt; pintarResultado(e.data);
</code></pre>
<h2>El patrón que uso para no sufrir con postMessage</h2>
<p>La API cruda de <code>postMessage</code> y <code>onmessage</code> se vuelve un espagueti de eventos en cuanto tienes más de dos tipos de mensaje. Lo que hago es envolver el worker en una promesa, para que desde el resto del código llamar al worker se sienta como un <code>await</code> normal y no como cablear un bus de eventos.</p>
<pre><code class="language-typescript">function ejecutarEnWorker&lt;T&gt;(worker: Worker, payload: unknown): Promise&lt;T&gt; {
  return new Promise((resolve, reject) =&gt; {
    worker.onmessage = (e) =&gt; resolve(e.data as T);
    worker.onerror = (e) =&gt; reject(e);
    worker.postMessage(payload);
  });
}
</code></pre>
<p>Para casos serios uso Comlink, que hace exactamente esto pero bien: te deja llamar funciones del worker como si fueran locales, con <code>await</code>, y esconde toda la mensajería. La primera vez que envuelves un worker así, la barrera mental de &quot;esto es complicado&quot; desaparece: el worker pasa a ser una función asíncrona más.</p>
<blockquote>
<p>💡 Un worker no hace tu código más rápido; hace tu UI más <em>fluida</em>. El cálculo tarda lo mismo o un poco más por la copia de datos. Lo que ganas es que el usuario puede seguir haciendo scroll y clicando mientras tanto. Es una mejora de percepción, no de throughput.</p>
</blockquote>
<h2>Dónde NO meto un worker</h2>
<p>Ser honesto con el trade-off importa. No uso worker cuando el cómputo es corto: si tarda 5 ms, moverlo a otro hilo solo añade latencia de mensajería y una capa de complejidad para nada. Tampoco cuando el trabajo es esperar red, porque eso ya no bloquea. Y evito el worker si el algoritmo necesita tocar el DOM: los workers no tienen acceso al DOM por diseño, y si tu &quot;cálculo&quot; en realidad manipula la interfaz, no es candidato.</p>
<p>El otro coste es de tooling y mantenimiento: un worker es otro archivo, otro punto de entrada para el bundler, otra pieza que alguien tiene que entender al leer el código. Con Vite o el bundler moderno de turno la fricción bajó mucho, pero no es cero. Por eso mi umbral es claro: worker solo cuando hay un long task medible que traba la UI de forma perceptible. Si no puedo señalar ese bloque amarillo en el profiler, no hay worker. Optimizar un bloqueo que nadie nota es añadir complejidad para presumir de arquitectura, y eso no es ingeniería, es decoración.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Caché semántico para LLMs: pagar una vez por respuestas que ya diste</title>
      <link>https://yohangel.com/blog/cache-semantico-llm/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/cache-semantico-llm/</guid>
      <description>Cómo usar embeddings para detectar preguntas repetidas y servir respuestas cacheadas sin llamar al modelo, con sus trampas incluidas.</description>
      <pubDate>Tue, 07 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>IA</category>
      <content:encoded><![CDATA[<p><strong>La factura de un LLM en producción no crece por las preguntas difíciles: crece por las repetidas. En cualquier producto con un asistente, una parte enorme del tráfico son variaciones de las mismas consultas — &quot;¿cómo cambio mi contraseña?&quot;, &quot;cómo restablezco mi clave&quot;, &quot;olvidé mi password&quot;. Un caché tradicional por clave exacta no captura nada de eso, porque el texto nunca es idéntico. Un caché semántico sí: convierte la pregunta en un embedding, busca si ya respondiste algo suficientemente parecido, y si el vecino más cercano supera un umbral de similitud, devuelve la respuesta guardada sin tocar el modelo. Elegí este patrón en un producto real porque el costo por request importaba más que la frescura absoluta de cada respuesta; a cambio, acepté la complejidad de gestionar umbrales y de invalidar entradas. Este artículo es lo que aprendí.</strong></p>
<h2>La mecánica: pgvector es suficiente</h2>
<p>No necesitas infraestructura nueva. Si ya usas PostgreSQL con pgvector para búsqueda semántica — que es mi caso habitual con Prisma —, el caché es una tabla más:</p>
<pre><code class="language-sql">CREATE TABLE llm_cache (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  query_text TEXT NOT NULL,
  query_embedding vector(1536) NOT NULL,
  response TEXT NOT NULL,
  model TEXT NOT NULL,
  hit_count INT DEFAULT 0,
  created_at TIMESTAMPTZ DEFAULT now(),
  expires_at TIMESTAMPTZ NOT NULL
);

CREATE INDEX ON llm_cache
  USING hnsw (query_embedding vector_cosine_ops);
</code></pre>
<p>El flujo de lectura es un query de vecino más cercano con umbral:</p>
<pre><code class="language-typescript">const [hit] = await prisma.$queryRaw&lt;CacheHit[]&gt;`
  SELECT response, 1 - (query_embedding &lt;=&gt; ${embedding}::vector) AS similarity
  FROM llm_cache
  WHERE expires_at &gt; now()
  ORDER BY query_embedding &lt;=&gt; ${embedding}::vector
  LIMIT 1
`;

if (hit &amp;&amp; hit.similarity &gt;= 0.92) {
  return hit.response; // cache hit: cero tokens gastados
}
</code></pre>
<p>Si no hay hit, llamas al modelo, guardas la respuesta con su embedding, y el siguiente usuario que pregunte lo mismo con otras palabras ya no paga.</p>
<h2>El umbral es la decisión de producto, no un detalle técnico</h2>
<p>Todo el patrón vive o muere en ese <code>0.92</code>. Demasiado bajo, y sirves respuestas equivocadas a preguntas que solo se parecen superficialmente — &quot;¿cómo cancelo mi suscripción?&quot; y &quot;¿cómo cancelo un pago?&quot; tienen embeddings incómodamente cercanos. Demasiado alto, y tu hit rate se desploma y el caché no paga su propia complejidad.</p>
<p>Mi enfoque: empezar en 0.95, loguear cada hit con la pregunta original y la cacheada, y revisar una muestra a mano cada semana. Bajar el umbral solo cuando los falsos positivos de la franja siguiente sean aceptables. En dominios con vocabulario cerrado (soporte de un producto concreto) he llegado a 0.90; en dominios abiertos no bajaría de 0.93.</p>
<blockquote>
<p>💡 El umbral de similitud no es un hiperparámetro que se ajusta una vez: es una política de producto que define cuánta imprecisión toleras a cambio de costo. Trátalo como tal — con logging, revisión periódica y un owner.</p>
</blockquote>
<h2>Invalidación: el precio oculto</h2>
<p>El caché semántico hereda el problema clásico de los cachés y le añade uno propio. El clásico: las respuestas caducan. Si tu producto cambia el flujo de facturación, todas las respuestas cacheadas sobre facturación son ahora mentiras bien escritas. Por eso cada entrada lleva <code>expires_at</code> y, más importante, un mecanismo de invalidación por categoría: etiqueto las entradas con el área funcional que tocan y cuando despliego un cambio en esa área, borro la categoría entera. Es brusco, pero es predecible.</p>
<p>El problema propio: las respuestas personalizadas. Si la respuesta del modelo incluye datos del usuario (&quot;tu plan actual es Pro&quot;), cachearla y servírsela a otro usuario es una fuga de datos, no una optimización. Mi regla es binaria: solo entra al caché lo que pasa por un clasificador previo de &quot;pregunta genérica&quot;. Todo lo que dependa del contexto del usuario se queda fuera, sin excepciones ni casos especiales.</p>
<h2>Cuándo no hacerlo</h2>
<p>Los trade-offs mandan. Un caché semántico compensa cuando el tráfico tiene alta redundancia semántica (soporte, FAQs, onboarding), el costo por llamada es relevante y la latencia importa — un hit responde en ~50ms contra los 2-4 segundos de un modelo grande. No compensa cuando cada pregunta es única (herramientas creativas, análisis de documentos del usuario), cuando las respuestas dependen de contexto personal, o cuando el volumen es tan bajo que la factura del LLM es ruido.</p>
<p>En mi caso, la señal para construirlo fue mirar los logs: cuando ves la misma intención expresada de veinte formas distintas un día tras otro, el caché se justifica solo. Si no has mirado tus logs todavía, empieza por ahí — es posible que no necesites nada de esto, y esa también es una buena noticia.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Container queries: el día que dejé de mentirle a mis componentes</title>
      <link>https://yohangel.com/blog/container-queries-componentes/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/container-queries-componentes/</guid>
      <description>Por qué los media queries rompen la promesa de un componente reutilizable y cómo las container queries la cumplen de verdad.</description>
      <pubDate>Tue, 07 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>Frontend</category>
      <content:encoded><![CDATA[<p><strong>Un componente reutilizable promete algo simple: lo pones donde sea y funciona. Durante años esa promesa era mentira, y la mentira tenía nombre: media query. Una tarjeta que se adapta con <code>@media (min-width: 768px)</code> no reacciona a su propio espacio, reacciona al ancho de la ventana. Ponla en un sidebar estrecho con la pantalla en 1400px y la tarjeta cree que tiene sitio de sobra, porque está mirando la variable equivocada. Las container queries arreglan esto de raíz: el componente responde al tamaño de su contenedor, no del viewport. Adopté esto en el design system de un producto real y me ahorró la clase de bug más frustrante de mantener, el de &quot;se ve bien en la demo y mal en su sitio real&quot;. Este artículo es por qué el cambio importa más de lo que parece.</strong></p>
<h2>La mentira del media query</h2>
<p>El problema es de nivel de abstracción. Un componente bien hecho no sabe nada del mundo exterior: recibe props, se pinta, y se supone que se adapta a donde lo coloques. Pero un media query rompe ese encapsulamiento por completo, porque consulta un estado global —el ancho del viewport— que el componente no controla ni debería conocer.</p>
<p>El resultado es que el mismo componente necesita saber en qué layout va a vivir. La tarjeta que se ve perfecta en la grid principal de tres columnas se rompe en el sidebar, no porque esté mal hecha, sino porque su breakpoint asume un ancho de ventana que no se corresponde con el ancho que realmente tiene. Acabas metiendo props como <code>variant=&quot;compact&quot;</code> para decirle a mano lo que debería poder deducir solo. Cada uno de esos props es una fuga de la abstracción.</p>
<h2>Cómo funciona: el contenedor declara, el hijo consulta</h2>
<p>El mecanismo tiene dos partes. Primero declaras que un elemento es un contenedor de consulta. Después, dentro, consultas su tamaño en vez del de la pantalla.</p>
<pre><code class="language-css">.card-wrapper {
  container-type: inline-size;
  container-name: card;
}

.card {
  display: grid;
  grid-template-columns: 1fr;
}

@container card (min-width: 400px) {
  .card {
    grid-template-columns: 120px 1fr;
  }
}
</code></pre>
<p>Lo que cambia respecto al media query es sutil y enorme a la vez: <code>@container card (min-width: 400px)</code> no pregunta &quot;¿es la pantalla ancha?&quot; sino &quot;¿tiene mi contenedor al menos 400px?&quot;. La misma tarjeta, sin una sola prop extra, se pinta en una columna dentro de un sidebar de 300px y en dos columnas dentro de una grid amplia. El componente por fin se adapta a su realidad, no a una suposición sobre la ventana.</p>
<h2>El caso que me convenció: una card en tres sitios distintos</h2>
<p>En el design system tenía una <code>ProductCard</code> que aparecía en tres contextos: la grid de catálogo (ancha), un carrusel de recomendados (medio) y un sidebar de &quot;vistos recientemente&quot; (estrecho). Con media queries tenía tres variantes y un <code>if</code> en el consumidor decidiendo cuál usar según dónde iba. Tres caminos de código para una sola tarjeta.</p>
<p>Con container queries borré las variantes. La <code>ProductCard</code> pasó a ser una sola implementación que consulta su contenedor y se reorganiza sola. El componente dejó de necesitar contexto sobre su ubicación, que es exactamente lo que un componente debería no necesitar. Menos props, menos ramas, menos superficie donde algo se puede desincronizar.</p>
<blockquote>
<p>💡 Si tu componente necesita una prop <code>variant</code> solo para caber en distintos anchos, no tienes un problema de diseño de API: tienes un media query donde debería haber una container query. Arréglalo abajo y la prop desaparece sola.</p>
</blockquote>
<h2>Trade-offs que acepté</h2>
<p>No todo es gratis. Declarar <code>container-type: inline-size</code> crea un contexto de contención de tamaño, y eso tiene una consecuencia que hay que entender: el contenedor deja de dimensionarse según su contenido en el eje consultado. En la práctica esto rara vez muerde si envuelves con un wrapper dedicado en lugar de convertir en contenedor un elemento que ya hacía otras cosas, pero es un pie de página real, no un detalle que puedas ignorar.</p>
<p>También acepté un poco más de anidamiento en el marcado. El patrón limpio es un <code>wrapper</code> que es el contenedor y un hijo que es lo que se estiliza; consultar el tamaño de un elemento y a la vez estilizar ese mismo elemento con la consulta no funciona bien. Un div más por componente es un precio que pago con gusto a cambio de borrar toda una categoría de props de contexto.</p>
<h2>Por qué esto es más que una comodidad</h2>
<p>Lo que de verdad cambió no fue el CSS, fue dónde vive la responsabilidad. Con media queries, la responsabilidad de que un componente se vea bien está repartida entre el componente y quien lo coloca: el consumidor tiene que elegir la variante correcta para el hueco. Con container queries, la responsabilidad vuelve entera al componente, que es donde pertenece.</p>
<p>Esa es la diferencia entre un design system que escala y uno que se convierte en un catálogo de casos especiales. Cada vez que un componente necesita saber dónde va para verse bien, has creado un acoplamiento entre la pieza y su contexto. Las container queries cortan ese acoplamiento, y con él, la clase de deuda que no aparece en ningún ticket pero te ralentiza en cada pantalla nueva.</p>
]]></content:encoded>
    </item>
    <item>
      <title>EventBridge: desacoplar servicios sin montar un monstruo de colas</title>
      <link>https://yohangel.com/blog/eventbridge-desacoplar-servicios/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/eventbridge-desacoplar-servicios/</guid>
      <description>Cuándo un event bus le gana a llamar servicios directamente o a encadenar colas SQS, y los trade-offs que acepté al adoptarlo.</description>
      <pubDate>Tue, 07 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>AWS</category>
      <content:encoded><![CDATA[<p><strong>Durante mucho tiempo mi forma por defecto de conectar dos servicios fue la más obvia: el servicio A llama al servicio B. Un POST, una Lambda invocando a otra, una escritura que dispara un webhook. Funciona hasta que tienes cinco consumidores del mismo evento y cada nuevo consumidor te obliga a tocar el productor. Ahí es donde EventBridge cambió mi arquitectura: el productor deja de saber quién lo escucha. Emite &quot;pasó esto&quot; a un bus y se olvida. Los consumidores se suscriben por su cuenta con reglas de patrón. Elegí este modelo en varios servicios de AWS porque el acoplamiento entre equipos me estaba costando más que la latencia extra; a cambio, acepté que depurar un sistema event-driven es más difícil que seguir una llamada síncrona. Este artículo es cuándo vale la pena y cuándo no.</strong></p>
<h2>El problema que resuelve: el productor no debería conocer a sus consumidores</h2>
<p>Imagina un evento de dominio simple: <code>PedidoConfirmado</code>. Cuando se confirma un pedido, hay que facturar, notificar al cliente, reservar inventario y actualizar analítica. La versión ingenua es que el servicio de pedidos llame a los cuatro. El día que marketing quiere enterarse también, alguien tiene que abrir el código de pedidos, añadir la quinta llamada, testearla y desplegarla. El productor acumula responsabilidades que no son suyas.</p>
<p>Con un bus de eventos, el servicio de pedidos emite un único evento y termina. Cada consumidor decide si le interesa. Añadir marketing es crear una regla nueva que apunta a su target, sin tocar pedidos. El acoplamiento pasa de &quot;código a código&quot; a &quot;esquema de evento a esquema de evento&quot;, que es mucho más barato de mantener.</p>
<h2>El patrón de reglas: filtrar en el bus, no en el consumidor</h2>
<p>Lo que más me gustó de EventBridge frente a un SNS clásico es que el filtrado vive en la regla, declarativo, sobre el contenido del evento. No entrego el evento a una Lambda para que ella decida si le importa: el bus solo entrega lo que hace match.</p>
<pre><code class="language-json">{
  &quot;source&quot;: [&quot;pedidos.service&quot;],
  &quot;detail-type&quot;: [&quot;PedidoConfirmado&quot;],
  &quot;detail&quot;: {
    &quot;total&quot;: [{ &quot;numeric&quot;: [&quot;&gt;&quot;, 500] }],
    &quot;pais&quot;: [&quot;ES&quot;, &quot;MX&quot;, &quot;CO&quot;]
  }
}
</code></pre>
<p>Esa regla solo dispara para pedidos confirmados de más de 500 en tres países. El consumidor no ejecuta ni una vez para el resto. Antes ese <code>if</code> vivía dentro de la función, invocada millones de veces para descartar la mayoría; ahora el descarte es gratis y no aparece en mi factura de Lambda ni en mis logs.</p>
<h2>Dónde EventBridge NO es la respuesta</h2>
<p>Ser honesto con los trade-offs es lo que separa una decisión de arquitectura de un acto de fe. EventBridge no es gratis en complejidad.</p>
<p>Si necesitas la respuesta del consumidor, no uses un bus. Es fire-and-forget: emites y no sabes qué pasó después salvo que montes otro evento de vuelta. Para un flujo request/response —el usuario espera un resultado en pantalla— una llamada síncrona directa sigue siendo lo correcto. Meter un event bus ahí solo añade latencia y una máquina de estados que nadie pidió.</p>
<p>Tampoco lo uso cuando el orden estricto es un requisito duro. EventBridge no garantiza orden, y puede entregar el mismo evento más de una vez. Si tu caso necesita procesar cosas en secuencia exacta, una cola FIFO de SQS encaja mejor. De hecho, un patrón que me funciona es EventBridge para el fan-out y SQS como buffer delante de cada consumidor lento, combinando lo mejor de ambos.</p>
<blockquote>
<p>💡 La pregunta que decide todo: ¿el productor necesita saber qué le pasó al consumidor? Si la respuesta es sí, no tienes un evento, tienes una llamada. No lo disfraces de event-driven.</p>
</blockquote>
<h2>La entrega &quot;al menos una vez&quot; te obliga a ser idempotente</h2>
<p>Este es el detalle que la gente descubre en producción y no en el diseño. EventBridge garantiza entrega <em>at-least-once</em>, lo que significa que tu consumidor puede recibir <code>PedidoConfirmado</code> dos veces por el mismo pedido. Si facturas en cada recepción, acabas de cobrar dos veces.</p>
<p>La solución no es rezar para que no pase: es diseñar el consumidor para que procesar el mismo evento dos veces dé el mismo resultado que procesarlo una. Una clave de idempotencia por evento, una tabla que registra &quot;ya procesé este ID&quot;, y un <code>INSERT ... ON CONFLICT DO NOTHING</code> en Postgres antes de hacer el trabajo con efectos.</p>
<pre><code class="language-typescript">async function handle(event: PedidoConfirmado) {
  const inserted = await db.processedEvents.createIfAbsent(event.id);
  if (!inserted) return; // ya lo procesamos, salimos limpio
  await facturar(event);
}
</code></pre>
<p>No es código elegante, es código que sobrevive a la realidad. Cualquier arquitectura event-driven que no trate la idempotencia como requisito de primera clase está construyendo sobre arena.</p>
<h2>Observabilidad: el precio real que pagué</h2>
<p>Lo más caro de adoptar EventBridge no fue el servicio, fue la observabilidad. En una llamada síncrona sigues el stack trace y ves todo el camino. En un bus, el productor emitió y se fue; el consumidor falló tres saltos después; y correlacionar ambos requiere que hayas propagado un <code>correlationId</code> en el <code>detail</code> de cada evento desde el día uno.</p>
<p>Lo aprendí tarde y lo pagué en incidentes que costaron horas de más solo por no poder unir la causa con el efecto. Ahora todo evento lleva su ID de correlación, todos los consumidores lo loguean, y una DLQ por regla captura lo que falla para que ningún evento se pierda en silencio. Con eso, el desacoplamiento sale a cuenta. Sin eso, cambias un problema de acoplamiento por uno de depuración a ciegas, y no está claro que ganes.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Server Actions en Next.js: dónde brillan y dónde me niego a usarlas</title>
      <link>https://yohangel.com/blog/nextjs-server-actions-limites/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/nextjs-server-actions-limites/</guid>
      <description>Las Server Actions eliminan boilerplate de API, pero no son un reemplazo universal de los endpoints. Mi criterio para decidir.</description>
      <pubDate>Tue, 07 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>Frontend</category>
      <content:encoded><![CDATA[<p><strong>Las Server Actions de Next.js son la mejor y la peor idea del framework, según dónde las pongas. Para mutaciones de formulario — crear, editar, borrar desde la UI — eliminan una capa entera de boilerplate: no defines endpoint, no serializas a mano, no gestionas fetch ni estados de carga con useEffect. Pero he visto (y he tenido que deshacer) proyectos donde se convirtieron en la única forma de hablar con el servidor, y ahí el patrón se rompe: lógica de negocio atrapada en el framework, imposible de consumir desde otro cliente, y un grafo de invalidaciones que nadie entiende. Mi criterio después de usarlas en producción: las Server Actions son la capa de interacción de la UI, no la API de tu producto. Este artículo es la línea que trazo y por qué.</strong></p>
<h2>Donde brillan: la mutación de formulario de toda la vida</h2>
<p>El caso feliz es un formulario que muta datos y revalida la vista. Con action, validación con Zod compartido y estado pendiente del propio React:</p>
<pre><code class="language-typescript">// app/proyectos/actions.ts
&#39;use server&#39;;

import { revalidatePath } from &#39;next/cache&#39;;
import { proyectoSchema } from &#39;@/schemas/proyecto&#39;;

export async function crearProyecto(formData: FormData) {
  const session = await getSession();
  if (!session) throw new Error(&#39;No autorizado&#39;);

  const parsed = proyectoSchema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }

  await prisma.proyecto.create({
    data: { ...parsed.data, ownerId: session.userId },
  });

  revalidatePath(&#39;/proyectos&#39;);
  return { ok: true };
}
</code></pre>
<p>Cero endpoints, cero cliente HTTP, el tipo de retorno viaja inferido hasta el componente. Para el 80% de las mutaciones de un panel interno, esto es todo lo que necesitas, y volver a escribir route handlers para esos casos es nostalgia, no ingeniería.</p>
<h2>La línea roja: lógica de negocio que vive en la action</h2>
<p>El problema empieza cuando la action deja de ser un adaptador y se convierte en el hogar de la lógica. Una action que calcula precios, orquesta tres servicios y decide reglas de negocio es código que solo puede invocarse desde un componente React de ese proyecto Next.js. El día que llega la app móvil, el cron job o el equipo que quiere un endpoint público, esa lógica hay que exhumarla.</p>
<p>Mi regla estructural: la action llama a un servicio, y el servicio no sabe que Next.js existe.</p>
<pre><code class="language-typescript">// services/proyectos.ts — framework-agnostic, testeable en aislamiento
export async function crearProyectoParaUsuario(
  input: ProyectoInput,
  userId: string
) {
  // toda la lógica de negocio vive aquí
}
</code></pre>
<p>La action queda en tres líneas: autenticar, parsear, delegar. Si mañana necesito exponer lo mismo por route handler o por un worker de SQS, el servicio ya está listo. Elegí esta separación aun sabiendo que duplica un poco de ceremonia; a cambio, ninguna decisión de negocio queda rehén del framework.</p>
<blockquote>
<p>💡 Una Server Action es un mecanismo de transporte con azúcar sintáctico, no una capa de arquitectura. Si borras Next.js de tu proyecto y con él se va lógica de negocio, tenías la lógica en el sitio equivocado.</p>
</blockquote>
<h2>Los límites operativos que nadie te cuenta hasta producción</h2>
<p><strong>Son POST secuenciales.</strong> Las Server Actions se ejecutan en serie por defecto desde el mismo cliente: si el usuario dispara tres, se encolan. Para un formulario da igual; para interacciones de alta frecuencia (autoguardado, drag and drop de un tablero) es un cuello de botella que descubres tarde.</p>
<p><strong>No son cancelables ni cacheables.</strong> Un GET a un route handler puede cachearse en CDN y abortarse con AbortController. Una action, no. Todo lo que sea lectura — búsqueda, autocompletar, filtros — no tiene nada que hacer en una action: eso es un route handler o directamente un Server Component que lee de la base de datos.</p>
<p><strong>El error handling es opaco.</strong> Un throw en una action llega al cliente como un error genérico en producción (por diseño, para no filtrar internals). Eso obliga a modelar los errores esperables como valores de retorno — el <code>{ error }</code> del ejemplo — y reservar el throw para lo verdaderamente excepcional. Es más Result que Exception, y conviene decidirlo desde el día uno para no mezclar estilos.</p>
<h2>El criterio en una frase</h2>
<p>Mutación iniciada por un usuario desde la UI de ese mismo proyecto: Server Action que delega en un servicio. Lecturas, APIs consumidas por terceros, webhooks, alta frecuencia, cualquier cosa que un día pueda necesitar otro cliente: route handler o backend propio. Con esa línea trazada, las Server Actions son una herramienta excelente — pequeña, aburrida y en su sitio, que es exactamente como me gustan las herramientas.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Observabilidad en serverless: logs estructurados o depurar a ciegas</title>
      <link>https://yohangel.com/blog/observabilidad-serverless-logs/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/observabilidad-serverless-logs/</guid>
      <description>En Lambda no hay servidor al que hacer SSH. Cómo estructuro logs, correlaciono requests y encuentro fallos sin volverme loco.</description>
      <pubDate>Tue, 07 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>AWS</category>
      <content:encoded><![CDATA[<p><strong>En un servidor tradicional, cuando algo falla, siempre te queda el último recurso: SSH, <code>tail -f</code>, y a leer. En serverless ese recurso no existe. Una request puede atravesar API Gateway, una Lambda, una cola SQS y otra Lambda, y si tus logs son strings sueltos escritos con <code>console.log</code>, reconstruir qué pasó es arqueología. Aprendí esto de la peor manera: un bug intermitente en producción que tardé días en localizar porque no podía seguir una request de punta a punta. Este artículo es el sistema de logging que uso desde entonces: logs estructurados, un ID de correlación que viaja con la request, y consultas en CloudWatch que responden preguntas en minutos.</strong></p>
<h2>Logs como datos, no como prosa</h2>
<p>El cambio fundamental es dejar de escribir logs para humanos y empezar a escribirlos para máquinas. Un log de prosa (<code>&quot;Error procesando pedido 123 del usuario 456&quot;</code>) es agradable de leer pero imposible de consultar en masa. Un log estructurado es un objeto JSON con campos consistentes:</p>
<pre><code class="language-typescript">logger.error(&#39;order_processing_failed&#39;, {
  orderId: order.id,
  userId: user.id,
  errorCode: err.code,
  correlationId: ctx.correlationId,
  durationMs: Date.now() - start,
});
</code></pre>
<p>CloudWatch Logs Insights entiende JSON de forma nativa. Con logs estructurados, &quot;¿cuántos pedidos fallaron esta semana con este código de error, y de qué usuarios?&quot; deja de ser un grep heroico y pasa a ser una consulta:</p>
<pre><code class="language-sql">fields @timestamp, orderId, userId
| filter level = &#39;error&#39; and errorCode = &#39;PAYMENT_TIMEOUT&#39;
| stats count(*) by userId
| sort count(*) desc
</code></pre>
<p>No uso un logger casero: AWS Lambda Powertools para TypeScript ya resuelve la serialización, los niveles, y añade automáticamente el contexto de la invocación (request ID, nombre de función, cold start). Reinventar eso es tiempo perdido.</p>
<h2>El ID de correlación: el hilo que une el sistema</h2>
<p>En una arquitectura orientada a eventos, el problema no es loguear: es correlacionar. La request del usuario dispara una Lambda que encola un mensaje en SQS que procesa otra Lambda que escribe en DynamoDB. Cuatro grupos de logs distintos, cuatro request IDs distintos, ninguna relación visible entre ellos.</p>
<p>Mi regla: el primer punto de entrada genera un <code>correlationId</code> (o adopta el que venga en el header <code>X-Correlation-Id</code>), y ese ID viaja con todo. En los mensajes SQS va dentro de los message attributes; en las llamadas entre servicios, en un header; en cada log, como campo obligatorio. Powertools permite inyectarlo una vez en el logger y olvidarte: todo log de esa invocación lo lleva.</p>
<p>Con eso, reconstruir la historia completa de una request es una sola consulta en Logs Insights cruzando todos los log groups implicados. Lo que antes era una tarde de arqueología ahora son treinta segundos.</p>
<blockquote>
<p>💡 La observabilidad no se añade cuando hay un incidente: se diseña antes. El día que algo se rompe en producción, tus logs ya son los que son. Cada campo que no logueaste es una pregunta que no puedes responder.</p>
</blockquote>
<h2>Métricas sin martillo: EMF</h2>
<p>Para métricas de negocio (pedidos procesados, matchings generados, colas que crecen) no uso llamadas a la API de CloudWatch, que añaden latencia y cuestan dinero por request. Uso Embedded Metric Format: escribes la métrica como parte del log JSON con un formato especial, y CloudWatch la extrae de forma asíncrona. Coste marginal cero en el camino caliente, y las métricas aparecen en dashboards y alarmas como cualquier otra.</p>
<pre><code class="language-typescript">metrics.addMetric(&#39;MatchingCompleted&#39;, MetricUnit.Count, 1);
metrics.addMetric(&#39;MatchingDurationMs&#39;, MetricUnit.Milliseconds, elapsed);
</code></pre>
<p>La disciplina que me impongo: cada Lambda emite al menos una métrica de éxito y una de fallo con nombre de negocio, no técnico. &quot;Errors&quot; no me dice nada a las 3 de la mañana; &quot;PaymentTimeouts&quot; sí.</p>
<h2>Lo que decidí no hacer</h2>
<p>No monté X-Ray en todo. Lo probé, y para mi escala el tracing distribuido completo era más ruido que señal: la mayoría de mis flujos tienen dos o tres saltos, y el correlation ID en logs estructurados los cubre de sobra. X-Ray lo reservo para los flujos con más saltos o cuando sospecho de latencias entre servicios que los logs no explican. Es un trade-off honesto: menos visibilidad automática a cambio de menos coste y menos configuración que mantener.</p>
<p>Tampoco centralizo logs en una herramienta externa. CloudWatch Logs Insights tiene una UX regular y consultas que se pagan por GB escaneado, pero añadir un tercero significa otro pipeline de envío, otro contrato, y otra factura. Mientras el volumen lo permita, prefiero el dolor conocido. El día que las consultas se vuelvan lentas o caras de verdad, esa decisión se revisa con datos: GB escaneados al mes y minutos perdidos por incidente.</p>
<h2>El resultado</h2>
<p>Nada de esto es glamuroso. Son tres hábitos: JSON en vez de prosa, un ID que viaja con la request, y métricas de negocio embebidas en los logs. Pero la diferencia operativa es enorme: los incidentes pasaron de &quot;a ver si consigo reproducirlo&quot; a &quot;dame el correlation ID y te digo qué pasó&quot;. En serverless no puedes hacer SSH al servidor, así que más te vale que el sistema cuente su propia historia.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Rerankers: la pieza que le faltaba a mi búsqueda semántica</title>
      <link>https://yohangel.com/blog/rerankers-busqueda-semantica/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/rerankers-busqueda-semantica/</guid>
      <description>Los embeddings recuperan candidatos razonables; el reranker decide cuáles merecen el top. Así los combino sin duplicar latencia.</description>
      <pubDate>Tue, 07 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>IA</category>
      <content:encoded><![CDATA[<p><strong>Durante meses traté la búsqueda por embeddings como si fuera el sistema completo: query a vector, cosine similarity contra pgvector, top 10 y a producción. Funcionaba... hasta que mirabas el orden de los resultados con lupa. El candidato correcto casi siempre estaba ahí, pero en la posición 4, o en la 7, rara vez en la 1. La lección que me costó aceptar: los embeddings son un filtro grueso excelente y un ordenador fino mediocre. El reranker es la pieza que separa &quot;los resultados relevantes están en la página&quot; de &quot;el mejor resultado es el primero&quot;. Este artículo es cómo lo integré en el motor de matching de JXBS sin destrozar la latencia.</strong></p>
<h2>Por qué el bi-encoder se queda corto</h2>
<p>Un embedding comprime todo un documento en un vector fijo antes de saber qué le vas a preguntar. Esa es su gran ventaja (puedes precalcularlo e indexarlo) y su límite estructural: la compresión pierde los matices que solo importan frente a una query concreta. Dos perfiles de candidato pueden estar igual de &quot;cerca&quot; de una vacante en el espacio vectorial por razones distintas, y el coseno no distingue si la cercanía viene de lo esencial o de lo accesorio.</p>
<p>Un reranker es un cross-encoder: recibe la query y el documento juntos, y produce un score de relevancia mirando la interacción entre ambos. Es mucho más preciso ordenando, y mucho más caro: no puedes precalcular nada, cada par query-documento es una inferencia. Por eso nadie rankea un corpus entero con un cross-encoder. El patrón es recuperar barato y reordenar caro sobre pocos candidatos.</p>
<h2>La arquitectura: recuperar 50, reordenar a 10</h2>
<p>Mi pipeline quedó en dos etapas. La primera es la que ya tenía: pgvector recupera los 50 vecinos más cercanos. La segunda pasa esos 50 por el reranker y se queda con los 10 mejores según el nuevo score.</p>
<pre><code class="language-sql">-- Etapa 1: recuperación barata, sobre-recuperando a propósito
SELECT id, titulo, contenido
FROM documentos
ORDER BY embedding &lt;=&gt; $1
LIMIT 50;
</code></pre>
<pre><code class="language-typescript">// Etapa 2: reranking sobre los candidatos
const scored = await rerank({
  query: userQuery,
  documents: candidates.map((c) =&gt; c.contenido),
});

const top = scored
  .sort((a, b) =&gt; b.relevanceScore - a.relevanceScore)
  .slice(0, 10);
</code></pre>
<p>El número 50 no es sagrado. Es el equilibrio que encontré entre dos fallos: si recuperas pocos, el documento correcto ni siquiera llega al reranker y no hay nada que reordenar; si recuperas demasiados, pagas latencia e inferencia por candidatos que no tenían ninguna opción. Elegí 50 porque en mis pruebas el resultado correcto estaba dentro del top 50 del bi-encoder prácticamente siempre; a cambio, acepto que si la recuperación falla de raíz, el reranker no la rescata. Garbage in, garbage out, pero mejor ordenado.</p>
<h2>Latencia: el precio real y cómo lo contuve</h2>
<p>El reranking añade una llamada de red y una inferencia por request. En mi caso eso significó pasar de ~80ms a ~350ms en p50. Para una búsqueda interactiva era demasiado, y lo contuve con tres decisiones:</p>
<p>La primera, rerankear solo cuando importa. Si el score del primer candidato del bi-encoder saca mucha ventaja al segundo, el orden ya está claro y me salto la segunda etapa. La segunda, truncar documentos: el reranker no necesita las 3.000 palabras del documento, con el título y el primer fragmento relevante le basta para discriminar, y el coste crece con los tokens. La tercera, cachear pares query-documento normalizados; en un producto real las queries se repiten mucho más de lo que uno cree.</p>
<blockquote>
<p>💡 El reranker no mejora tu recuperación: mejora tu <em>precisión en el top</em>. Si tu problema es que los buenos resultados no aparecen ni en el top 50, tu problema está en los embeddings o en el chunking, y ningún reranker te va a salvar.</p>
</blockquote>
<h2>Cómo medí que valía la pena</h2>
<p>Antes de integrar nada monté un set de evaluación pequeño: ~100 queries reales con el resultado que un humano consideraba correcto marcado a mano. La métrica que me importaba era MRR (mean reciprocal rank): si el resultado correcto está primero vale 1, si está segundo 0.5, y así. Con solo bi-encoder tenía un MRR mediocre; con reranker subió de forma clara y consistente. No doy las cifras exactas porque dependen brutalmente del dominio y del dataset, pero el patrón se repite en casi cualquier corpus: el bi-encoder encuentra, el cross-encoder ordena.</p>
<p>Lo que sí es generalizable es el método: no integres un reranker porque está de moda. Monta primero las 100 queries con juicio humano, mide tu baseline, y que la decisión la tome el número. En mi caso el número fue contundente y la latencia extra estaba pagada.</p>
<h2>Trade-offs que acepté</h2>
<p>Elegí un reranker gestionado vía API en lugar de servir un cross-encoder propio, porque a mi escala el coste por request era menor que el coste fijo de mantener una GPU caliente. A cambio, acepté una dependencia externa en el camino crítico de la búsqueda, mitigada con un fallback: si el reranker no responde en 500ms, devuelvo el orden del bi-encoder tal cual. Búsqueda algo peor es infinitamente mejor que búsqueda caída.</p>
<p>También acepté que el sistema es más difícil de razonar: ahora hay dos modelos que pueden degradarse por separado. Por eso las evals corren contra el pipeline completo, no contra cada pieza aislada. Lo que le llega al usuario es el resultado de la cadena entera, y eso es lo único que tiene sentido medir.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Subidas de archivos con presigned URLs: que S3 haga el trabajo pesado</title>
      <link>https://yohangel.com/blog/s3-presigned-urls-subidas/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/s3-presigned-urls-subidas/</guid>
      <description>Por qué dejé de pasar archivos por mi backend y cómo diseñar el flujo de presigned URLs sin agujeros de seguridad.</description>
      <pubDate>Tue, 07 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>AWS</category>
      <content:encoded><![CDATA[<p><strong>Pasar un archivo por tu backend para subirlo a S3 es pagar dos veces el mismo peaje. El archivo viaja del navegador a tu API, consume memoria y ancho de banda de tu servidor, y luego viaja otra vez de tu API a S3. En una Lambda, además, te comes el límite de payload; en Fargate, ocupas un contenedor haciendo de tubería. La alternativa que uso desde hace años: el backend firma una URL temporal con permisos quirúrgicos, el navegador sube directo a S3, y tu API solo procesa metadatos. Elegí este patrón en los paneles que construyo para clientes porque escala sin tocar infraestructura; a cambio, el flujo tiene más pasos y hay que diseñar la seguridad con cuidado, porque una presigned URL mal acotada es una puerta abierta. Este artículo es el flujo completo.</strong></p>
<h2>El flujo en tres pasos</h2>
<p>El patrón tiene tres actores: el navegador, tu API y S3. La API nunca ve el archivo.</p>
<pre><code class="language-typescript">// 1. El backend genera la URL firmada (Lambda o endpoint normal)
import { S3Client, PutObjectCommand } from &#39;@aws-sdk/client-s3&#39;;
import { getSignedUrl } from &#39;@aws-sdk/s3-request-presigner&#39;;

const s3 = new S3Client({});

export async function crearUrlDeSubida(userId: string, contentType: string) {
  if (!TIPOS_PERMITIDOS.has(contentType)) {
    throw new Error(&#39;Tipo de archivo no permitido&#39;);
  }

  const key = `uploads/${userId}/${crypto.randomUUID()}`;

  const url = await getSignedUrl(
    s3,
    new PutObjectCommand({
      Bucket: process.env.UPLOADS_BUCKET,
      Key: key,
      ContentType: contentType,
      ContentLength: undefined, // se limita con condiciones en POST policy si hace falta
    }),
    { expiresIn: 300 } // 5 minutos, ni uno más
  );

  return { url, key };
}
</code></pre>
<p>El navegador hace un <code>PUT</code> directo a esa URL con el archivo como body. Cuando termina, notifica a tu API con la <code>key</code>, y ahí registras el archivo en PostgreSQL vía Prisma, disparas el procesamiento asíncrono, o lo que toque.</p>
<h2>Las tres decisiones de seguridad que importan</h2>
<p><strong>La key la genera el servidor, siempre.</strong> Si dejas que el cliente elija el nombre del archivo, estás dejando que elija dónde escribe en tu bucket. La key incluye el <code>userId</code> autenticado y un UUID: el cliente no aporta ni un carácter.</p>
<p><strong>Expiración corta y content-type fijado.</strong> Cinco minutos es suficiente para iniciar cualquier subida. Y firmar el <code>ContentType</code> en la URL obliga a que lo que se sube sea lo que se declaró — no evita que alguien renombre un ejecutable a <code>.jpg</code>, pero cierra la vía fácil.</p>
<p><strong>El bucket de subidas no es el bucket final.</strong> Todo cae en un bucket de staging con una regla de ciclo de vida que borra objetos a las 24 horas. Un proceso posterior — en mi caso una Lambda disparada por el evento <code>s3:ObjectCreated</code> — valida el archivo de verdad (magic bytes, tamaño, escaneo si aplica) y lo mueve al bucket definitivo. Lo que nadie valida, se autodestruye solo.</p>
<blockquote>
<p>💡 Una presigned URL no es &quot;acceso a S3&quot;: es UNA operación, sobre UNA key, durante UN intervalo. Si tu URL firmada permite más que eso, no has entendido el patrón — has abierto un agujero con extra de pasos.</p>
</blockquote>
<h2>El evento de confirmación: no te fíes del cliente</h2>
<p>El error clásico de este patrón es marcar el archivo como &quot;subido&quot; cuando el navegador dice que terminó. El navegador miente: se cierra la pestaña, falla la red, el usuario cancela. La fuente de verdad es S3, no el cliente.</p>
<p>Por eso el registro en base de datos tiene dos fases: la API crea la fila en estado <code>pendiente</code> al firmar la URL, y la Lambda del evento <code>ObjectCreated</code> la pasa a <code>disponible</code> cuando el objeto existe de verdad. Las filas que se quedan en <code>pendiente</code> más de una hora las limpia un job. El cliente puede notificar para acelerar la UX, pero su notificación es una optimización, nunca la verdad.</p>
<h2>Cuándo no usar este patrón</h2>
<p>Como siempre, hay trade-offs. Si los archivos son pequeños (JSON, avatares de pocos KB) y el volumen es bajo, pasar por el backend es más simple y la simplicidad gana. Si necesitas transformar el archivo en línea antes de guardarlo (redimensionar, transcodificar de forma síncrona), el backend tiene que verlo de todos modos. Y si tu frontend es una PWA offline-first, la subida directa complica la cola de sincronización: ahí a veces prefiero encolar el archivo localmente y subirlo desde un service worker cuando hay red, lo que cambia el diseño entero.</p>
<p>Para todo lo demás — documentos, imágenes, cualquier cosa que pese megabytes y llegue con concurrencia — dejar que S3 haga el trabajo pesado es de las pocas decisiones de arquitectura que no he lamentado nunca.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Tool calling en producción: el LLM no ejecuta, propone</title>
      <link>https://yohangel.com/blog/tool-calling-agentes-produccion/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/tool-calling-agentes-produccion/</guid>
      <description>El modelo mental que evita la mitad de los bugs de un agente con herramientas: el LLM sugiere llamadas, tu código decide si se ejecutan.</description>
      <pubDate>Tue, 07 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>IA</category>
      <content:encoded><![CDATA[<p><strong>El error más caro que cometí al meter tool calling en un producto fue mental, no de código: pensaba que el LLM ejecutaba las herramientas. No las ejecuta. El modelo devuelve un objeto JSON que dice &quot;me gustaría llamar a <code>buscarCandidatos</code> con estos argumentos&quot;, y ahí termina su trabajo. Quien decide si esa llamada se ejecuta, con qué permisos y qué pasa con el resultado, es tu código. Interiorizar esa frontera —el LLM propone, tu backend dispone— cambió por completo cómo diseño agentes. Dejé de tratar al modelo como un ejecutor autónomo y empecé a tratarlo como un planificador que emite intenciones que yo valido. Este artículo es ese modelo mental y las decisiones de producción que se derivan de él.</strong></p>
<h2>La frontera que lo cambia todo</h2>
<p>Cuando le das herramientas a un modelo, el ciclo real es: le pasas la petición del usuario y el esquema de las herramientas disponibles; el modelo responde o bien con texto, o bien con una o más <em>tool calls</em> (nombre + argumentos en JSON); tu código ejecuta las que decida ejecutar; le devuelves el resultado al modelo; y el modelo continúa. El LLM nunca toca tu base de datos ni tu API. Es un generador de intenciones estructuradas.</p>
<p>Esa frontera es la mejor noticia de la arquitectura, porque significa que todo el control de seguridad vive en tu lado. El modelo puede <em>pedir</em> borrar un registro; que se borre o no depende de un <code>if</code> tuyo. Tratar la tool call como una solicitud sujeta a autorización, y no como una orden, es lo que separa un agente que puedes poner en producción de una demo que da miedo.</p>
<h2>Los esquemas son tu contrato, y el modelo los lee</h2>
<p>La calidad de un agente con herramientas depende brutalmente de lo bien descritas que estén las herramientas. El modelo elige qué llamar basándose en el nombre, la descripción y el esquema de parámetros. Descripciones vagas producen llamadas erróneas; descripciones precisas producen llamadas correctas.</p>
<pre><code class="language-typescript">const tools = [{
  name: &#39;buscar_candidatos&#39;,
  description: &#39;Busca candidatos por habilidades y seniority. &#39; +
    &#39;Úsala solo cuando el usuario pida perfiles concretos, &#39; +
    &#39;no para preguntas generales sobre el mercado.&#39;,
  input_schema: {
    type: &#39;object&#39;,
    properties: {
      skills: { type: &#39;array&#39;, items: { type: &#39;string&#39; } },
      seniority: { type: &#39;string&#39;, enum: [&#39;junior&#39;, &#39;mid&#39;, &#39;senior&#39;] },
    },
    required: [&#39;skills&#39;],
  },
}];
</code></pre>
<p>Ese <code>enum</code> no es decoración: restringe el espacio de salida y hace mucho menos probable que el modelo invente un valor que tu backend no sabe manejar. Cada restricción que pones en el esquema es una clase de bug que eliminas antes de que ocurra. He aprendido a invertir en esquemas estrictos con la misma seriedad con la que diseño una API pública, porque para el modelo eso es exactamente lo que son.</p>
<h2>Validar la salida como si viniera de un cliente hostil</h2>
<p>Aquí está el punto que más gente se salta. El modelo genera los argumentos como texto, y aunque le des un esquema, no hay garantía dura de que respete tus invariantes de negocio. Puede pasarte un <code>seniority</code> válido según el enum pero un array de <code>skills</code> vacío, o una fecha con formato correcto pero en el pasado.</p>
<p>Por eso valido cada tool call con Zod antes de ejecutarla, con la misma desconfianza que aplicaría a un input de usuario. La tool call es un input de usuario, solo que generado por un modelo. Si no valida, no ejecuto: devuelvo el error al modelo como resultado de la herramienta y dejo que reintente con argumentos corregidos. Ese bucle de &quot;fallaste la validación, aquí está por qué, prueba otra vez&quot; es sorprendentemente eficaz y mantiene el sistema seguro sin intervención humana.</p>
<blockquote>
<p>💡 Una tool call es una petición no confiable que resulta venir de un LLM en vez de un navegador. Valídala, autorízala y regístrala exactamente igual que cualquier entrada externa. El modelo no es parte de tu perímetro de confianza.</p>
</blockquote>
<h2>Efectos secundarios: separa leer de escribir</h2>
<p>No todas las herramientas son iguales, y tratarlas como si lo fueran es pedir problemas. Distingo dos categorías con reglas distintas. Las de solo lectura —buscar, consultar, calcular— las ejecuto sin fricción: si el modelo se equivoca de query, el coste es una respuesta pobre, nada irreversible. Las de escritura —crear, actualizar, borrar, enviar— pasan por un filtro mucho más estricto.</p>
<p>Para las de escritura con impacto real, el patrón que me funciona es no darle al modelo la herramienta destructiva directamente, sino una que <em>propone</em> la acción y la deja pendiente de una confirmación explícita. El modelo puede redactar el email; enviarlo requiere un paso que mi código controla, muchas veces con un humano en el lazo. Elegí esta cautela a cambio de agentes menos &quot;mágicos&quot;, y volvería a elegirla: un agente que puede mandar mensajes sin supervisión es un incidente esperando su turno.</p>
<h2>Por qué este modelo mental escala y el otro no</h2>
<p>Cuando piensas que el LLM ejecuta, cada nueva herramienta te da miedo, porque estás ampliando lo que un sistema no determinista puede hacer solo. Cuando entiendes que el LLM solo propone, añadir herramientas es barato: cada una es una intención más que tu código sabe validar, autorizar y ejecutar bajo tus reglas.</p>
<p>El agente deja de ser una caja negra a la que le rezas y pasa a ser un planificador cuyas propuestas atraviesan una capa de control que tú escribiste y entiendes. Toda la creatividad del modelo, ninguna de su capacidad de hacer daño sin permiso. Esa división de responsabilidades no es un detalle de implementación: es la diferencia entre un agente que puedes defender en una revisión de seguridad y uno que no.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Un esquema Zod para gobernarlos a todos: validación compartida cliente/servidor</title>
      <link>https://yohangel.com/blog/zod-validacion-compartida/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/zod-validacion-compartida/</guid>
      <description>Duplicar validaciones entre el formulario y la API es una fuente de bugs silenciosos. Así comparto un único esquema entre ambos mundos.</description>
      <pubDate>Tue, 07 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>Frontend</category>
      <content:encoded><![CDATA[<p><strong>Todo formulario serio valida dos veces: en el cliente, para dar feedback inmediato, y en el servidor, porque el cliente es territorio hostil y nunca se confía en él. El problema es cuando esas dos validaciones son dos implementaciones distintas. En un panel que construí para un cliente, el frontend aceptaba un teléfono con espacios y el backend lo rechazaba: el usuario veía el campo en verde y el submit fallaba con un error críptico. No era un bug de código, era un bug de duplicación. Desde entonces sigo una regla: el esquema de validación se escribe UNA vez, en Zod, en un paquete compartido, y tanto el formulario como la API lo importan. Este artículo es ese patrón.</strong></p>
<h2>El problema real: dos fuentes de verdad que divergen</h2>
<p>La validación duplicada no falla el día que la escribes: falla seis meses después, cuando alguien cambia una regla en el backend (mínimo de caracteres, un formato nuevo de código postal) y nadie toca el frontend. A partir de ahí tienes dos comportamientos: lo que el formulario dice que es válido y lo que la API acepta de verdad. El usuario sufre la diferencia.</p>
<p>La solución no es disciplina (&quot;acordaos de cambiar los dos sitios&quot;), porque la disciplina no escala. La solución es estructural: que solo exista un sitio.</p>
<h2>El esquema como contrato compartido</h2>
<p>En un monorepo, el esquema vive en un paquete propio, sin dependencias de React ni de Node:</p>
<pre><code class="language-typescript">// packages/schemas/src/registro.ts
import { z } from &#39;zod&#39;;

export const registroSchema = z.object({
  email: z.string().email(&#39;Email inválido&#39;),
  telefono: z
    .string()
    .transform((v) =&gt; v.replace(/\s+/g, &#39;&#39;))
    .pipe(z.string().regex(/^\+?\d{9,15}$/, &#39;Teléfono inválido&#39;)),
  password: z.string().min(12, &#39;Mínimo 12 caracteres&#39;),
});

export type RegistroInput = z.input&lt;typeof registroSchema&gt;;
export type RegistroOutput = z.output&lt;typeof registroSchema&gt;;
</code></pre>
<p>Dos detalles importan aquí. El primero, el <code>transform</code> + <code>pipe</code>: la normalización (quitar espacios del teléfono) es parte del esquema, así el &quot;teléfono con espacios&quot; es válido en los dos mundos y se normaliza igual en los dos mundos. El segundo, exportar <code>z.input</code> y <code>z.output</code> como tipos separados: lo que entra (con espacios) y lo que sale (normalizado) son tipos distintos, y TypeScript te obliga a no confundirlos.</p>
<h2>En el cliente: el mismo esquema alimenta el formulario</h2>
<p>Con react-hook-form y su resolver de Zod, el formulario valida contra el esquema compartido sin escribir ni una regla más:</p>
<pre><code class="language-typescript">const form = useForm&lt;RegistroInput&gt;({
  resolver: zodResolver(registroSchema),
});
</code></pre>
<p>Los mensajes de error que definiste en el esquema aparecen bajo cada campo. Si mañana el mínimo de la contraseña sube a 14, cambias un número en un archivo y el formulario y la API se enteran a la vez.</p>
<h2>En el servidor: parsear, no confiar</h2>
<p>En la API, el mismo esquema es la frontera entre el mundo exterior y tu código tipado:</p>
<pre><code class="language-typescript">export async function POST(req: Request) {
  const parsed = registroSchema.safeParse(await req.json());
  if (!parsed.success) {
    return Response.json(
      { errors: parsed.error.flatten().fieldErrors },
      { status: 422 },
    );
  }
  // parsed.data es RegistroOutput: normalizado y tipado
  await crearUsuario(parsed.data);
}
</code></pre>
<p>Uso <code>safeParse</code> y devuelvo <code>fieldErrors</code> con la misma estructura que consume el formulario. Así, incluso si un cliente malicioso salta la validación del navegador, el error que devuelve el servidor encaja en la misma UI de errores por campo. La validación del cliente es UX; la del servidor es seguridad. Misma lógica, roles distintos.</p>
<blockquote>
<p>💡 &quot;Parse, don&#39;t validate&quot;: el esquema no solo comprueba que los datos son correctos, los <em>transforma</em> en un tipo que ya no puede ser incorrecto. Después de la frontera, ninguna función vuelve a preguntarse si el email es válido: el tipo lo garantiza.</p>
</blockquote>
<h2>Los límites del patrón</h2>
<p>No todo es compartible, y fingir que lo es genera esquemas monstruosos. Las validaciones que dependen de estado del servidor (¿este email ya existe?, ¿este cupón sigue activo?) no pertenecen al esquema compartido: son reglas de negocio del backend y se comprueban ahí, devolviendo errores con el mismo formato de <code>fieldErrors</code> para que la UI las pinte igual.</p>
<p>También decidí no compartir esquemas de respuesta de la API con el mismo entusiasmo. Los esquemas de entrada cambian despacio y los controlas tú; tipar cada respuesta con Zod en runtime añade un coste de parseo que en la mayoría de endpoints no compra nada. Ahí me basta el contrato de tipos de TypeScript generado del propio esquema.</p>
<p>El trade-off global del patrón es el acoplamiento: frontend y backend comparten un paquete, lo que exige monorepo o publicar el paquete versionado. Elegí monorepo con pnpm workspaces porque el coste de coordinar versiones entre repos separados era exactamente el tipo de fricción que intentaba eliminar. A cambio, un cambio de esquema obliga a desplegar ambos lados coordinados. Lo acepto: prefiero un despliegue coordinado explícito a una divergencia silenciosa que descubre un usuario.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Islands en Astro: cuándo hidratar y cuándo no</title>
      <link>https://yohangel.com/blog/astro-islands-hidratacion/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/astro-islands-hidratacion/</guid>
      <description>La hidratación parcial de Astro no es magia: es una decisión por componente que tomas tú, y equivocarte cuesta JavaScript que nadie te pidió.</description>
      <pubDate>Mon, 06 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>Frontend</category>
      <content:encoded><![CDATA[<p><strong>Astro parte de una premisa que después de años de SPAs suena casi herética: por defecto, tu componente no envía JavaScript al navegador. Nada. Se renderiza a HTML en el build y ahí se queda, estático y rápido. La interactividad no es el estado natural de la página, es algo que tú pides explícitamente, componente a componente, con una directiva <code>client:</code>. Este blog está construido así, y entender la arquitectura de islas —cuándo hidratar y, sobre todo, cuándo no— es la diferencia entre una página que vuela y una que arrastra kilobytes de framework para animar un botón.</strong></p>
<h2>El modelo mental: HTML por defecto, JS bajo demanda</h2>
<p>En una SPA clásica todo es JavaScript: el framework se monta entero en el cliente, hidrata todo el árbol y desde ahí gobierna la página. Es cómodo para el desarrollador y caro para el usuario, porque paga el coste de arrancar el framework aunque el 90% de la pantalla sea texto que nunca cambia.</p>
<p>Astro invierte la premisa. Renderiza tus componentes a HTML en el servidor o en el build, y por defecto descarta el JavaScript que los generó. Lo que llega al navegador es HTML plano que pinta al instante. Las &quot;islas&quot; son las zonas concretas que sí necesitan vida propia —un carrusel, un formulario con validación, un menú desplegable— rodeadas de un mar de HTML estático que no cuesta nada. Cada isla se hidrata de forma independiente; el resto de la página ni se entera.</p>
<h2>Las directivas <code>client:</code> son la decisión, no un detalle</h2>
<p>La palanca es la directiva que le pones al componente al usarlo. Cada una describe <em>cuándo</em> se hidrata esa isla, y elegir bien es donde está el rendimiento:</p>
<pre><code class="language-astro">---
import Carousel from &#39;../components/Carousel.jsx&#39;;
import NewsletterForm from &#39;../components/NewsletterForm.jsx&#39;;
import HeavyChart from &#39;../components/HeavyChart.jsx&#39;;
---

&lt;!-- Se hidrata en cuanto carga la página: para lo que se ve arriba --&gt;
&lt;Carousel client:load /&gt;

&lt;!-- Se hidrata cuando entra en el viewport: para lo que está más abajo --&gt;
&lt;HeavyChart client:visible /&gt;

&lt;!-- Se hidrata cuando el navegador está ocioso: para lo no urgente --&gt;
&lt;NewsletterForm client:idle /&gt;
</code></pre>
<p><code>client:load</code> hidrata de inmediato: úsalo solo para lo que es interactivo desde el primer pixel visible. <code>client:idle</code> espera a que el hilo principal respire. <code>client:visible</code> es mi favorita: no gasta un byte de JavaScript hasta que el componente entra en pantalla, ideal para todo lo que vive por debajo del fold. La directiva no es un adorno de sintaxis: es literalmente la política de carga de cada isla, y ponerlas a la ligera es cómo se cuela JavaScript que el usuario no necesita.</p>
<h2>Cuándo NO hidratar (que es casi siempre)</h2>
<p>El error más común de quien llega desde React es tratar cada componente como si necesitara estado. No lo necesita. Una tarjeta, un encabezado, una lista de posts, un pie de página: todo eso es HTML que se pinta una vez y no vuelve a cambiar. Si no hay evento, no hay estado y no hay interacción, no hay isla. Se queda como componente estático de Astro y envía cero JavaScript.</p>
<p>La pregunta que me hago ante cada componente no es &quot;¿puede ser interactivo?&quot;, sino &quot;¿tiene que responder a algo del usuario en el cliente?&quot;. Un botón que solo es un enlace no es una isla, es una etiqueta <code>&lt;a&gt;</code>. Un acordeón se puede hacer con <code>&lt;details&gt;</code> y <code>&lt;summary&gt;</code> nativos sin una línea de JS. Cuanto más empujo la interactividad hacia HTML y CSS nativos, menos islas necesito y más ligera queda la página.</p>
<blockquote>
<p>💡 En Astro, cada <code>client:</code> que escribes es JavaScript que el usuario descarga. La mejor optimización no es hidratar más rápido, es no hidratar.</p>
</blockquote>
<h2>Las islas no comparten estado, y eso es a propósito</h2>
<p>Aquí está el trade-off que hay que tener claro antes de casarse con el modelo. Cada isla es un componente aislado: se hidrata sola y no comparte estado con las demás por arte de magia. Si vengo de una SPA donde un store global lo conecta todo, esto se siente como una limitación. Dos islas que necesitan hablar entre sí no lo hacen a través de props, porque son árboles independientes montados por separado.</p>
<p>La solución existe —eventos del navegador, <code>nanostores</code> compartidos, señales— pero es fricción real, y esa fricción es la señal de que quizá estás usando la herramienta a contrapelo. Si tu página es básicamente una aplicación con estado muy entrelazado, muchas partes hablando entre sí constantemente, Astro te va a pedir gimnasia para algo que un framework de SPA te da gratis. Ahí no fuerzo Astro: elijo la herramienta adecuada.</p>
<h2>Por qué elegí este modelo igualmente</h2>
<p>Astro brilla cuando el contenido manda y la interactividad es puntual: blogs, sitios de marketing, documentación, portfolios, e-commerce con islas de producto. Es exactamente la forma de la mayoría de la web, y de este sitio. Acepto la fricción de coordinar islas a cambio de que, por defecto, cada página nazca sin peso, y que sea yo quien decide y justifica cada kilobyte de JavaScript que añado.</p>
<p>Ese cambio de default lo es todo. En una SPA el JavaScript es la norma y quitarlo cuesta trabajo; en Astro el JavaScript es la excepción y añadirlo es una decisión consciente. Prefiero un sistema donde lo barato es lo fácil y lo caro requiere que yo lo pida a propósito. Esa inversión de la carga por defecto es, para el tipo de webs que construyo, la mejor decisión de rendimiento que puedo tomar sin optimizar una sola línea.</p>
]]></content:encoded>
    </item>
    <item>
      <title>View Transitions en Astro: navegación SPA sin el peso de una SPA</title>
      <link>https://yohangel.com/blog/astro-view-transitions/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/astro-view-transitions/</guid>
      <description>Cómo consigo transiciones suaves entre páginas y estado que sobrevive a la navegación sin renunciar al HTML que Astro sirve por defecto.</description>
      <pubDate>Mon, 06 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>Frontend</category>
      <content:encoded><![CDATA[<p><strong>Elegí Astro para varios sitios precisamente porque sirve HTML y manda muy poco JavaScript al cliente. La contrapartida clásica de ese modelo es que cada clic recarga la página entera: parpadeo blanco, scroll que salta, se pierde el estado. Las View Transitions de Astro me dieron la parte buena de una SPA —navegación fluida, sin recargas visibles, con estado que persiste— sin volver a arrastrar un router de cliente y megas de bundle. Este artículo es cómo las uso y, sobre todo, dónde están los bordes que conviene conocer antes de activarlas.</strong></p>
<h2>Qué problema resuelven de verdad</h2>
<p>En un sitio MPA tradicional cada navegación es una recarga completa del documento. Funciona, es robusto y es lo que Astro hace de serie, pero se siente tosco: entre página y página hay un instante en blanco, el navegador reinicia el scroll y cualquier cosa que estuviera sonando o reproduciéndose se corta. En una SPA eso no pasa porque nunca recargas: un router intercepta el clic y reemplaza el contenido con JavaScript. El precio es que te llevas todo el aparato de la SPA —el router, la hidratación, el estado en cliente— aunque tu sitio sea mayormente contenido.</p>
<p>Las View Transitions se colocan en medio. Astro intercepta la navegación, pide la siguiente página, intercambia el contenido del <code>&lt;body&gt;</code> sin recargar el documento y anima la transición usando la API de View Transitions del navegador. Sigues sirviendo páginas HTML normales, pero la navegación entre ellas deja de parpadear. Activarlo es una línea en el layout.</p>
<pre><code class="language-astro">---
// src/layouts/Layout.astro
import { ClientRouter } from &#39;astro:transitions&#39;;
---
&lt;html lang=&quot;es&quot;&gt;
  &lt;head&gt;
    &lt;ClientRouter /&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;slot /&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>Con ese <code>&lt;ClientRouter /&gt;</code> en el <code>&lt;head&gt;</code>, cada enlace interno pasa a navegarse sin recarga y con una transición suave por defecto. No has escrito un router ni has convertido nada en SPA: sigue siendo Astro sirviendo HTML.</p>
<h2>Animaciones con nombre: el detalle que lo vende</h2>
<p>La transición por defecto es un fundido discreto, pero lo que engancha es poder animar elementos concretos de una página a otra. Si marco dos elementos en páginas distintas con el mismo <code>transition:name</code>, el navegador entiende que son &quot;el mismo&quot; y anima el cambio de posición y tamaño entre ambos. El caso típico es la miniatura de un artículo que crece hasta convertirse en la cabecera del detalle.</p>
<pre><code class="language-astro">&lt;!-- En el listado --&gt;
&lt;img src={post.cover} transition:name={`cover-${post.slug}`} /&gt;

&lt;!-- En la página de detalle --&gt;
&lt;img src={post.cover} transition:name={`cover-${post.slug}`} /&gt;
</code></pre>
<p>El nombre tiene que ser único por elemento en cada página; por eso lo derivo del <code>slug</code>. Si repites un mismo <code>transition:name</code> para varios elementos visibles a la vez, el navegador no sabe cuál es cuál y la animación se rompe. Es el error más común al empezar.</p>
<h2>El estado que quieres que sobreviva</h2>
<p>Aquí está la ganancia menos obvia. Como no hay recarga completa, puedo pedir que ciertos elementos persistan entre navegaciones en lugar de recrearse. Un reproductor de audio, un mapa pesado, un menú con su estado abierto: con <code>transition:persist</code> el elemento se conserva tal cual al pasar de página, sin reiniciarse.</p>
<pre><code class="language-astro">&lt;audio src={cancion} controls transition:persist /&gt;
</code></pre>
<p>Sin esto, cada navegación destruiría el <code>&lt;audio&gt;</code> y volvería a crearlo, cortando la reproducción. Con <code>transition:persist</code>, el mismo nodo del DOM viaja a la página siguiente y sigue sonando. Es exactamente el tipo de continuidad por la que la gente monta una SPA entera, y aquí lo consigo con un atributo.</p>
<blockquote>
<p>💡 Cada elemento persistido es un nodo que decides no recrear. Úsalo para lo que debe tener continuidad real —audio, vídeo, un mapa caro— y no como parche para no recalcular algo que sí debería refrescarse con la nueva página.</p>
</blockquote>
<h2>Los bordes que hay que conocer</h2>
<p>Nada de esto es gratis, y es honesto decir dónde aprietan. El primero es el scripting. Como la página no recarga, el <code>&lt;script&gt;</code> que corría en <code>DOMContentLoaded</code> no se vuelve a ejecutar en cada navegación: se ejecutó una vez y la nueva página no lo dispara de nuevo. Cualquier inicialización que dieras por hecha en cada carga hay que reengancharla al evento <code>astro:page-load</code>, que Astro emite en la carga inicial y después de cada transición.</p>
<pre><code class="language-typescript">// Se ejecuta en la primera carga y tras cada navegación
document.addEventListener(&#39;astro:page-load&#39;, () =&gt; {
  inicializarWidgets();
});
</code></pre>
<p>Este es el fallo que más cuesta detectar, porque el sitio funciona perfecto al recargar a mano y solo se rompe al navegar entre páginas: los scripts simplemente no se reengancharon.</p>
<p>El segundo borde es la degradación. Las View Transitions se apoyan en una API que no todos los navegadores implementan igual; donde no está, Astro cae con elegancia a una navegación normal con recarga. Eso significa que no puedes tratar la transición como garantizada: es una mejora progresiva, no una base sobre la que construir lógica. Y el tercero, más de criterio que técnico, es no pasarse: animarlo todo marea. Reservo las animaciones con nombre para una o dos conexiones visuales que de verdad aporten continuidad, y dejo el resto en el fundido discreto.</p>
<h2>Por qué me sale a cuenta</h2>
<p>Elegí View Transitions en Astro en vez de saltar a una SPA porque me quedo con lo que quería del framework —HTML servido, JavaScript mínimo, páginas independientes que cachean y se indexan bien— y encima gano la fluidez que antes obligaba a cambiar de modelo entero. A cambio acepto un puñado de bordes concretos: reenganchar scripts en <code>astro:page-load</code>, tratar la transición como progresiva y ser disciplinado con las animaciones. Es un intercambio que casi siempre acepto, porque el coste es acotado y conocido, mientras que meterme en una SPA por conseguir una animación sería pagar en bundle, complejidad y SEO un precio muy superior al problema que resuelve.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Colas con SQS: reintentos que no duplican trabajo</title>
      <link>https://yohangel.com/blog/colas-sqs-idempotencia/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/colas-sqs-idempotencia/</guid>
      <description>Cómo hago que los reintentos de SQS sean seguros en JXBS sin duplicar correos, cargos ni estados.</description>
      <pubDate>Mon, 06 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>AWS</category>
      <content:encoded><![CDATA[<p><strong>Una cola no es un lujo: es la forma de sacar del camino crítico todo lo que no necesita responder al instante. En JXBS mando a SQS lo que puede esperar unos segundos: correos a candidatos, recálculo de matches, sincronizaciones. Lo que aprendí por las malas es que la parte difícil no es encolar, sino que el reintento no repita el trabajo. SQS entrega &quot;al menos una vez&quot;, y ese &quot;al menos&quot; es exactamente donde se rompen las cosas.</strong></p>
<h2>Por qué encolo en vez de responder en línea</h2>
<p>Cuando un reclutador publica una vacante, la respuesta HTTP no debería esperar a que se generen embeddings, se recalculen matches y se disparen notificaciones. Eso lo mando a una cola y devuelvo la respuesta de inmediato. El usuario percibe una app rápida; el trabajo pesado ocurre detrás, a su ritmo.</p>
<p>La cola me da tres cosas concretas. Absorbe picos: si entran cien vacantes de golpe, se procesan al ritmo que aguante el consumidor, no todas a la vez. Aísla fallos: si el proveedor de correo está caído, la tarea se reintenta sola sin tumbar la petición original. Y desacopla: el productor no sabe ni le importa quién consume.</p>
<h2>El problema real: &quot;al menos una vez&quot;</h2>
<p>SQS estándar garantiza entrega, pero no unicidad. El mismo mensaje puede llegar dos veces: porque el consumidor tardó más que el <em>visibility timeout</em> y SQS lo re-entregó, porque hubo un reintento de red, o porque el proceso murió justo después de trabajar pero antes de borrar el mensaje.</p>
<p>Si mi consumidor manda un correo por cada mensaje sin más, un candidato recibe dos. Si crea un cargo, cobro doble. La entrega duplicada no es un caso raro que ocurre una vez al año: es el comportamiento normal del sistema, y hay que diseñarlo asumiendo que pasará.</p>
<h2>Idempotencia: la única defensa que escala</h2>
<p>La solución no es evitar los duplicados, es hacer que procesarlos dos veces dé el mismo resultado que procesarlos una. Eso es idempotencia, y la implemento con una clave estable por unidad de trabajo.</p>
<pre><code class="language-sql">CREATE TABLE processed_message (
  idempotency_key TEXT PRIMARY KEY,
  processed_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
</code></pre>
<p>El consumidor, antes de trabajar, intenta insertar la clave. Si ya existe, el mensaje es un duplicado y lo descarto sin efectos.</p>
<pre><code class="language-typescript">async function handle(msg: Job) {
  const inserted = await db.processedMessage
    .create({ data: { idempotencyKey: msg.id } })
    .catch(() =&gt; null); // choca con la PK si ya se procesó

  if (!inserted) return; // duplicado: no hago nada

  await doTheActualWork(msg);
}
</code></pre>
<p>La clave no la invento al azar: la derivo de la intención del mensaje. Para un correo de bienvenida es <code>welcome-email:{candidateId}</code>, no un UUID nuevo por cada intento. Así, dos mensajes que representan la misma acción colisionan y solo uno gana.</p>
<blockquote>
<p>💡 Un reintento seguro no es el que nunca se repite; es el que puede repetirse sin que a nadie le importe.</p>
</blockquote>
<h2>Visibility timeout y la DLQ</h2>
<p>Dos piezas de SQS hay que afinar. El <em>visibility timeout</em> debe ser mayor que el peor tiempo de proceso realista: si tardo más, SQS asume que fallé y re-entrega mientras yo sigo trabajando. Lo mido contra el p99 del consumidor, no contra el promedio.</p>
<p>Y la <em>dead-letter queue</em>: tras N intentos fallidos, el mensaje sale de la cola principal y cae en una DLQ. Sin ella, un mensaje envenenado —uno que siempre falla— se reintenta para siempre y tapa la cola. La DLQ es donde reviso qué se rompió sin que bloquee todo lo demás. Le pongo una alarma: si algo aterriza ahí, quiero enterarme.</p>
<h2>Lo que haría diferente</h2>
<p>Al principio metí la lógica de idempotencia dentro de cada handler. Terminé repitiéndola y equivocándome distinto en cada uno. Hoy vive en un wrapper único: recibe la clave, verifica, ejecuta, y ningún handler vuelve a pensar en duplicados. El patrón importa más que la infraestructura. SQS es reemplazable; la disciplina de asumir que todo mensaje puede llegar dos veces es lo que de verdad te salva.</p>
]]></content:encoded>
    </item>
    <item>
      <title>DynamoDB single-table: el patrón que más me costó entender</title>
      <link>https://yohangel.com/blog/dynamodb-single-table/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/dynamodb-single-table/</guid>
      <description>Por qué meter varias entidades en una sola tabla de DynamoDB deja de parecer una locura cuando piensas en accesos y no en entidades.</description>
      <pubDate>Mon, 06 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>AWS</category>
      <content:encoded><![CDATA[<p><strong>Vengo de PostgreSQL, así que la primera vez que vi un diseño single-table de DynamoDB —usuarios, pedidos y productos conviviendo en la misma tabla, con claves como <code>USER#123</code> y <code>ORDER#456</code>— pensé que alguien había perdido la cabeza. Tardé en entenderlo porque estaba haciendo la pregunta equivocada: en un modelo relacional diseñas por entidades, y en DynamoDB diseñas por patrones de acceso. Cuando ese chip hace clic, el single-table deja de ser una aberración y se convierte en la forma natural de sacarle partido a la base. Este artículo es cómo hice ese cambio mental y dónde están las trampas.</strong></p>
<h2>Por qué DynamoDB no es Postgres con otra piel</h2>
<p>En Postgres normalizo, creo tablas por entidad y dejo que el planificador resuelva los joins en tiempo de consulta. Es flexible: si mañana necesito una consulta nueva, la escribo y el motor se las arregla, quizá con un índice extra. Esa flexibilidad la pago en que el rendimiento depende del plan que elija el motor.</p>
<p>DynamoDB invierte el trato. No hay joins ni planificador: cada acceso es una búsqueda por clave, y si no diseñaste la clave para esa consulta, esa consulta o es imposible o te obliga a un <code>Scan</code> que recorre toda la tabla. A cambio, un acceso bien diseñado tiene latencia predecible sin importar cuántos datos haya. La consecuencia es brutal: en DynamoDB no puedes empezar por el modelo de datos, tienes que empezar por la lista de consultas que tu producto va a hacer.</p>
<h2>Empieza por los access patterns, siempre</h2>
<p>Antes de tocar una tabla, escribo la lista de accesos en lenguaje llano. Para un producto de pedidos sería algo así: dame un usuario por su id; dame todos los pedidos de un usuario; dame un pedido con sus líneas; dame los pedidos de un usuario ordenados por fecha. Esa lista no es documentación, es el diseño. Cada patrón tiene que corresponder a un <code>Query</code> de partition key, no a un <code>Scan</code>.</p>
<p>Este paso es el que la gente que viene de SQL se salta, y es exactamente el que no puedes saltarte. Si más adelante aparece un patrón que no previste, en Postgres es una consulta nueva; en DynamoDB puede ser un rediseño de claves o un índice secundario global. El coste de equivocarse aquí es alto, así que aquí es donde pongo el pensamiento.</p>
<h2>Claves genéricas: PK y SK que no significan una sola cosa</h2>
<p>El truco del single-table es que la partition key y la sort key no se llaman <code>user_id</code> ni <code>order_id</code>. Se llaman <code>PK</code> y <code>SK</code> a secas, y su contenido cambia según la entidad. Un usuario vive en <code>PK = USER#123</code>, <code>SK = PROFILE</code>. Sus pedidos viven en la misma partición, <code>PK = USER#123</code>, <code>SK = ORDER#456</code>. Así, pedir un usuario y todos sus pedidos es un único <code>Query</code> sobre <code>PK = USER#123</code>: la partición ya trae el perfil y los pedidos juntos, sin join.</p>
<pre><code class="language-typescript">// Todos los ítems de un usuario (perfil + pedidos) en una sola query
const res = await ddb.query({
  TableName: &#39;app&#39;,
  KeyConditionExpression: &#39;PK = :pk&#39;,
  ExpressionAttributeValues: { &#39;:pk&#39;: &#39;USER#123&#39; },
});

// Solo los pedidos: acoto por prefijo de la sort key
const orders = await ddb.query({
  TableName: &#39;app&#39;,
  KeyConditionExpression: &#39;PK = :pk AND begins_with(SK, :prefix)&#39;,
  ExpressionAttributeValues: { &#39;:pk&#39;: &#39;USER#123&#39;, &#39;:prefix&#39;: &#39;ORDER#&#39; },
});
</code></pre>
<p>El <code>begins_with</code> sobre la sort key es la palanca: al prefijar los tipos (<code>ORDER#</code>, <code>PROFILE</code>, <code>ADDRESS#</code>) puedo traer justo el subconjunto que quiero de una partición, ordenado, en una sola llamada.</p>
<blockquote>
<p>💡 En DynamoDB la sort key no ordena datos, diseña consultas. El prefijo que le pones es lo que decide qué preguntas podrás hacer barato.</p>
</blockquote>
<h2>GSIs: cuando necesitas mirar los datos por otro eje</h2>
<p>Los patrones que no encajan en la clave principal se resuelven con índices secundarios globales. Un GSI es, en esencia, una proyección de la tabla con otra PK y otra SK, mantenida por DynamoDB de forma asíncrona. Si necesito &quot;todos los pedidos con estado <code>PENDING</code> ordenados por fecha&quot;, creo un GSI cuya PK sea el estado y cuya SK sea la fecha, y ese acceso vuelve a ser un <code>Query</code> limpio.</p>
<p>El patrón que uso es la sobrecarga de índice: los mismos atributos genéricos <code>GSI1PK</code> y <code>GSI1SK</code> significan cosas distintas según la entidad, igual que la clave principal. Con un par de GSIs bien pensados cubro casi cualquier producto. Lo que no hago es crear un GSI por capricho: cada índice cuesta escritura y almacenamiento, porque cada <code>put</code> en la tabla se replica en los índices que apliquen.</p>
<h2>Los trade-offs que nadie te cuenta al principio</h2>
<p>El single-table es potente, pero es honesto reconocer lo que cuesta. Lo primero es la curva mental: modelar así es incómodo hasta que interiorizas pensar en accesos, y un equipo nuevo tarda en leer una tabla donde todo se llama <code>PK</code> y <code>SK</code>. Lo segundo es la rigidez: si aparece un patrón de acceso realmente imprevisto, adaptarte cuesta más que en SQL, donde una consulta ad-hoc siempre es posible aunque sea lenta.</p>
<p>Por eso no uso single-table para todo. Cuando el producto tiene consultas exploratorias, informes cambiantes o relaciones que no sé anticipar, Postgres me da una flexibilidad que DynamoDB me cobraría carísima. Elijo DynamoDB cuando los patrones de acceso son conocidos, acotados y necesito latencia predecible a escala sin operar un motor relacional. Ahí el single-table brilla: menos piezas, latencia plana y una factura que escala con el uso real y no con el tamaño de la tabla. Como todo en AWS, no es la mejor herramienta, es la mejor herramienta para un problema con la forma correcta.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Lambda o Fargate: cómo decido cuál usar</title>
      <link>https://yohangel.com/blog/lambda-vs-fargate/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/lambda-vs-fargate/</guid>
      <description>Dejé de preguntarme cuál es mejor y empecé a preguntarme qué forma tiene la carga. La respuesta casi siempre sale de ahí.</description>
      <pubDate>Mon, 06 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>AWS</category>
      <content:encoded><![CDATA[<p><strong>&quot;¿Lambda o Fargate?&quot; es una de esas preguntas que la gente hace esperando un ganador, y no lo hay. Las dos ejecutan código sin que gestiones servidores, pero resuelven problemas con forma distinta. Después de montar bastantes servicios sobre ECS Fargate y bastantes funciones en Lambda, dejé de elegir por moda y empecé a mirar tres cosas de la carga: cómo llega el tráfico, cuánto dura cada unidad de trabajo y cuánto estado necesita mantener el proceso. Casi siempre la decisión se cae sola por su propio peso.</strong></p>
<h2>No es serverless contra contenedores</h2>
<p>El primer malentendido es tratarlo como serverless contra contenedores. Fargate también es serverless: defines una task, AWS la ejecuta y no tocas una sola instancia EC2. La diferencia real está en el modelo de ejecución. Lambda es efímero e invocado por evento: llega una petición, se levanta un entorno, corre tu handler, responde y se apaga. Fargate es un proceso de larga vida: tu contenedor arranca una vez, se queda escuchando y atiende peticiones hasta que lo escalas o lo matas.</p>
<p>Esa diferencia lo tiñe todo. En Lambda no controlas el ciclo de vida: no hay un &quot;arranque&quot; tuyo donde abrir un pool de conexiones y reutilizarlo tranquilo, porque el entorno puede reciclarse en cualquier momento. En Fargate sí tienes un proceso estable con su arranque, su memoria caliente y su pool de conexiones vivo entre peticiones. Elegir es, en el fondo, elegir cuánto control quieres sobre ese ciclo de vida.</p>
<h2>La forma del tráfico manda</h2>
<p>Lo primero que miro es cómo llega el trabajo. Si el tráfico es intermitente, a ráfagas o impredecible —un webhook que salta a veces, un cron que procesa cuando toca, un endpoint con picos raros— Lambda encaja de forma natural. Escala de cero a muchas invocaciones en paralelo sin que configures nada, y cuando no hay tráfico no pagas nada. Ese &quot;de cero a mil sin avisar&quot; es exactamente donde Fargate sufre: mantener capacidad para el pico significa pagar contenedores encendidos esperando, y escalar reactivo tarda más que un arranque de Lambda.</p>
<p>Si el tráfico es sostenido y constante, el cálculo se invierte. Un servicio con carga estable durante todo el día, en Lambda, es un goteo continuo de invocaciones que se factura por cada milisegundo y termina saliendo caro frente a un puñado de contenedores Fargate a pleno rendimiento. Ahí un proceso de larga vida aprovecha mejor cada CPU: no repite el coste de arrancar en cada petición y mantiene calientes recursos que Lambda tendría que rehacer una y otra vez.</p>
<blockquote>
<p>💡 Lambda cobra por invocación y duración: brilla cuando a veces el tráfico es cero. Fargate cobra por contenedor encendido: brilla cuando el tráfico casi nunca es cero. Antes de decidir, dibuja la curva de tráfico de un día.</p>
</blockquote>
<h2>Duración y límites: el reloj de los 15 minutos</h2>
<p>El segundo eje es cuánto dura cada unidad de trabajo. Lambda tiene un tope duro de quince minutos por invocación. Para una API o un procesamiento de eventos sobra, pero cualquier trabajo que pueda pasarse —una migración larga, un procesado de vídeo, un job que recorre un dataset grande— no cabe, y trocearlo para que quepa es complejidad que no siempre compensa. Fargate no tiene ese reloj: una task puede correr minutos u horas, lo que la hace el sitio natural para trabajo por lotes largo o procesos que simplemente tienen que estar vivos.</p>
<p>También está el arranque en frío. Cuando Lambda levanta un entorno nuevo, la primera invocación paga el coste de inicializar el runtime. Para cargas tolerantes a latencia da igual; para un endpoint sensible a la cola de latencias, ese frío ocasional se nota. Se mitiga —concurrencia aprovisionada, runtimes ligeros— pero es un factor real. Fargate no tiene arranque en frío por petición: el arranque lo pagas una vez cuando escalas una task nueva, no en cada invocación.</p>
<h2>Estado, conexiones y dependencias</h2>
<p>El tercer eje es cuánto estado y qué dependencias necesita el proceso. El caso clásico es la base de datos. Un pool de conexiones vive de reutilizar conexiones entre peticiones, y eso encaja con un proceso de larga vida como Fargate. En Lambda, cada entorno concurrente abre las suyas, y bajo un pico puedes agotar las conexiones de PostgreSQL sin darte cuenta; la solución pasa por un proxy de conexiones intermedio, otra pieza más en el diagrama. No es imposible hacer Lambda contra una base relacional, pero es fricción que Fargate no tiene.</p>
<pre><code class="language-typescript">// En Fargate esto se inicializa UNA vez al arrancar el
// contenedor y el pool se reutiliza en cada petición.
import { Pool } from &#39;pg&#39;;

const pool = new Pool({ max: 10 }); // vive entre peticiones

export async function handler(req: Request) {
  const client = await pool.connect(); // reutiliza, no reabre
  try {
    return await consulta(client, req);
  } finally {
    client.release();
  }
}
</code></pre>
<p>Ese mismo patrón en Lambda es traicionero: el <code>pool</code> sobrevive mientras el entorno se reutiliza, pero se multiplica por cada entorno concurrente, y ahí es donde revienta la base bajo carga.</p>
<h2>Mi regla, y por qué mezclo los dos</h2>
<p>Al final mi criterio es simple. Empiezo en Lambda cuando el trabajo es corto, disparado por eventos, con tráfico irregular y poco estado: webhooks, tareas asíncronas, glue entre servicios de AWS. Me muevo a Fargate cuando el trabajo es sostenido, de larga duración, o necesita conexiones vivas y control del ciclo de vida: APIs con carga constante, workers de cola que procesan sin parar, batch largo.</p>
<p>Y no elijo uno para todo el sistema. En el mismo producto convive un servicio HTTP en Fargate con conexión estable a Postgres y, al lado, funciones Lambda para los webhooks entrantes y los jobs asíncronos. Elegí mezclar en vez de forzar un único modelo porque cada carga tiene su forma, y pelearse con la herramienta para que haga algo que no es lo suyo se paga siempre: en Lambda con proxies y troceo, en Fargate con capacidad ociosa esperando un pico que no llega. Como casi todo en AWS, no hay una mejor herramienta, hay la mejor herramienta para la forma de tu problema.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Evals para features con LLM: cómo sé que no rompí nada</title>
      <link>https://yohangel.com/blog/llm-evals-produccion/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/llm-evals-produccion/</guid>
      <description>Sin un set de evals, cada cambio de prompt o de modelo es una apuesta a ciegas. Así monté el mío.</description>
      <pubDate>Mon, 06 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>IA</category>
      <content:encoded><![CDATA[<p><strong>La primera vez que cambié un prompt en producción en JXBS, lo hice como cambiaba cualquier otra cosa: lo probé con dos o tres ejemplos a mano, me pareció mejor y lo desplegué. A la semana un usuario reportó que el matching devolvía basura en un caso que antes funcionaba. No había roto el código: había roto el comportamiento del modelo, y no tenía forma de saberlo porque no medía nada. Los evals son la pieza que convierte &quot;trabajar con LLMs&quot; de artesanía supersticiosa en ingeniería. Este artículo es el mínimo viable de evals que uso hoy en cada feature que lleva un modelo dentro.</strong></p>
<h2>Un LLM no tiene tests unitarios, y eso es el problema</h2>
<p>Cuando escribes una función normal, un test unitario fija su contrato: entra esto, sale aquello, y si alguien lo rompe el pipeline te avisa. Con un feature basado en LLM ese contrato se difumina. El mismo prompt con el mismo input puede dar respuestas distintas, y un cambio aparentemente inofensivo —reordenar dos frases del system prompt, subir de una versión del modelo a la siguiente— puede degradar casos que ni sabías que dependían de ese detalle.</p>
<p>El error mental es tratar el prompt como código estable y el modelo como una dependencia fija. No lo son. El prompt es un parámetro que ajustas constantemente y el modelo es una dependencia que cambia debajo de ti sin avisar. Sin una batería de casos que corras en cada cambio, estás desplegando a ciegas y descubriendo las regresiones a través de los usuarios. Que es la peor forma de descubrirlas.</p>
<h2>El eval mínimo: un dataset dorado y una métrica que te importe</h2>
<p>No hace falta una plataforma de evals para empezar. Hace falta un archivo con casos representativos y una función que puntúe. Un caso es un input real, la salida esperada (o una propiedad que la salida debe cumplir) y opcionalmente por qué está ahí. Yo los saco de tres sitios: ejemplos que sé que funcionan, casos límite que me preocupan, y sobre todo los bugs reales que reporta la gente —cada regresión de producción se convierte en un caso de eval para que no vuelva a pasar.</p>
<pre><code class="language-typescript">type EvalCase = {
  name: string;
  input: MatchInput;
  // Una aserción sobre la salida, no una igualdad exacta:
  // con LLMs comparar strings palabra por palabra es inútil.
  expect: (output: MatchOutput) =&gt; boolean;
};

const cases: EvalCase[] = [
  {
    name: &#39;candidato senior con stack exacto sale primero&#39;,
    input: seniorReactExacto,
    expect: (out) =&gt; out.ranked[0].id === &#39;cand_42&#39;,
  },
  {
    name: &#39;no inventa habilidades que no están en el CV&#39;,
    input: cvSinKubernetes,
    expect: (out) =&gt; !out.ranked[0].reasons.includes(&#39;Kubernetes&#39;),
  },
];
</code></pre>
<p>Fíjate en que no comparo la salida entera carácter a carácter. Con un LLM eso está condenado a fallar por diferencias irrelevantes de redacción. Lo que compruebo son propiedades: que el candidato correcto quede arriba, que no aparezca una habilidad alucinada, que el formato sea parseable. Cada aserción codifica algo que de verdad me importa del comportamiento, no la forma exacta del texto.</p>
<h2>Puntuar lo subjetivo: cuándo uso un LLM como juez</h2>
<p>Muchas salidas no se pueden verificar con un <code>if</code>. &quot;¿Es esta explicación del match clara y fundamentada?&quot; no es una propiedad booleana obvia. Para eso uso un segundo LLM como evaluador: le paso el input, la salida y una rúbrica concreta, y le pido una nota con justificación. Funciona sorprendentemente bien para detectar degradaciones grandes, y es mucho más barato que revisar cientos de salidas a mano.</p>
<p>Pero elegí usar LLM-como-juez con los ojos abiertos, no como bala de plata. Tiene sesgos conocidos: tiende a premiar respuestas largas, favorece el estilo sobre el fondo, y si le pides que puntúe su propio modelo se vuelve indulgente. Lo trato como un filtro ruidoso, no como la verdad: sirve para cazar caídas grandes de calidad entre versiones, no para afinar si algo pasó de 8,2 a 8,4. Para las propiedades que sí puedo verificar con código, uso código, que es determinista y gratis.</p>
<blockquote>
<p>💡 Cada bug de LLM que llega a producción es un caso de eval que te faltaba. Antes de arreglar el prompt, añade el caso que falla a tu dataset. Así el arreglo queda blindado y esa regresión concreta no puede volver nunca.</p>
</blockquote>
<h2>Ejecutar los evals donde duele: antes de desplegar</h2>
<p>Un dataset de evals que corres cuando te acuerdas no sirve de nada. El valor aparece cuando se ejecuta automáticamente en cada cambio que toque el prompt, el modelo o la lógica de orquestación. Lo enganché al pipeline como un paso más: si la tasa de aciertos sobre el dataset cae por debajo de un umbral, el despliegue se para igual que si fallara un test.</p>
<pre><code class="language-typescript">async function runEvals(cases: EvalCase[]) {
  const results = await Promise.all(
    cases.map(async (c) =&gt; ({
      name: c.name,
      passed: c.expect(await runFeature(c.input)),
    })),
  );

  const passed = results.filter((r) =&gt; r.passed).length;
  const rate = passed / results.length;

  console.table(results);
  if (rate &lt; 0.9) {
    throw new Error(`Evals por debajo del umbral: ${rate}`);
  }
}
</code></pre>
<p>El umbral no es 100% a propósito. Con LLMs hay casos genuinamente ambiguos donde un fallo ocasional es tolerable, y exigir verde perfecto te lleva a sobreajustar el prompt a tu dataset. Prefiero un umbral alto pero realista y vigilar la tendencia: si la tasa lleva tres versiones bajando, hay un problema aunque cada versión pasara el corte.</p>
<h2>Dónde pongo el esfuerzo hoy</h2>
<p>Si tuviera que dar un solo consejo a alguien que mete su primer LLM en un producto, sería este: monta los evals antes de pulir el prompt, no después. Es tentador dedicar el tiempo a la parte creativa de redactar instrucciones ingeniosas, pero sin una forma de medir estás optimizando a ciegas. Elegí invertir en el arnés de evaluación antes que en el prompt perfecto porque el arnés se paga solo en la primera regresión que caza, y su valor crece con cada caso que le añades. El prompt lo reescribiré diez veces; los evals me dicen cuál de esas diez versiones es mejor.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Structured outputs con LLMs: cómo dejé de parsear texto a mano</title>
      <link>https://yohangel.com/blog/llm-structured-outputs/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/llm-structured-outputs/</guid>
      <description>Por qué function calling y validación con Zod convirtieron mis LLMs en producto de un componente frágil en una pieza de infraestructura fiable.</description>
      <pubDate>Mon, 06 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>IA</category>
      <content:encoded><![CDATA[<p><strong>La primera vez que metí un LLM en producto, el modelo hacía su trabajo bien y yo hacía el mío mal: le pedía JSON en el prompt, rezaba, y luego escribía regex para rescatar el objeto de entre disculpas y bloques de markdown. Funcionaba el 90% de las veces, que en producción es otra forma de decir que fallaba. La solución no fue un prompt más listo, sino tratar la salida del modelo como una interfaz tipada: structured outputs con function calling y validación con Zod. Este artículo es cómo hice ese cambio y qué gané a cambio.</strong></p>
<h2>El problema real no es el modelo, es el borde</h2>
<p>Un LLM dentro de un producto no vive solo: su salida alimenta una función, una consulta o una llamada a otra API. Ese punto de contacto entre lenguaje natural y código tipado es el borde frágil. Si el modelo devuelve texto libre, el borde se llena de parseo defensivo: buscar el primer <code>{</code>, contar llaves, quitar los ```json, capturar el <code>JSON.parse</code> que revienta. Cada una de esas líneas es deuda que se rompe en cuanto el modelo decide ser conversacional.</p>
<p>Durante un tiempo intenté resolverlo con prompt engineering. &quot;Responde solo con JSON válido, sin explicaciones.&quot; Ayuda, pero no garantiza nada: el prompt es una súplica, no un contrato. La lección que me llevó tiempo aceptar es que la fiabilidad no se pide, se impone en la capa de la API.</p>
<h2>Function calling como contrato, no como truco</h2>
<p>Los modelos modernos aceptan un esquema de herramienta o de respuesta y se comprometen a devolver algo que encaja en ese esquema. Eso cambia el juego: en vez de describir el formato en prosa, lo declaro como estructura y el modelo rellena los huecos. Dejo de parsear intenciones y empiezo a recibir datos.</p>
<p>Lo modelo así: defino la forma que necesito, la paso como esquema de respuesta, y el resto de mi código asume que recibirá exactamente eso. El prompt vuelve a hablar de la tarea —qué extraer, cómo priorizar— y no de comas y comillas.</p>
<pre><code class="language-typescript">import { z } from &#39;zod&#39;;

const CandidateExtraction = z.object({
  seniority: z.enum([&#39;junior&#39;, &#39;mid&#39;, &#39;senior&#39;, &#39;lead&#39;]),
  primary_stack: z.array(z.string()).max(8),
  years_experience: z.number().int().min(0).max(50),
  location: z.string(),
  open_to_remote: z.boolean(),
});

type CandidateExtraction = z.infer&lt;typeof CandidateExtraction&gt;;
</code></pre>
<p>Ese esquema es la fuente de verdad. De él sale el tipo de TypeScript, de él sale el esquema JSON que le paso al modelo, y contra él valido la respuesta. Una sola definición, tres usos.</p>
<h2>Zod: la red que atrapa lo que el modelo no garantiza</h2>
<p>Aquí viene el matiz que mucha gente se salta: que la API acepte un esquema no significa que el resultado sea correcto en el sentido que a mí me importa. El modelo puede devolver un JSON estructuralmente válido y semánticamente absurdo: un <code>years_experience</code> de 200, un stack con quince entradas duplicadas, un enum que no existía. La forma es correcta; el contenido, no.</p>
<p>Por eso valido siempre en el borde con Zod, aunque use structured outputs. No es cinturón y tirantes por paranoia: es que la garantía de la API y la garantía de mi dominio son cosas distintas. Zod me deja expresar la segunda —rangos, longitudes máximas, enums cerrados— y convierte una respuesta dudosa en un error que puedo manejar antes de que contamine la base de datos.</p>
<pre><code class="language-typescript">const raw = await callModelWithSchema(prompt, CandidateExtraction);
const parsed = CandidateExtraction.safeParse(raw);

if (!parsed.success) {
  // No propago basura: reintento con el error como contexto,
  // o caigo a un flujo manual. Nunca escribo sin validar.
  return handleInvalid(parsed.error);
}

await saveCandidate(parsed.data); // parsed.data ya es del tipo correcto
</code></pre>
<blockquote>
<p>💡 Un LLM en producto no es una fuente de verdad, es una fuente de propuestas. La validación es lo que convierte una propuesta en un dato en el que confías.</p>
</blockquote>
<h2>Reintentos con el error como contexto</h2>
<p>Cuando la validación falla, el instinto es reintentar con el mismo prompt. Malgastas tokens repitiendo el mismo error. Lo que funciona de verdad es reinyectar el fallo: le devuelvo al modelo qué esperaba y qué me dio, y le pido corregir. En la práctica, el 90% de los fallos de validación se resuelven en un solo reintento informado, porque casi siempre son deslices tontos —un campo que faltaba, un enum mal escrito— que el modelo corrige de inmediato cuando le señalas el punto exacto.</p>
<p>Le pongo un tope de reintentos, claro. Si tras dos intentos sigue sin encajar, el problema no es el modelo teniendo un mal día: es que la tarea está mal planteada o el esquema pide algo que el input no contiene. Ahí el fallo ruidoso es un regalo, porque me obliga a arreglar la causa en vez de esconderla.</p>
<h2>El trade-off que acepté</h2>
<p>Structured outputs no es gratis. Encorsetar al modelo en un esquema reduce su flexibilidad: para tareas realmente abiertas, donde no sé de antemano la forma de la respuesta, el esquema estorba más de lo que ayuda. Y añadir Zod más los reintentos es código y latencia que antes no tenía.</p>
<p>Elegí structured outputs igualmente porque en producto casi nunca quiero creatividad en el borde: quiero determinismo. Prefiero pagar en rigidez y en unas líneas de validación a cambio de que el resto de mi sistema pueda tratar la salida del LLM como trataría cualquier otra API tipada. Ese es el objetivo real: que el modelo deje de ser una excepción especial que todos rodean con cuidado y pase a ser un componente más, con su contrato y su manejo de errores, como cualquier otra pieza en la que ya confío.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Módulos de OpenTofu que no odiarás dentro de seis meses</title>
      <link>https://yohangel.com/blog/opentofu-modulos-iac/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/opentofu-modulos-iac/</guid>
      <description>La mayoría de los módulos de infraestructura envejecen mal. Estos son los límites que me impuse para que no pase.</description>
      <pubDate>Mon, 06 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>AWS</category>
      <content:encoded><![CDATA[<p><strong>Un módulo de infraestructura es de esas cosas que escribes una vez y sufres muchas. Empiezas con un módulo limpio para levantar un servicio en ECS Fargate, y seis meses después es un monstruo con cuarenta variables, tres booleanos que activan comportamientos incompatibles y un <code>count</code> anidado que nadie entiende. Migré varios proyectos de Terraform a OpenTofu y en el camino reescribí casi todos mis módulos, y lo que aprendí no va de sintaxis: va de resistir la tentación de que un módulo lo haga todo. Este es el conjunto de reglas que sigo hoy para que la infraestructura como código siga siendo un activo y no una losa.</strong></p>
<h2>Un módulo modela un concepto, no una lista de recursos</h2>
<p>El error que cometí más veces fue crear módulos alrededor de &quot;cosas que suelo desplegar juntas&quot; en lugar de alrededor de un concepto con una frontera clara. Un módulo &quot;servicio-web&quot; que crea el servicio de ECS, su balanceador, su DNS, su base de datos y sus alarmas parece cómodo, pero acopla decisiones que cambian a ritmos distintos. El día que quieres un servicio sin base de datos, o con la base de datos compartida con otro, el módulo te pelea.</p>
<p>La regla que me funciona es que un módulo debe modelar un concepto que tenga sentido nombrar por sí solo. &quot;Un servicio en Fargate&quot; es un concepto. &quot;Una cola SQS con su DLQ&quot; es un concepto. &quot;Todo lo que necesita el proyecto X&quot; no lo es: es una composición, y las composiciones van en la raíz, no dentro de un módulo. Cuando cada módulo tiene una frontera conceptual limpia, componerlos es fácil y sustituir uno por otro no rompe el resto.</p>
<h2>Los booleanos de configuración son deuda disfrazada</h2>
<p>Cada vez que añades una variable <code>enable_algo</code> a un módulo, estás metiendo una bifurcación en su comportamiento. Con dos o tres flags todavía se entiende. Con ocho, tu módulo tiene cientos de combinaciones posibles y solo has probado las cuatro que usas. El resto son campos de minas esperando a alguien.</p>
<pre><code class="language-hcl"># El módulo que envejece mal: se configura con flags
# que activan ramas de comportamiento incompatibles.
module &quot;servicio&quot; {
  source          = &quot;./modules/servicio&quot;
  enable_https    = true
  enable_autoscaling = true
  enable_spot     = false
  enable_efs      = true
  # ...y así hasta que nadie sabe qué combinaciones funcionan
}
</code></pre>
<p>Prefiero módulos con menos opciones y más pequeños, y resolver la variación componiendo en lugar de configurando. Si un servicio necesita almacenamiento persistente y otro no, no meto un flag <code>enable_efs</code>: hago que el volumen sea un módulo aparte que conecto cuando hace falta. El módulo de servicio no sabe nada de EFS y no tiene una rama sin probar. La composición se ve en la raíz, donde debe verse, y no escondida tras un booleano.</p>
<h2>Las salidas son el contrato, cuídalas como una API</h2>
<p>Lo que un módulo expone en sus <code>outputs</code> es su interfaz pública, y cambiarla rompe a quien lo consume igual que romperías a los clientes de una API. Al principio exponía de todo &quot;por si acaso&quot;, y acabé con módulos cuyos consumidores dependían de detalles internos que yo quería cambiar. Ahora trato las salidas con la misma disciplina que un contrato: expongo lo mínimo que un consumidor necesita para conectar este módulo con otro —un ARN, un id, un endpoint— y nada de la fontanería interna.</p>
<pre><code class="language-hcl"># Expón identificadores para componer, no recursos enteros.
output &quot;service_arn&quot; {
  value = aws_ecs_service.this.id
}

output &quot;task_role_arn&quot; {
  # Otros módulos adjuntan políticas a este rol;
  # ese es el punto de extensión que quiero ofrecer.
  value = aws_iam_role.task.arn
}
</code></pre>
<p>Exponer el ARN de un rol en vez del rol entero es un ejemplo pequeño pero importante: le doy al consumidor el punto exacto donde extender (adjuntar una política) sin abrirle el recurso completo para que manosee cosas que romperían mi módulo.</p>
<blockquote>
<p>💡 Antes de añadir una variable a un módulo, pregúntate si la variación pertenece dentro del módulo o en quien lo compone. La mayoría de las veces pertenece fuera. Un módulo con pocas entradas y salidas claras se reutiliza; uno con veinte flags se copia y se pega.</p>
</blockquote>
<h2>El estado es donde se pagan los errores de diseño</h2>
<p>En IaC el diseño no se prueba de verdad hasta que aplicas cambios sobre infraestructura existente, y ahí es donde el estado te pasa factura. Un módulo mal fronterizado se nota cuando un <code>plan</code> quiere destruir y recrear algo que solo querías tocar de refilón, porque moviste un recurso de sitio o cambiaste un <code>count</code> por un <code>for_each</code>. Migrando a OpenTofu aprendí a tratar la estructura del estado como parte del diseño, no como un detalle: los <code>moved</code> blocks existen precisamente para refactorizar sin destruir, y usarlos con cuidado es lo que te deja reorganizar módulos sin un susto en producción.</p>
<p>Elegí módulos pequeños y de frontera clara sabiendo el precio: hay más piezas que componer en la raíz y la composición es más verbosa que un mega-módulo con flags. A cambio, cada pieza es entendible, testeable y sustituible por su cuenta, y un cambio en una no amenaza con recrear media infraestructura. Para código que va a vivir años y sobre el que aplico cambios cada semana, esa previsibilidad vale mucho más que la comodidad de escribir menos líneas el primer día.</p>
<h2>Dónde pongo el esfuerzo hoy</h2>
<p>Si tuviera que resumir todo en una frase: diseña módulos pensando en cómo se van a componer y a cambiar, no en cuánto escribes hoy. La infraestructura como código no es un script que ejecutas una vez, es una base de código que mantienes, y sufre los mismos males que cualquier otra —acoplamiento, ramas sin probar, contratos que se rompen— con el agravante de que aquí un error puede tirar un servicio. Menos opciones, fronteras claras y salidas tratadas como API es lo que hace que un módulo siga siendo tuyo dentro de seis meses en vez de ser el trozo del repo que nadie quiere tocar.</p>
]]></content:encoded>
    </item>
    <item>
      <title>RAG en producción: el chunking importa más que el modelo</title>
      <link>https://yohangel.com/blog/rag-chunking-produccion/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/rag-chunking-produccion/</guid>
      <description>Cómo partes tus documentos decide más sobre la calidad de tu RAG que el LLM que elijas encima.</description>
      <pubDate>Mon, 06 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>IA</category>
      <content:encoded><![CDATA[<p><strong>Cuando monté el primer RAG serio para búsqueda semántica en JXBS, me pasé días comparando modelos de embeddings y probando LLMs cada vez más grandes encima del retriever. El salto de calidad real no vino de ahí. Vino de arreglar cómo partía los documentos antes de indexarlos. El chunking —esa parte aburrida que todo el mundo resuelve con un <code>split</code> por caracteres y sigue— es la decisión que más determina si tu RAG responde bien o alucina con seguridad. Este artículo es lo que aprendí a fuerza de recuperar fragmentos inútiles.</strong></p>
<h2>El retriever solo puede recuperar lo que indexaste bien</h2>
<p>Un RAG tiene dos mitades: recuperar los fragmentos relevantes y generar una respuesta a partir de ellos. La segunda mitad es la que se lleva toda la atención porque es donde vive el LLM vistoso. Pero la generación está limitada por lo que le llega: si el retriever te trae tres fragmentos mediocres, ningún modelo, por grande que sea, va a inventar el contexto que falta. Como mucho lo va a fingir, que es peor.</p>
<p>Y lo que el retriever puede recuperar depende por completo de cómo cortaste el texto. Un embedding representa el significado de un fragmento entero. Si ese fragmento mezcla dos ideas distintas, su vector queda en un punto intermedio que no representa bien a ninguna de las dos, y la búsqueda por similitud lo pasa por alto justo cuando lo necesitas. El problema no es el modelo de embeddings: es que le diste a codificar un trozo de texto sin una idea clara dentro.</p>
<h2>Partir por caracteres es el error por defecto</h2>
<p>La receta que trae casi todo tutorial es cortar cada 1000 caracteres con 200 de solape. Es cómodo y es lo que hice al principio. El problema es que 1000 caracteres no significan nada semánticamente: el corte cae a mitad de una frase, separa una definición de su ejemplo, o mete el final de una sección y el principio de la siguiente en el mismo chunk. Acabas con vectores que representan fragmentos partidos por la mitad.</p>
<p>El primer arreglo barato es partir por estructura, no por longitud. Los documentos ya vienen con fronteras semánticas: párrafos, encabezados, ítems de lista. Cortar respetando esas fronteras hace que cada chunk tenga una idea razonablemente completa dentro.</p>
<pre><code class="language-typescript">// En vez de trocear a ciegas cada N caracteres,
// respeta las fronteras naturales del documento
function chunkByStructure(markdown: string): string[] {
  // Parte por encabezados de sección primero
  const sections = markdown.split(/\n(?=#{1,3}\s)/);

  return sections.flatMap((section) =&gt; {
    // Si una sección es corta, es un chunk entero
    if (section.length &lt;= 1200) return [section];
    // Si es larga, subdivide por párrafos, no por caracteres
    return section
      .split(/\n\n+/)
      .reduce&lt;string[]&gt;((acc, para) =&gt; {
        const last = acc[acc.length - 1];
        if (last &amp;&amp; (last + &#39;\n\n&#39; + para).length &lt;= 1200) {
          acc[acc.length - 1] = last + &#39;\n\n&#39; + para;
        } else {
          acc.push(para);
        }
        return acc;
      }, []);
  });
}
</code></pre>
<p>No es sofisticado, pero el salto de calidad respecto al corte ciego es inmediato porque cada vector pasa a representar una unidad de sentido y no un pedazo arbitrario.</p>
<h2>El tamaño del chunk es un trade-off, no un número mágico</h2>
<p>Aquí no hay un valor correcto, hay una tensión que tienes que resolver según tu caso. Chunks pequeños dan embeddings muy precisos —el vector representa una idea concreta— pero fragmentan el contexto: la respuesta a una pregunta puede quedar repartida en cinco trozos y el retriever solo te trae los tres más parecidos. Chunks grandes conservan contexto pero diluyen el embedding: cuanto más texto metes, más se promedia el significado y menos discrimina la búsqueda.</p>
<p>Mi regla práctica es partir de chunks medianos, del tamaño de un par de párrafos que traten un solo subtema, y ajustar mirando qué recupera el sistema en consultas reales. Si veo que las respuestas correctas quedan cortadas, subo el tamaño; si veo que se recuperan fragmentos que hablan de varias cosas a la vez, lo bajo. El solape entre chunks contiguos ayuda a que una idea a caballo entre dos trozos no se pierda, pero el solape no arregla un corte hecho en el sitio equivocado.</p>
<blockquote>
<p>💡 No optimices el tamaño del chunk en abstracto. Escribe diez consultas reales de tu producto, mira qué fragmentos recupera el sistema para cada una, y ajusta a partir de eso. El eval manual de diez casos te dice más que cualquier heurística.</p>
</blockquote>
<h2>Metadatos y contexto: el chunk no vive solo</h2>
<p>Un fragmento arrancado del medio de un documento pierde de dónde salió. &quot;El plazo es de treinta días&quot; no significa nada sin saber plazo de qué. Por eso a cada chunk le adjunto metadatos —título del documento, sección, fecha— y cuando puedo antepongo una línea de contexto al propio texto que se embebe, de modo que el vector también capture a qué pertenece el fragmento. Ese pequeño encabezado hace que fragmentos ambiguos se vuelvan recuperables por la consulta correcta.</p>
<p>Los metadatos además te dan filtrado previo. En pgvector puedo combinar la búsqueda por similitud con un <code>WHERE</code> sobre columnas normales, y acotar el espacio de búsqueda antes de comparar vectores.</p>
<pre><code class="language-sql">SELECT id, contenido
FROM chunks
WHERE documento_tipo = &#39;contrato&#39;
  AND idioma = &#39;es&#39;
ORDER BY embedding &lt;=&gt; $1
LIMIT 5;
</code></pre>
<p>Filtrar por metadatos antes de ordenar por distancia no solo mejora la precisión: reduce el conjunto sobre el que corre la búsqueda vectorial y con ello la latencia.</p>
<h2>Dónde pongo el esfuerzo hoy</h2>
<p>Si tuviera que repartir el tiempo de nuevo, dedicaría la mayor parte a la fase de ingesta —cómo parto, qué metadatos guardo, cómo enriquezco cada chunk con su contexto— y mucho menos a probar modelos. El modelo de embeddings importa, y el LLM generador importa, pero ambos operan sobre lo que la ingesta les prepara. Elegí invertir en chunking en vez de en un modelo más grande porque el chunking es barato de iterar y su efecto es multiplicador: mejora cada consulta que el sistema atienda, hoy y en el futuro. A cambio, es trabajo poco vistoso y difícil de presumir. Me sale a cuenta igual.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Modelar el dominio con tipos: cuando el compilador escribe tus tests</title>
      <link>https://yohangel.com/blog/typescript-tipos-dominio/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/typescript-tipos-dominio/</guid>
      <description>Un buen modelo de tipos hace que los estados imposibles no compilen. Así uso TypeScript para eso.</description>
      <pubDate>Mon, 06 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>Frontend</category>
      <content:encoded><![CDATA[<p><strong>La mayoría del TypeScript que veo usa los tipos como una capa de autocompletado: un <code>interface</code> por aquí, un <code>any</code> por allá cuando molesta, y a correr. Funciona, pero desperdicia lo más valioso que ofrece el lenguaje. Si modelas bien el dominio, el compilador deja de ser un corrector ortográfico y pasa a ser el que impide que existan estados que no deberían existir. En los paneles y PWAs que construyo, un montón de bugs que antes cazaba con tests o en revisión ahora ni siquiera compilan. Este artículo es cómo llegué a usar el sistema de tipos como parte del diseño, no como decoración.</strong></p>
<h2>Los estados imposibles no deberían ser representables</h2>
<p>El patrón que más me cambió la forma de escribir código es este: si un estado no puede ocurrir en tu dominio, haz que tampoco pueda escribirse en tu tipo. El ejemplo canónico es el clásico &quot;cargando / cargado / error&quot;. La versión ingenua lo modela con campos opcionales sueltos:</p>
<pre><code class="language-typescript">// El tipo permite estados que no tienen sentido:
// loading true y data presente a la vez, o error con data.
type State = {
  loading: boolean;
  data?: Resultado;
  error?: Error;
};
</code></pre>
<p>Ese tipo admite <code>{ loading: true, data: algo, error: otraCosa }</code>, un estado que jamás debería existir pero que el compilador acepta encantado. Cada componente que lo consume tiene que acordarse de comprobar las combinaciones a mano, y el día que a alguien se le olvida, aparece un bug. La unión discriminada cierra la puerta:</p>
<pre><code class="language-typescript">// Ahora cada variante lleva exactamente los datos que tiene,
// y ninguna combinación imposible es representable.
type State =
  | { status: &#39;loading&#39; }
  | { status: &#39;success&#39;; data: Resultado }
  | { status: &#39;error&#39;; error: Error };
</code></pre>
<p>Con esta versión, acceder a <code>data</code> sin comprobar antes que <code>status</code> es <code>&#39;success&#39;</code> no compila. El compilador te obliga a tratar cada caso, y el estado &quot;cargando con datos y error a la vez&quot; simplemente no puede escribirse. No he añadido un solo test y he eliminado una familia entera de bugs.</p>
<h2>Tipos marcados: no todos los strings son iguales</h2>
<p>Un <code>string</code> puede ser un id de usuario, un email o un token, y para TypeScript son intercambiables. Eso significa que pasar un id donde iba un email compila sin queja, y ese error se descubre en tiempo de ejecución o nunca. Los tipos marcados (branded types) le ponen una etiqueta al tipo para que el compilador distinga cosas que estructuralmente son iguales pero conceptualmente no.</p>
<pre><code class="language-typescript">type UserId = string &amp; { readonly __brand: &#39;UserId&#39; };
type Email = string &amp; { readonly __brand: &#39;Email&#39; };

function enviarInvitacion(to: Email) { /* ... */ }

const id = &#39;usr_123&#39; as UserId;
enviarInvitacion(id); // ❌ no compila: UserId no es Email
</code></pre>
<p>El truco es que el <code>__brand</code> no existe en tiempo de ejecución —es puro tipo, cero coste— pero fuerza a que un <code>UserId</code> y un <code>Email</code> no se confundan. Combinado con validación en la frontera (una función que valida un string y devuelve un <code>Email</code> marcado), consigues que a partir de ese punto el sistema de tipos garantice que lo que circula ya está validado. La validación deja de ser algo que &quot;espero que alguien haya hecho antes&quot;.</p>
<blockquote>
<p>💡 Cuando dudes entre validar en tiempo de ejecución o confiar en un tipo, haz ambas cosas una vez en la frontera: valida el dato de entrada y devuélvelo marcado. A partir de ahí el compilador propaga esa garantía gratis por todo el código, sin una sola comprobación más.</p>
</blockquote>
<h2>Dejar que la inferencia trabaje en lugar de anotar todo</h2>
<p>Un error frecuente es anotar tipos en todas partes por costumbre, incluso donde TypeScript los infiere mejor que tú. Anotar de más no es más seguro: acopla tu código a nombres de tipo que luego cuesta cambiar, y a veces esconde con un tipo ancho lo que la inferencia habría dejado preciso. Prefiero anotar las fronteras —las firmas públicas de funciones, los contratos entre módulos— y dejar que la inferencia haga el trabajo dentro.</p>
<pre><code class="language-typescript">// `as const` hace que el tipo inferido sea exacto,
// no un `string[]` genérico.
const TAGS = [&#39;IA&#39;, &#39;AWS&#39;, &#39;Frontend&#39;] as const;
type Tag = (typeof TAGS)[number]; // &#39;IA&#39; | &#39;AWS&#39; | &#39;Frontend&#39;
</code></pre>
<p>Ese patrón me deja tener una única fuente de verdad —el array <code>TAGS</code>— y derivar el tipo de ella. Si mañana añado un tag al array, el tipo se actualiza solo y todos los sitios que hacían un <code>switch</code> exhaustivo sobre <code>Tag</code> empiezan a dar error de compilación hasta que trato el caso nuevo. El dato y el tipo no se pueden desincronizar porque uno se deriva del otro.</p>
<h2>El coste, porque siempre lo hay</h2>
<p>Modelar el dominio con tipos ricos no es gratis. Las uniones discriminadas obligan a escribir más ramas explícitas, los tipos marcados añaden ceremonia en las fronteras, y hay un punto en el que insistir en expresar una invariante en el sistema de tipos produce firmas que nadie quiere leer. Elegí este estilo asumiendo ese coste porque en un panel que mantengo durante años el compilador es el único revisor que nunca se cansa ni se despista, y cada invariante que consigo expresar como un tipo es un test que no tengo que escribir ni mantener. Pero sé cuándo parar: si un tipo se vuelve más difícil de entender que el bug que previene, ahí una comprobación en tiempo de ejecución y un comentario honesto ganan.</p>
<h2>Dónde pongo el esfuerzo hoy</h2>
<p>Mi regla práctica es invertir en tipos allí donde el dominio tiene reglas que de verdad importan —qué estados son válidos, qué datos están validados, qué valores son intercambiables y cuáles no— y no gastar esfuerzo en tipar hasta el último detalle interno que la inferencia ya cubre. Usado así, TypeScript deja de ser una molestia que te pide anotaciones y se convierte en una herramienta de diseño: escribes el modelo una vez, y el compilador se encarga de que nadie, tú incluido dentro de seis meses, pueda usarlo mal sin enterarse.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Cursor + Claude en un equipo real: cómo integramos IA sin perder calidad</title>
      <link>https://yohangel.com/blog/cursor-claude-equipo-real/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/cursor-claude-equipo-real/</guid>
      <description>Qué automatizar, qué revisar siempre y cómo mantener el criterio del equipo cuando la IA escribe la mitad del código.</description>
      <pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>IA</category>
      <content:encoded><![CDATA[<p><strong>Cuando adoptamos Cursor y Claude en el equipo, la pregunta no era si la IA podía escribir código — ya sabíamos que sí. La pregunta era otra: ¿cómo evitamos que un equipo que produce el doble de código termine produciendo el doble de bugs?</strong></p>
<p>Después de meses trabajando así en un monorepo con miles de commits — donde una parte importante del código pasa por flujos asistidos por IA — esto es lo que funcionó, lo que falló y lo que haría distinto desde el día uno.</p>
<h2>La IA no reemplaza el criterio, lo exige</h2>
<p>El primer error que cometimos fue tratar la IA como un desarrollador junior más: darle una tarea y confiar en el resultado. El código compilaba, los tests pasaban… y aun así rompía convenciones del proyecto que nadie había escrito en ningún documento, porque vivían en la cabeza del equipo.</p>
<p>La solución fue invertir el flujo: antes de generar código, generamos contexto. Documentamos las convenciones implícitas — cómo nombramos servicios, dónde viven los tipos compartidos, qué patrones de error usamos — en archivos que las herramientas leen en cada sesión. La IA es tan buena como el contexto que le das.</p>
<blockquote>
<p>💡 Regla del equipo: si un patrón se corrige dos veces en code review, se documenta para la IA. La tercera vez no debería existir.</p>
</blockquote>
<h2>Qué automatizamos (y qué no)</h2>
<p>No todo el trabajo se beneficia igual. Nuestra división quedó así:</p>
<ul>
<li><strong>Automatizamos:</strong> scaffolding de módulos, tests unitarios de casos límite, migraciones repetitivas, refactors mecánicos entre paquetes, y la primera versión de cualquier CRUD.</li>
<li><strong>Asistimos:</strong> diseño de APIs, lógica de negocio y queries complejas — la IA propone, el humano decide.</li>
<li><strong>Nunca delegamos:</strong> decisiones de arquitectura, seguridad, permisos y todo lo que toca dinero o datos personales.</li>
</ul>
<h2>El code review cambió de forma</h2>
<p>Revisar código generado por IA no es como revisar código humano. El código de IA se ve bien — está formateado, nombrado con sentido y comentado. El peligro está en lo que parece correcto. Por eso movimos el foco del review: menos estilo, más comportamiento.</p>
<pre><code class="language-text">// Checklist de review para código asistido por IA
// 1. ¿Los casos límite son reales o inventados?
// 2. ¿Reutiliza lo que ya existe o duplicó un helper?
// 3. ¿El manejo de errores sigue nuestro patrón?
// 4. ¿Hay tests que fallan si el comportamiento cambia?
// 5. ¿Tocó algo fuera del alcance del ticket?
</code></pre>
<p>El punto 5 resultó ser el más importante: las herramientas de IA tienden a «mejorar» código vecino que nadie les pidió tocar. Un diff limpio y acotado vale más que uno brillante y extenso.</p>
<h2>Lo que haría diferente hoy</h2>
<p>Empezaría por la documentación de contexto desde el primer día, no después del primer incidente. Y establecería una métrica simple que al final es la única que importa: no cuánto código genera el equipo, sino cuánto código sobrevive seis meses en producción sin ser reescrito.</p>
<p>La IA nos hizo más rápidos, sí. Pero el cambio real fue otro: nos obligó a hacer explícito todo el conocimiento que antes era tribal. Y eso habría valido la pena incluso sin la velocidad.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Serverless sin dolor: Amplify, Lambda y DynamoDB para apps reales</title>
      <link>https://yohangel.com/blog/serverless-sin-dolor/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/serverless-sin-dolor/</guid>
      <description>La arquitectura del panel de Toyota Colombia: decisiones, trade-offs y lo que haría diferente hoy.</description>
      <pubDate>Thu, 28 May 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>AWS</category>
      <content:encoded><![CDATA[<p><strong>Cuando construimos el panel de administración de Toyota Colombia en Destiny, la pregunta no era &quot;¿serverless o no?&quot;, sino &quot;¿cómo montamos un backend que nadie tenga que cuidar de madrugada?&quot;. El panel alimentaba una PWA offline-first hecha en Next.js: catálogos, contenido, datos que los concesionarios consultaban desde el terreno. El tráfico era irregular, el equipo de operaciones no existía como tal, y pagar por servidores encendidos las 24 horas para picos ocasionales no tenía sentido. Serverless encajó por eso: por la forma del problema, no por la moda.</strong></p>
<h2>Por qué serverless tenía sentido aquí</h2>
<p>No uso serverless por defecto. Lo uso cuando el patrón de carga es irregular y el equipo es pequeño. Este proyecto cumplía las dos cosas.</p>
<ul>
<li><strong>Tráfico a ráfagas.</strong> El panel se usaba en ventanas concretas: cargas de contenido, actualizaciones de catálogo, consultas puntuales. Entre medias, casi nada. Pagar EC2 encendido para eso es quemar dinero.</li>
<li><strong>Cero equipo de plataforma.</strong> No había SREs. Con Lambda y DynamoDB no parcheo sistemas operativos, no gestiono escalado, no me despierto porque un disco se llenó.</li>
<li><strong>Aislamiento por evento.</strong> Cada request es una invocación. Un pico no tumba el servicio entero; escala hacia arriba y luego a cero.</li>
</ul>
<p>El precio de esto son otras cosas: modelar bien DynamoDB desde el día uno, vigilar los cold starts y aceptar que estás casado con AWS. Vale la pena si eres honesto con esos costos.</p>
<h2>Modelar DynamoDB: single-table y patrones de acceso</h2>
<p>El error más común con DynamoDB es tratarlo como Postgres sin joins. No lo es. En DynamoDB modelas <strong>los patrones de acceso primero</strong>, y la tabla después.</p>
<p>Para el panel usé un diseño single-table: una sola tabla, con <code>PK</code> y <code>SK</code> genéricos, y varios tipos de entidad conviviendo. Los patrones que necesitábamos eran claros: traer un vehículo por id, listar vehículos por categoría, traer el contenido asociado a un modelo.</p>
<pre><code class="language-json">{
  &quot;PK&quot;: &quot;VEHICLE#corolla-2023&quot;,
  &quot;SK&quot;: &quot;METADATA&quot;,
  &quot;type&quot;: &quot;vehicle&quot;,
  &quot;name&quot;: &quot;Corolla&quot;,
  &quot;year&quot;: 2023,
  &quot;category&quot;: &quot;sedan&quot;,
  &quot;GSI1PK&quot;: &quot;CATEGORY#sedan&quot;,
  &quot;GSI1SK&quot;: &quot;VEHICLE#corolla-2023&quot;
}
</code></pre>
<p>La clave: el acceso por id va contra <code>PK</code>/<code>SK</code>, y el listado por categoría va contra un índice secundario global (<code>GSI1PK</code>/<code>GSI1SK</code>). Nada de escaneos completos de tabla. Cada consulta es un <code>Query</code> sobre una clave de partición conocida, que es lo único que DynamoDB hace barato y predecible.</p>
<p>Reglas que me ahorraron dolor:</p>
<ol>
<li><strong>Escribe la lista de patrones de acceso antes de tocar la tabla.</strong> Si aparece uno nuevo después, casi siempre es un GSI más, no un rediseño.</li>
<li><strong>Prefijos en las claves</strong> (<code>VEHICLE#</code>, <code>CATEGORY#</code>) para que entidades distintas convivan sin chocar.</li>
<li><strong>Nunca un <code>Scan</code> en el camino caliente.</strong> Si necesitas filtrar por algo, ese algo va en una clave.</li>
</ol>
<h2>Cold starts: reales, pero domesticables</h2>
<p>Los cold starts existen y son molestos, pero en 2026 el drama está muy exagerado. En este panel, el backend eran funciones Node en Lambda detrás de API Gateway, y la latencia extra en el primer arranque nunca fue un problema de negocio: era una herramienta interna, no un checkout con SLA de milisegundos.</p>
<p>Aun así, lo que sí hago:</p>
<ul>
<li><strong>Bundle pequeño.</strong> Menos dependencias que cargar, arranque más rápido. Empaquetar con esbuild y sacar lo que no uso importa más que cualquier truco.</li>
<li><strong>Cliente de DynamoDB fuera del handler,</strong> para reutilizar la conexión entre invocaciones calientes.</li>
<li><strong>Memoria como palanca de CPU.</strong> En Lambda la CPU escala con la memoria asignada; subir de 128 a 512 MB muchas veces sale más barato <em>y</em> más rápido, porque la función termina antes.</li>
</ul>
<pre><code class="language-ts">import { DynamoDBClient } from &quot;@aws-sdk/client-dynamodb&quot;;
import { DynamoDBDocumentClient, QueryCommand } from &quot;@aws-sdk/lib-dynamodb&quot;;

// Fuera del handler: se reutiliza entre invocaciones calientes.
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));

export const handler = async (event: { category: string }) =&gt; {
  const res = await ddb.send(
    new QueryCommand({
      TableName: process.env.TABLE_NAME,
      IndexName: &quot;GSI1&quot;,
      KeyConditionExpression: &quot;GSI1PK = :pk&quot;,
      ExpressionAttributeValues: { &quot;:pk&quot;: `CATEGORY#${event.category}` },
    }),
  );
  return { items: res.Items ?? [] };
};
</code></pre>
<blockquote>
<p>💡 Con serverless no pagas por servidores; pagas por diseñar bien tus patrones de acceso desde el primer día. Esa deuda de diseño no se refactoriza barato.</p>
</blockquote>
<h2>Amplify para el frontend y auth</h2>
<p>El panel en sí lo entregamos con Amplify: hosting del frontend, auth con Cognito por debajo, y el pegamento entre el cliente y las Lambda. Para un panel interno con login, Amplify te quita semanas de plomería. No tienes que montar tú el flujo de sesiones ni el hosting con CI.</p>
<p>Es cómodo, con un precio: Amplify es opinado. Cuando quieres salirte de su camino feliz, la abstracción empieza a pesar y depurar lo que genera por debajo cuesta. Para el alcance de este proyecto, el intercambio fue bueno.</p>
<h2>Infra como código y los trade-offs de operación</h2>
<p>Todo esto vive en código. Nada de crear recursos a mano en la consola: tablas, funciones, roles IAM, todo declarado. Hoy, con la experiencia de gestionar la infra de JXBS en OpenTofu (ECS Fargate, colas, Secrets Manager, CI/CD), tengo aún más claro que <strong>el peor error es tener recursos que nadie sabe de dónde salieron.</strong> Si no está en código, no existe.</p>
<p>Lo bueno de este modelo, sin adornos:</p>
<ul>
<li>Sin servidores que parchear ni mantener.</li>
<li>Escala a cero: sin tráfico, sin factura de cómputo.</li>
<li>Menos superficie de operación para un equipo pequeño.</li>
</ul>
<p>Lo incómodo, con la misma honestidad:</p>
<ul>
<li><strong>Lock-in real.</strong> DynamoDB y Lambda no se migran a otro cloud sin reescribir.</li>
<li><strong>Depurar es distinto.</strong> No hay un servidor donde entrar; vives en logs y trazas.</li>
<li><strong>El coste puede sorprender</strong> si haces <code>Scan</code> o modelas mal: DynamoDB es baratísimo bien usado y carísimo mal usado.</li>
</ul>
<h2>Qué haría distinto hoy (2026)</h2>
<p>Volvería a elegir serverless para este caso; la forma del problema no cambió. Pero cambiaría cosas:</p>
<ul>
<li><strong>Menos Amplify, más control explícito.</strong> Hoy montaría el hosting y el auth de forma más directa y dejaría Amplify para prototipos, no para lo que va a vivir años.</li>
<li><strong>Empezaría en OpenTofu, no en la herramienta propietaria.</strong> Es lo que uso ahora en producción y no volvería atrás.</li>
<li><strong>Invertiría más temprano en observabilidad.</strong> Trazas distribuidas desde el día uno; en serverless, no verlas es volar a ciegas.</li>
</ul>
<p>Serverless sin dolor no significa serverless sin decisiones. Significa tomar las decisiones difíciles al principio —modelado de datos, IaC, observabilidad— para no pagarlas de madrugada después.</p>
]]></content:encoded>
    </item>
    <item>
      <title>PWAs offline-first: apps que funcionan cuando la señal no</title>
      <link>https://yohangel.com/blog/pwas-offline-first/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/pwas-offline-first/</guid>
      <description>Service Workers, caché inteligente y sincronización diferida para eventos con conectividad intermitente.</description>
      <pubDate>Fri, 15 May 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>Frontend</category>
      <content:encoded><![CDATA[<p><strong>En un evento de Toyota en Colombia, la conexión no es un lujo garantizado: es un pabellón lleno de gente, redes móviles saturadas y stands donde el WiFi va y viene. Ahí construí, con el equipo de Destiny, una PWA con Next.js que no dependía de la señal para funcionar. No una app &quot;tolerante a fallos de red&quot;, sino una app pensada desde el primer día para operar sin conexión y reconciliar cuando la señal volviera. Esa diferencia de enfoque —offline-first, no offline-tolerant— cambió cómo diseñamos el almacenamiento, la sincronización y, sobre todo, cómo le mostrábamos el estado a personas que no son técnicas y están atendiendo clientes en pleno stand.</strong></p>
<h2>Offline-first no es lo mismo que offline-tolerant</h2>
<p>Una app offline-tolerant asume que la red es lo normal y el offline la excepción: cuando falla, muestra un error amable y espera. Una app offline-first invierte la premisa. La fuente de verdad inmediata es local; la red es un detalle de implementación que sirve para propagar y refrescar datos cuando está disponible.</p>
<p>En el evento de Toyota esto no era una decisión estética. Un promotor registrando datos de un cliente interesado no puede quedarse mirando un spinner porque el pabellón saturó la red móvil. La captura tenía que completarse siempre, guardarse localmente y salir a servidor cuando se pudiera. El backend era serverless en AWS —Amplify, Lambda y DynamoDB— pero desde el punto de vista del frontend, el servidor podía no existir durante minutos y la app seguía siendo plenamente usable.</p>
<h2>Estrategias de caché en el service worker</h2>
<p>El service worker es el corazón de una PWA offline-first. La clave está en no usar una sola estrategia, sino elegir por tipo de recurso:</p>
<ul>
<li><strong>App shell (precache):</strong> HTML, JS, CSS y assets del cascarón se precachean en la instalación. La app arranca sin red.</li>
<li><strong>Stale-while-revalidate:</strong> para recursos que pueden estar un poco desactualizados (imágenes de catálogo, íconos). Sirvo la caché de inmediato y actualizo en segundo plano.</li>
<li><strong>Network-first con fallback a caché:</strong> para datos que quiero frescos si hay señal, pero que no pueden bloquear la experiencia si no la hay.</li>
</ul>
<pre><code class="language-js">// sw.js — enrutado por tipo de recurso
self.addEventListener(&#39;fetch&#39;, (event) =&gt; {
  const { request } = event;
  const url = new URL(request.url);

  // Datos de API: network-first con fallback a caché
  if (url.pathname.startsWith(&#39;/api/&#39;)) {
    event.respondWith(
      fetch(request)
        .then((res) =&gt; {
          const copy = res.clone();
          caches.open(&#39;api-cache&#39;).then((c) =&gt; c.put(request, copy));
          return res;
        })
        .catch(() =&gt; caches.match(request))
    );
    return;
  }

  // Assets del catálogo: stale-while-revalidate
  if (url.pathname.startsWith(&#39;/catalog/&#39;)) {
    event.respondWith(
      caches.match(request).then((cached) =&gt; {
        const network = fetch(request).then((res) =&gt; {
          caches.open(&#39;catalog-cache&#39;).then((c) =&gt; c.put(request, res.clone()));
          return res;
        });
        return cached || network;
      })
    );
    return;
  }

  // App shell: cache-first
  event.respondWith(caches.match(request).then((c) =&gt; c || fetch(request)));
});
</code></pre>
<h2>Escrituras pendientes en IndexedDB</h2>
<p>Leer offline es fácil; escribir offline es donde está el verdadero trabajo. Cada captura de datos que hacía un promotor se guardaba primero en IndexedDB como una operación pendiente, con un <code>id</code> propio generado en el cliente (un UUID), un timestamp y el payload completo. La UI daba feedback inmediato: el registro quedaba guardado, punto. No esperaba al servidor.</p>
<p>Esa cola local es lo que convierte el offline en algo confiable. Nada vive solo en memoria; si el promotor cerraba la pestaña o se quedaba sin batería, la operación seguía ahí al reabrir.</p>
<blockquote>
<p>💡 En offline-first la red no es la fuente de verdad, es solo un canal de sincronización. Si tu UI espera al servidor para confirmar algo, todavía estás construyendo una app offline-tolerant disfrazada.</p>
</blockquote>
<h2>Sincronización diferida cuando vuelve la señal</h2>
<p>Cuando la conectividad regresaba, había que vaciar esa cola. Usé la Background Sync API para delegar ese trabajo al navegador: registro un evento de sync y el propio sistema decide cuándo hay red estable para ejecutarlo, incluso si la pestaña ya no está en primer plano.</p>
<pre><code class="language-js">// Al encolar una escritura, pido un sync diferido
async function queueWrite(record) {
  await idbPut(&#39;pending-writes&#39;, record); // guarda en IndexedDB
  const reg = await navigator.serviceWorker.ready;
  if (&#39;sync&#39; in reg) {
    await reg.sync.register(&#39;flush-writes&#39;);
  }
}

// En el service worker: vaciar la cola cuando el navegador lo permita
self.addEventListener(&#39;sync&#39;, (event) =&gt; {
  if (event.tag === &#39;flush-writes&#39;) {
    event.waitUntil(flushPendingWrites());
  }
});

async function flushPendingWrites() {
  const pending = await idbGetAll(&#39;pending-writes&#39;);
  for (const record of pending) {
    const res = await fetch(&#39;/api/leads&#39;, {
      method: &#39;POST&#39;,
      headers: {
        &#39;Content-Type&#39;: &#39;application/json&#39;,
        &#39;Idempotency-Key&#39;: record.id, // el UUID del cliente
      },
      body: JSON.stringify(record),
    });
    if (res.ok) await idbDelete(&#39;pending-writes&#39;, record.id);
  }
}
</code></pre>
<h2>Conflictos e idempotencia</h2>
<p>El escenario que no puedes ignorar: la escritura sí llegó al servidor, pero la respuesta se perdió en la red antes de volver. El cliente cree que falló y reintenta. Sin protección, duplicas el registro.</p>
<p>La solución fue idempotencia con la clave del cliente. Ese UUID generado al capturar el dato viajaba como <code>Idempotency-Key</code>. En la Lambda, si ya existía un registro con esa clave, se devolvía el resultado existente en lugar de crear uno nuevo. Así, reintentar era seguro por diseño: mil reintentos producían un solo registro.</p>
<p>Para conflictos de edición sobre el mismo dato, la regla fue simple y explicable: last-write-wins por timestamp del cliente. No era CRDTs ni nada sofisticado, pero para el flujo del evento —capturas mayormente independientes— era suficiente y predecible.</p>
<h2>La UX del estado de sincronización</h2>
<p>Todo lo anterior es invisible para el promotor, y así debe ser casi siempre. Pero &quot;casi&quot; es la palabra clave. La persona en el stand necesita confianza, no detalles técnicos. Mostrábamos tres estados simples:</p>
<ul>
<li><strong>Guardado</strong> (local, sin conexión todavía): un check y &quot;guardado en el dispositivo&quot;.</li>
<li><strong>Sincronizando:</strong> un indicador discreto cuando la cola se estaba vaciando.</li>
<li><strong>Sincronizado:</strong> confirmación de que el dato ya vivía en el servidor.</li>
</ul>
<p>También mostraba un contador de &quot;pendientes por enviar&quot;. Eso le daba tranquilidad a alguien no técnico: sabía que nada se había perdido aunque no hubiera señal, y sabía cuándo todo estaba a salvo antes de cerrar el turno.</p>
<h2>Qué haría distinto en 2026</h2>
<p>Hoy repensaría un par de cosas. Primero, apoyarme más en librerías maduras en lugar de escribir tanto a mano: Workbox para las estrategias del service worker, y una capa como Dexie sobre IndexedDB en vez de la API cruda. Segundo, para la sincronización, evaluaría un motor local-first de verdad —algo con reconciliación basada en CRDTs cuando el flujo lo justifique— para no atarme a last-write-wins en casos con edición concurrente real. Y tercero, sería más deliberado con la observabilidad: registrar métricas de la cola (tamaño, edad de las operaciones, tasa de reintentos) para diagnosticar en campo, porque en un evento no tienes tiempo de abrir DevTools. La idea central, sin embargo, no la tocaría: la señal es opcional, el trabajo no.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Embeddings + pgvector: matching semántico en PostgreSQL</title>
      <link>https://yohangel.com/blog/embeddings-pgvector/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/embeddings-pgvector/</guid>
      <description>Cómo construimos el motor de matching de JXBS sin añadir una base de datos vectorial dedicada.</description>
      <pubDate>Thu, 30 Apr 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>IA</category>
      <content:encoded><![CDATA[<p><strong>Cuando arranqué el motor de matching en JXBS, la primera decisión no fue qué modelo de embeddings usar, sino dónde guardar los vectores. Y decidí no meter una base de datos vectorial dedicada. Los embeddings viven en el mismo PostgreSQL donde ya está todo lo demás: candidatos, vacantes, aplicaciones. Con la extensión pgvector, Postgres almacena, indexa y busca por similitud sin que yo tenga que sincronizar dos sistemas. Este artículo es el porqué de esa decisión y cómo la implementé de verdad.</strong></p>
<h2>Por qué me quedé en Postgres</h2>
<p>La tentación de meter Pinecone, Weaviate o Qdrant es real. Pero cada datastore nuevo es otra cosa que operar, monitorear, respaldar y mantener consistente. En un producto de reclutamiento, la similitud semántica nunca vive sola: siempre la cruzo con datos estructurados. Ubicación, seniority, disponibilidad, si la vacante sigue abierta. Si los vectores están en otro sistema, cada búsqueda se convierte en dos consultas y un join hecho a mano en el código de la aplicación.</p>
<p>Teniéndolo todo en Postgres gano tres cosas concretas. Transacciones: cuando actualizo un candidato y su embedding, o ambos entran o ninguno. Joins: filtro por columnas estructuradas y ordeno por distancia vectorial en la misma consulta. Y simplicidad operativa: un backup, un punto de monitoreo, una migración de Prisma. Con ECS Fargate y OpenTofu, cada pieza extra de infraestructura se paga en tiempo de mantenimiento.</p>
<h2>Cómo funcionan los embeddings, a nivel práctico</h2>
<p>Un embedding es un vector de números que representa el significado de un texto. Textos parecidos quedan cerca en ese espacio; textos distintos, lejos. No necesito entender la geometría de alta dimensión para usarlo: le paso el texto a un modelo de embeddings, me devuelve un array de floats, y lo guardo.</p>
<p>Lo importante es qué texto le paso. Para un candidato no le doy el CV crudo entero: armo un resumen estructurado con experiencia, tecnologías y rol. Para una vacante, el título más los requisitos reales. Basura entra, basura sale: la calidad del embedding depende de la calidad del texto que lo genera, mucho más que del modelo.</p>
<h2>Guardando vectores con pgvector</h2>
<p>pgvector añade un tipo <code>vector</code> y operadores de distancia. La tabla se ve así:</p>
<pre><code class="language-sql">CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE candidate_embedding (
  candidate_id  UUID PRIMARY KEY REFERENCES candidate(id),
  embedding     vector(1536) NOT NULL,
  seniority     TEXT NOT NULL,
  location      TEXT NOT NULL,
  is_available  BOOLEAN NOT NULL DEFAULT true,
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ON candidate_embedding
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);
</code></pre>
<p>El operador <code>&lt;=&gt;</code> es distancia coseno. Menor distancia, mayor similitud. Elijo coseno porque me interesa la dirección del vector, no su magnitud.</p>
<h2>Índice: HNSW vs IVFFlat</h2>
<p>pgvector ofrece dos tipos de índice y la diferencia importa. IVFFlat agrupa vectores en listas y busca solo en las más cercanas: se construye rápido y ocupa poco, pero su recall depende de cuántas listas visites y sufre si los datos crecen mucho. HNSW construye un grafo navegable por capas: da mejor recall con latencia más baja en consultas, a cambio de tardar más en construirse y consumir más memoria.</p>
<p>Me quedé con HNSW. En reclutamiento, un candidato relevante que no aparece es un fallo silencioso que nadie reporta pero que degrada el producto. Prefiero pagar en construcción de índice y RAM a cambio de recall estable. <code>ef_construction</code> controla la calidad del grafo al construir; en consulta, subir <code>ef_search</code> mejora recall a costa de latencia. Ahí está la palanca del trade-off, y la muevo según lo que mida, no por corazonada.</p>
<blockquote>
<p>💡 La distancia coseno te dice qué se parece; no te dice qué sirve. Esa diferencia es todo el producto.</p>
</blockquote>
<h2>Búsqueda híbrida: por qué no confío solo en el coseno</h2>
<p>Este es el punto que más me costó aprender. La similitud semántica es buena encontrando cosas parecidas, pero &quot;parecido&quot; no es &quot;correcto&quot;. Un backend senior en Caracas y uno junior en otra zona horaria pueden tener embeddings cercanos porque comparten tecnologías. El coseno no sabe que la vacante exige seniority alto y presencia local.</p>
<p>Por eso hago búsqueda híbrida: la similitud vectorial es una señal, no el veredicto. Filtro con <code>WHERE</code> sobre columnas estructuradas y combino la distancia con un score estructurado.</p>
<pre><code class="language-sql">SELECT
  c.candidate_id,
  1 - (c.embedding &lt;=&gt; $1::vector)              AS similarity,
  CASE WHEN c.seniority = $2 THEN 0.3 ELSE 0 END AS seniority_boost
FROM candidate_embedding c
WHERE c.is_available = true
  AND c.location = $3
ORDER BY (c.embedding &lt;=&gt; $1::vector)
         - CASE WHEN c.seniority = $2 THEN 0.3 ELSE 0 END
LIMIT 20;
</code></pre>
<p>El <code>WHERE</code> recorta el espacio a candidatos elegibles antes de rankear. La similitud semántica ordena dentro de ese conjunto, y un boost estructurado ajusta el orden final. Los pesos son configurables y los afino contra lo que el equipo de reclutamiento considera un buen match, no contra una métrica abstracta de coseno.</p>
<h2>Chunking y mantener los embeddings frescos</h2>
<p>Los CV largos no los meto de una sola pasada. Divido en secciones con sentido (experiencia, skills, educación) y así el embedding de &quot;experiencia backend&quot; no queda diluido por tres párrafos de hobbies. Es chunking pragmático, guiado por la estructura del documento, no por un tamaño fijo de tokens.</p>
<p>Frescura: un embedding es una foto del texto en un momento. Si el candidato actualiza su perfil o la vacante cambia requisitos, el vector queda obsoleto. Guardo un hash del texto de origen; si cambia, reencolo la regeneración. Así solo recalculo lo que de verdad cambió, y no quemo llamadas al modelo por gusto.</p>
<h2>Costos y cuándo sí sacaría una DB vectorial</h2>
<p>Generar embeddings cuesta por token, y ese es el gasto real, no el almacenamiento. Reencolar solo ante cambios de hash mantiene la factura bajo control. HNSW pesa en RAM, así que dimensiono la instancia de Postgres pensando en el índice, no solo en las filas.</p>
<p>¿Cuándo dejaría Postgres? Si llegara a cientos de millones de vectores con tráfico de búsqueda muy alto y sostenido, donde el índice ya no cabe cómodo en memoria y la latencia se vuelve el cuello de botella del negocio. A esa escala, un motor vectorial dedicado con sharding se justifica. Pero es una decisión que se toma con números reales de carga, no anticipándose a un problema que quizá nunca llega. Hasta ahí, Postgres con pgvector hace el trabajo y me deja un sistema con menos piezas que puedan romperse.</p>
]]></content:encoded>
    </item>
    <item>
      <title>De monolito a backends por audiencia: lecciones de una migración</title>
      <link>https://yohangel.com/blog/monolito-a-backends/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/monolito-a-backends/</guid>
      <description>Por qué dividimos un backend NestJS compartido y los errores de DI con pnpm que nadie te cuenta.</description>
      <pubDate>Sat, 18 Apr 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>AWS</category>
      <content:encoded><![CDATA[<p><strong>Durante casi tres años, un solo backend de NestJS nos sirvió bien. Un monolito, un despliegue, un modelo mental. Hasta que dejó de servir. No fue una caída dramática ni un incidente de producción: fue la fricción diaria de tocar el código de un portal y tener que pensar en los otros tres. En JXBS construimos un SaaS de reclutamiento AI-first, con varios portales para audiencias distintas —candidatos, empresas, administración interna— y todos vivían dentro del mismo backend. Esta es la historia de por qué lo partimos en backends por audiencia, y las cosas que nadie te advierte sobre pnpm y la inyección de dependencias de NestJS cuando lo haces.</strong></p>
<h2>Por qué un solo backend dejó de escalar con nosotros</h2>
<p>El problema nunca fue el rendimiento. Fue el acoplamiento y el radio de impacto de cada despliegue.</p>
<p>Cuando todo vive en un módulo raíz de NestJS, las fronteras se difuminan solas. Un servicio de candidatos empieza a importar algo del módulo de empresas &quot;solo por ahora&quot;. Un guard pensado para administración se cuela en una ruta pública. Y como es un único proceso, todo comparte la misma configuración, el mismo pool de conexiones a PostgreSQL, el mismo ciclo de arranque.</p>
<p>Tres síntomas concretos nos empujaron a movernos:</p>
<ul>
<li><strong>Radio de impacto en cada deploy.</strong> Un cambio en el portal de administración obligaba a redesplegar el backend entero. Si algo salía mal, se caía todo, incluido el portal público de candidatos que no había cambiado en semanas.</li>
<li><strong>Audiencias mezcladas.</strong> Distintos portales tienen distintos requisitos de autenticación, distintos límites de rate, distinta superficie de exposición. Meterlos en el mismo proceso significaba que la ruta más sensible definía el nivel de paranoia de todas.</li>
<li><strong>Cognición compartida.</strong> Cada persona del equipo tenía que cargar todo el dominio en la cabeza para tocar una parte pequeña.</li>
</ul>
<h2>Qué significa &quot;backends por audiencia&quot;</h2>
<p>La idea es simple: un backend por portal. Uno para candidatos, uno para empresas, uno para administración. Cada uno es un servicio NestJS 11 independiente, con su propio despliegue, su propia configuración y su propia superficie de API. El frontend sigue siendo Next.js 15 con React 19, y cada portal habla con su backend.</p>
<p>Lo que <strong>no</strong> cambia es la base de datos ni el dominio. Seguimos con PostgreSQL + pgvector y Prisma. El truco está en que el código compartido —entidades, lógica de dominio, utilidades, el cliente de Prisma— vive en paquetes del monorepo (Turborepo + pnpm), no duplicado en cada servicio.</p>
<pre><code class="language-text">apps/
  candidates-api/        # NestJS 11 — puerto candidatos
  companies-api/         # NestJS 11 — puerto empresas
  admin-api/             # NestJS 11 — puerto administración
  web-candidates/        # Next.js 15
  web-companies/         # Next.js 15
packages/
  domain/                # lógica de dominio pura, sin NestJS
  database/              # Prisma client + repos
  auth/                  # módulo de auth reutilizable
  config/                # carga y validación de env
</code></pre>
<p>La regla que nos salvó: los paquetes de <code>packages/</code> exponen clases y funciones; los <code>apps/</code> deciden cómo cablearlas. El dominio no sabe en qué backend corre.</p>
<h2>Las trampas de pnpm + inyección de dependencias que nadie menciona</h2>
<p>Aquí es donde perdí horas. NestJS asume que un provider es un singleton dentro de su contenedor. pnpm, con su estructura de <code>node_modules</code> estricta y sus symlinks, puede romper esa suposición sin decir nada.</p>
<p>El primer golpe: <strong>instancias de provider duplicadas</strong>. Si un paquete compartido declara un provider y dos módulos distintos lo importan por caminos ligeramente diferentes, terminas con dos instancias. Un &quot;singleton&quot; que no lo es. Lo notas cuando un caché en memoria o un pool de conexiones se comporta de forma inconsistente.</p>
<p>La causa casi siempre es la misma: el token del provider no es idéntico entre importadores, o la dependencia compartida quedó instalada en dos sitios del árbol.</p>
<p>El patrón que lo evita: un <strong>módulo dinámico global</strong> con un token explícito, declarado una sola vez.</p>
<pre><code class="language-ts">// packages/database/src/database.module.ts
import { Global, Module, DynamicModule } from &#39;@nestjs/common&#39;;
import { PrismaService } from &#39;./prisma.service&#39;;

export const PRISMA = Symbol(&#39;PRISMA&#39;); // token estable y único

@Global()
@Module({})
export class DatabaseModule {
  static forRoot(): DynamicModule {
    return {
      module: DatabaseModule,
      providers: [{ provide: PRISMA, useClass: PrismaService }],
      exports: [PRISMA],
    };
  }
}
</code></pre>
<p>Cada app llama a <code>DatabaseModule.forRoot()</code> <strong>una vez</strong> en su módulo raíz. El token es un <code>Symbol</code> exportado, así que no hay ambigüedad de string ni riesgo de colisión.</p>
<p>Las otras dos que me mordieron:</p>
<ul>
<li><strong>Peer deps, no deps directas.</strong> Los paquetes compartidos declaran <code>@nestjs/common</code> y <code>@nestjs/core</code> como <code>peerDependencies</code>, nunca como dependencias normales. Si un paquete trae su propia copia de NestJS, sus decoradores y metadatos viven en otro realm y la DI simplemente no los reconoce. En <code>pnpm-workspace.yaml</code>, apoyarse en el catálogo para fijar una sola versión ayuda mucho.</li>
<li><strong>Fronteras de módulo, no de carpeta.</strong> Un paquete puede exportar código; que sea un <code>@Module</code> de NestJS es una decisión aparte. Mantuvimos <code>packages/domain</code> libre de NestJS —clases y funciones puras— y solo <code>packages/auth</code> y <code>packages/database</code> exponen módulos. Así el dominio se reusa sin arrastrar el contenedor de DI a todas partes.</li>
</ul>
<blockquote>
<p>💡 En un monorepo, un &quot;singleton&quot; solo lo es si el token es idéntico y la dependencia está instalada una sola vez. pnpm no te avisa cuando no se cumple: te lo cuenta en producción.</p>
</blockquote>
<h2>Cómo compartir dominio sin volver a acoplarlo</h2>
<p>El riesgo obvio de partir el monolito es reconstruir el acoplamiento dentro de los paquetes. Un paquete <code>shared</code> que lo sabe todo es el monolito con otro nombre.</p>
<p>Lo que funcionó:</p>
<ul>
<li><strong>El dominio no importa infraestructura.</strong> <code>packages/domain</code> no conoce Prisma ni NestJS. Recibe interfaces; las apps inyectan implementaciones.</li>
<li><strong>Un paquete, una responsabilidad.</strong> <code>auth</code>, <code>database</code>, <code>config</code> por separado. Si dudas de a qué paquete pertenece algo, probablemente pertenece a la app.</li>
<li><strong>Sin dependencias entre backends.</strong> <code>candidates-api</code> nunca importa de <code>companies-api</code>. Si necesitan hablar, es por API o por un contrato compartido en <code>packages/</code>, jamás por import directo.</li>
</ul>
<h2>Desplegar varios servicios en ECS Fargate</h2>
<p>Cada backend es un servicio propio en ECS Fargate: su task definition, su imagen, su auto-scaling. La CI/CD la gestionamos con OpenTofu, así que añadir un backend es declarar un módulo nuevo, no clicar en una consola.</p>
<p>Turborepo nos da el otro pilar: builds afectados. Solo se construye y despliega lo que cambió. Tocar <code>admin-api</code> no reconstruye <code>candidates-api</code>. Ese fue el objetivo original —reducir el radio de impacto— y aquí se vuelve real.</p>
<p>El detalle que cuida el bolsillo: cada servicio dimensiona su CPU y memoria según su carga. El portal de candidatos y el de administración no tienen por qué pagar el mismo tamaño de tarea.</p>
<h2>Hacerlo sin un big-bang</h2>
<p>No reescribimos nada de golpe. Extrajimos primero un backend —el de menor riesgo— dejando el monolito sirviendo al resto. Portal por portal, movimos rutas al nuevo servicio y apuntamos el frontend correspondiente. El monolito fue encogiendo hasta desaparecer.</p>
<h2>Lo que le diría a mi yo del pasado</h2>
<p>Dibuja las fronteras de audiencia desde el día uno, aunque todo viva en un solo proceso al principio. Separar módulos con límites claros es barato; desenredar un monolito acoplado es caro. Y trata la inyección de dependencias en un monorepo pnpm como lo que es: un tema de identidad de instancias, no de imports. Fija las versiones, usa tokens explícitos, mantén el dominio puro. El resto se deja llevar.</p>
]]></content:encoded>
    </item>
    <item>
      <title>El design system como producto: mantener una librería UI compartida</title>
      <link>https://yohangel.com/blog/design-system-producto/</link>
      <guid isPermaLink="true">https://yohangel.com/blog/design-system-producto/</guid>
      <description>Versionado, tokens y disciplina de componentes cuando varios portales dependen de tu librería.</description>
      <pubDate>Thu, 02 Apr 2026 00:00:00 GMT</pubDate>
      <dc:creator>Yohangel Ramos</dc:creator>
      <category>Frontend</category>
      <content:encoded><![CDATA[<p><strong>Durante mucho tiempo traté nuestra librería de componentes como una carpeta compartida: un sitio donde dejar botones y modales para no repetirlos. El día que varios portales del monorepo empezaron a depender de ella al mismo tiempo, entendí que ya no era una carpeta. Era un producto, con usuarios reales —el resto del equipo— que sufren cada cambio mal pensado. Desde entonces la mantengo como Tech Lead en JXBS con esa mentalidad: mis usuarios son otros desarrolladores, y su experiencia importa tanto como la del usuario final.</strong></p>
<h2>Los tokens son el contrato, no un detalle</h2>
<p>Antes de hablar de componentes hay que hablar de tokens, porque son lo primero que rompes sin darte cuenta. Un token es una decisión de diseño con nombre estable: un color, un espaciado, una escala tipográfica. Si un portal escribe <code>#2563eb</code> a mano y otro usa <code>var(--color-primary)</code>, no tienes un design system, tienes dos sistemas que coinciden por accidente.</p>
<p>Yo trato los tokens como el contrato público. Los consumidores no piden colores en hexadecimal; piden intención semántica.</p>
<pre><code class="language-css">:root {
  /* Primitivos: no se usan directamente en componentes */
  --blue-600: #2563eb;
  --gray-900: #111827;
  --space-2: 0.5rem;
  --space-4: 1rem;

  /* Semánticos: esto es lo que el consumidor toca */
  --color-action: var(--blue-600);
  --color-text-strong: var(--gray-900);
  --radius-control: 0.5rem;
}

[data-theme=&quot;dark&quot;] {
  --color-text-strong: #f9fafb;
}
</code></pre>
<p>La capa semántica es lo que me permite hacer theming sin tocar un solo componente. Cambio el mapeo, no la firma. Y esa separación entre primitivos y semánticos es la que evita que alguien acople su portal a un azul concreto que mañana quiero mover.</p>
<h2>La API del componente es una promesa</h2>
<p>Con React 19 y TypeScript, la firma de un componente es un contrato tan serio como el de una API HTTP. Cada prop que expongo es algo que tendré que sostener durante versiones. Por eso prefiero composición sobre configuración: en lugar de un <code>&lt;Card&gt;</code> con veinte props booleanas, expongo piezas que se combinan.</p>
<pre><code class="language-tsx">// Mal: configuración que crece sin fin
&lt;Card title=&quot;...&quot; footer withBorder elevated dense hasIcon /&gt;

// Mejor: composición, el consumidor arma su caso
&lt;Card&gt;
  &lt;Card.Header&gt;...&lt;/Card.Header&gt;
  &lt;Card.Body&gt;...&lt;/Card.Body&gt;
  &lt;Card.Footer&gt;...&lt;/Card.Footer&gt;
&lt;/Card&gt;
</code></pre>
<p>Dos reglas que no negocio. La primera: no filtrar el DOM interno. Si expongo un <code>className</code> que se pega a un <code>&lt;div&gt;</code> cualquiera y mañana ese <code>&lt;div&gt;</code> desaparece, rompo a todo el que dependía de esa estructura. Prefiero exponer puntos de extensión explícitos (<code>asChild</code>, slots, variantes tipadas) antes que dejar que la gente estilice mis entrañas. La segunda: las props estables son sagradas. Renombrar <code>variant</code> a <code>kind</code> &quot;porque suena mejor&quot; no es un refactor, es romper a seis equipos.</p>
<blockquote>
<p>💡 Un design system no se mide por cuántos componentes tiene, sino por cuánta gente puede construir encima sin escribirte un mensaje.</p>
</blockquote>
<h2>Versionar dentro del monorepo sin hacer daño</h2>
<p>Turborepo y pnpm hacen que todo conviva, y eso tiene una trampa: es demasiado fácil &quot;subir todo&quot; a la vez. Yo uso changesets con semver de verdad. Un cambio de color semántico es un patch. Una prop nueva opcional es un minor. Quitar una prop, renombrarla o cambiar el DOM que alguien podría estar seleccionando es un major, y un major exige aviso, guía de migración y a veces un periodo de convivencia con la versión anterior.</p>
<p>Lo que evito religiosamente es el &quot;bump everything&quot; reflejo. Si toco solo el <code>Button</code>, no quiero forzar a que un portal que ni lo usa se recompile y revalide. El versionado granular es lo que mantiene la confianza: cuando actualizas, sabes qué te va a doler antes de que duela.</p>
<h2>Accesibilidad de fábrica y el lado social</h2>
<p>En Acid Tango, en Madrid, empujé WCAG 2.1 AA como estándar de equipo, y esa experiencia cambió cómo construyo librerías. La mejor accesibilidad es la que el consumidor recibe gratis. Si mi <code>Dialog</code> gestiona el foco, el <code>Escape</code>, el <code>aria-modal</code> y el retorno del foco al disparador, entonces cada portal es accesible sin que su desarrollador sepa nada de ARIA. El teclado, el manejo de foco y el contraste no son features opcionales: son el suelo.</p>
<p>Pero la parte más difícil no es técnica, es social. Un design system tiene gobernanza aunque nadie la escriba. Yo intento que sea explícita: quién decide qué entra, cómo se propone un componente nuevo, y —lo más incómodo— cómo se dice que no. Digo que no cuando algo sirve a un solo portal; eso vive en el portal, no en el core. Para onboarding de un componente nuevo pido tres cosas: un caso de uso real repetido, documentación con ejemplos copiables, y accesibilidad resuelta antes de mezclar. Sin ejemplos, la gente reinventa; y cada reinvención es una grieta en el contrato.</p>
<h2>Qué haría distinto en 2026</h2>
<p>Documentaría antes de codear. Durante un tiempo la documentación llegaba después, y eso es tarde: si no puedes explicar la API en un ejemplo corto, la API está mal. Hoy escribiría el ejemplo de uso primero y dejaría que él dictara la firma.</p>
<p>También sería más agresivo con la deprecación graduada. Marcar una prop como <code>@deprecated</code> en TypeScript, dejarla funcionar unas versiones y avisar en cada build es infinitamente más amable que un major sorpresa. Un design system maduro no se mide por lo rápido que cambia, sino por lo predecible que es al hacerlo.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
