# Oriol Martí
> Senior Backend & Cloud Engineer. Senior backend engineer and SaaS founder. I design and build distributed systems, AWS cloud architectures and digital products from scratch.
Source: https://urimarti.com. Content in Spanish and English.
## Profile (es)
Senior Backend & Cloud Engineer. Ingeniero backend senior y SaaS founder. Diseño y construyo sistemas distribuidos, arquitecturas cloud en AWS y productos digitales desde cero.
### Stack
- Backend & Arquitectura: TypeScript, Node.js, NestJS, Symfony, PHP, Ruby, Microservices, Event-driven
- Frontend: Next.js, React, Vue.js, CSS
- Cloud & DevOps: AWS Lambda, API Gateway, S3, EC2, Cognito, CDK, CI/CD
- Data & Caching: PostgreSQL, MySQL, MongoDB, Redis
- Integraciones: Shopify API, Salesforce, ERP, Payment gateways
### Experience
- The Knot Worldwide: Senior Full Stack Developer (2023 — actualidad)
- Viaplay Group: Senior Full Stack Developer (2023)
- Netenders: Senior Full Stack Developer (2022 — 2023)
- Biciescapa: Tech Lead & Full Stack Developer (2018 — 2022)
- Flick Hockey: Tech Lead & Full Stack Developer (2013 — 2018)
- GrupoZona BCN: Diseñador y programador (2012 — 2013)
- Illuminated Sing: Responsable de diseño (2008 — 2015)
### Languages
- Catalán (nativo)
- Español (nativo)
- Inglés (profesional)
### Projects
#### ecom-addons (https://ecom-addons.com)
Tienda de addons y themes para PrestaShop, WooCommerce y Drupal Commerce: rendimiento, conversión y cumplimiento normativo de la UE, con pago único, todas las versiones incluidas y demos en tiendas reales.
- Web estática en Astro en tres idiomas, área de cliente en React con TanStack Router/Query y API en Hono + zod sobre Lambda, todo detrás de una sola distribución de CloudFront y desplegado con AWS CDK.
- Login con Cognito (contraseña o Google), pagos con Stripe Checkout y Stripe Tax, licencias y descargas versionadas e inmutables en S3, y tickets de soporte ligados al periodo de cada licencia.
- Addons para rendimiento (caché de página, búsqueda y filtros instantáneos), conversión y normativa de la UE (Omnibus, GPSR, botón de desistimiento, garantía legal), con la lógica compartida entre plataformas y tests con PHPUnit y Playwright.
- Demos en tiendas PrestaShop reales que se preparan bajo demanda en EC2, y correos de ciclo de vida (bienvenida, reseñas, venta cruzada) enviados por un CRM propio a partir de eventos de la API.
Stack: Astro, React, Hono, AWS Lambda, DynamoDB, Cognito, Stripe, PHP, PrestaShop, WooCommerce, Drupal
#### airinfoguide (https://airinfoguide.com)
Guía de aeropuertos y viajes: salidas y llegadas en directo, cómo llegar a la ciudad, coches, parking y alojamiento, itinerarios día a día, y un pasaporte viajero, registro de vuelos y planificador de viajes.
- Web estática en Astro con React solo en las partes interactivas: más de 17.000 páginas en cinco idiomas con canonical, hreflang y datos estructurados (Airport, City, TouristDestination, FAQPage) en cada una.
- API de vuelos en Hono sobre Lambda, servida por CloudFront en /api/* del mismo dominio y protegida con OAC: 93 fuentes oficiales convertidas a un mismo tipo de vuelo, con codeshares agrupados y horas en UTC.
- Cuentas con email o Google, sincronización entre dispositivos, avisos de vuelos por push, app instalable (PWA), WAF y CSP con hashes generada en el build.
- Itinerarios de 3, 5 y 7 días para 95 ciudades, fotos de Wikimedia Commons con sus créditos y enlaces de afiliado (coches, traslados, parking, alojamiento) generados en el build.
Stack: Astro, React, Hono, AWS Lambda, CloudFront, AWS CDK, SEO, PWA
#### fita (https://fitaread.com)
Ayudas de lectura libres y gratuitas para personas con TDAH y dislexia: destacan suavemente el inicio de cada palabra, en la web y en tus ebooks, y todo ocurre en tu dispositivo.
- Núcleo en TypeScript que decide qué parte de cada palabra se destaca y la aplica al DOM, publicado como monorepo con pnpm.
- Extensión de navegador (Manifest V3) para Chrome, Edge y Firefox, incluido Firefox para Android; se activa y desactiva con Alt+B.
- Web estática en Astro y 27 idiomas con demo en vivo, fichas de lectura para niños y un conversor de EPUB, TXT, Markdown y HTML que funciona en el navegador.
- Tests con Vitest y Playwright, lint con Biome, sin analítica ni cookies, código bajo Apache-2.0 y textos bajo CC BY 4.0.
Stack: TypeScript, Astro, WebExtension, Open source, Accessibility, Vitest, Playwright
#### Festam (https://festam.cat)
Mapa de las fiestas mayores en tiempo real: los actos sobre el mapa con un deslizador de hora y la ubicación compartida con tus amigos. Los ayuntamientos compran una licencia y sus entidades gestionan los actos.
- API en NestJS desplegada como Lambda detrás de una HTTP API, con infraestructura en AWS CDK y capas Controller → Facade → servicios → repositorios comprobadas por lint.
- Las lecturas anónimas nunca llegan a Lambda ni a MongoDB: la API escribe un modelo de lectura en JSON en S3, dividido en celdas geográficas de 0,5°, que sirve CloudFront y se invalida en cada cambio.
- MongoDB con índices 2dsphere para fiestas y actos; DynamoDB de tabla única para los datos de cada usuario y la ubicación compartida, que llega a los amigos por WebSocket.
- Bedrock convierte el programa de fiestas (PDF o imagen) en actos estructurados. Panel en React para ayuntamientos y entidades, app PWA con Expo y web en Astro en catalán, castellano e inglés.
Stack: NestJS, AWS Lambda, CDK, MongoDB, DynamoDB, CloudFront, Bedrock, Expo, Astro
#### Build the Drop (https://buildthedrop.com)
SaaS de dropshipping que conecta AliExpress con Shopify: importa productos, sincroniza stock y precios, y tramita y paga los pedidos automáticamente.
- Backend en NestJS desplegado como Lambda detrás de API Gateway, con infraestructura en AWS CDK.
- Cada evento (pedido creado, cambio de stock, webhook de Stripe) entra en SQS y lo procesa su propio handler; un cron de EventBridge lanza la sincronización de stock.
- Panel en React con TanStack Router y Query, app embebida de Shopify con Polaris y web pública en Astro.
- MongoDB, Cognito para la autenticación, Stripe para las suscripciones y Bedrock para generar contenido de producto.
Stack: NestJS, AWS Lambda, SQS, CDK, React, MongoDB, Shopify API, Stripe
#### AliExpress Reviews (https://aliexpress-reviews.com)
API de pago para consultar las reviews de cualquier producto de AliExpress, con los mismos filtros que la ficha del producto y planes FREE, PRO y MAX.
- Lambdas en TypeScript (Node 22, ARM) sobre HTTP API, con un authorizer que valida la API key y la cachea 5 minutos.
- DynamoDB de tabla única: la cuota diaria es un solo UpdateItem condicional por petición.
- Cada respuesta se cachea 24 h en S3, así cada petición hace como mucho una llamada a AliExpress.
- Web en Astro en seis idiomas sobre S3 y CloudFront, login con Cognito, pagos con Stripe y despliegue con CDK desde GitHub Actions (OIDC).
Stack: AWS Lambda, DynamoDB, S3, CDK, Astro, Stripe
### Case studies
#### Rendimiento y resiliencia a escala (The Knot Worldwide)
Equipo de cuatro ingenieros a cargo del backend y parte del frontend de una plataforma e-commerce con más de 2M de peticiones al mes.
- Reducción del 90% en el tiempo de respuesta del servicio principal.
- Readstore interno para ganar control y resiliencia sobre los datos entre servicios.
- Deuda técnica reducida un 80% y estándares de programación para todo el departamento.
- Herramientas internas que aceleran hasta un 95% la creación de contenido de otros equipos.
Stack: TypeScript, NestJS, Next.js, MongoDB, Redis
#### Pagos globales (Viaplay Group)
Formé parte de la creación del equipo de payments de la plataforma de streaming.
- Simplificación del sistema de pagos para ganar funcionalidad y eficiencia.
- Integración de nuevos instrumentos de pago, ampliando opciones y potencial de ingresos.
Stack: Payments, Full stack
#### E-commerce omnicanal con ERP y TPV propio (Biciescapa · Tech Lead)
Liderazgo técnico del departamento de desarrollo del e-commerce durante más de cuatro años.
- Conexión con el ERP y sincronización de stock en tiempo real.
- Punto de venta (TPV) a medida para las tiendas físicas con Vue.js y PHP.
- Herramientas en PHP y Node.js para crear y manipular el catálogo online y offline.
Stack: PHP, Node.js, Vue.js, ERP
#### Integraciones a medida (Netenders)
Desarrollo sobre varios stacks e-commerce y sus integraciones con terceros.
- Backend en Symfony sobre Sylius.
- Integración con Salesforce 100% a medida en Ruby.
- Scripts en Node.js contra las APIs de Shopify.
Stack: Symfony, Ruby, Salesforce, Shopify API
## Profile (en)
Senior Backend & Cloud Engineer. Senior backend engineer and SaaS founder. I design and build distributed systems, AWS cloud architectures and digital products from scratch.
### Stack
- Backend & Architecture: TypeScript, Node.js, NestJS, Symfony, PHP, Ruby, Microservices, Event-driven
- Frontend: Next.js, React, Vue.js, CSS
- Cloud & DevOps: AWS Lambda, API Gateway, S3, EC2, Cognito, CDK, CI/CD
- Data & Caching: PostgreSQL, MySQL, MongoDB, Redis
- Integrations: Shopify API, Salesforce, ERP, Payment gateways
### Experience
- The Knot Worldwide: Senior Full Stack Developer (2023 — present)
- Viaplay Group: Senior Full Stack Developer (2023)
- Netenders: Senior Full Stack Developer (2022 — 2023)
- Biciescapa: Tech Lead & Full Stack Developer (2018 — 2022)
- Flick Hockey: Tech Lead & Full Stack Developer (2013 — 2018)
- GrupoZona BCN: Designer & developer (2012 — 2013)
- Illuminated Sing: Head of design (2008 — 2015)
### Languages
- Catalan (native)
- Spanish (native)
- English (professional)
### Projects
#### ecom-addons (https://ecom-addons.com)
An addon and theme store for PrestaShop, WooCommerce and Drupal Commerce: performance, conversion and EU compliance, with a one-off payment, every version included and demos on real stores.
- Static Astro site in three languages, a React customer area with TanStack Router/Query and a Hono + zod API on Lambda, all behind a single CloudFront distribution and deployed with AWS CDK.
- Cognito login (password or Google), payments with Stripe Checkout and Stripe Tax, versioned, immutable licences and downloads on S3, and support tickets tied to each licence’s period.
- Addons for performance (page cache, instant search and filters), conversion and EU regulation (Omnibus, GPSR, withdrawal button, legal guarantee), with logic shared across platforms and tests with PHPUnit and Playwright.
- Demos on real PrestaShop stores prepared on demand on EC2, and lifecycle emails (welcome, reviews, cross-sell) sent by an in-house CRM from API events.
Stack: Astro, React, Hono, AWS Lambda, DynamoDB, Cognito, Stripe, PHP, PrestaShop, WooCommerce, Drupal
#### airinfoguide (https://airinfoguide.com)
An airport and travel guide: live departures and arrivals, getting into town, cars, parking and stays, day-by-day itineraries, plus a traveller passport, flight log and trip planner.
- Static Astro site with React only in the interactive parts: more than 17,000 pages in five languages, each with canonical, hreflang and structured data (Airport, City, TouristDestination, FAQPage).
- Flights API in Hono on Lambda, served by CloudFront on /api/* of the same domain and protected with OAC: 93 official sources converted to one flight type, with codeshares grouped and times in UTC.
- Accounts with email or Google, sync across devices, push flight alerts, an installable app (PWA), WAF and a hash-based CSP generated at build time.
- 3, 5 and 7-day itineraries for 95 cities, Wikimedia Commons photos with their credits, and affiliate links (cars, transfers, parking, stays) generated at build time.
Stack: Astro, React, Hono, AWS Lambda, CloudFront, AWS CDK, SEO, PWA
#### fita (https://fitaread.com)
Free, open-source reading aids for people with ADHD and dyslexia: they gently emphasize the start of each word, on the web and in your ebooks, and everything happens on your device.
- TypeScript core that decides which part of each word to emphasize and applies it to the DOM, in a pnpm monorepo.
- Browser extension (Manifest V3) for Chrome, Edge and Firefox, including Firefox for Android; toggled with Alt+B.
- Static Astro site in 27 languages with a live demo, reading worksheets for children and an in-browser converter for EPUB, TXT, Markdown and HTML.
- Tests with Vitest and Playwright, Biome for linting, no analytics or cookies, code under Apache-2.0 and texts under CC BY 4.0.
Stack: TypeScript, Astro, WebExtension, Open source, Accessibility, Vitest, Playwright
#### Festam (https://festam.cat)
A live map of local festivals: events on a map with a time slider, and your location shared with friends. Councils buy a licence and their local groups manage the events.
- NestJS API deployed as a Lambda behind an HTTP API, with infrastructure in AWS CDK and Controller → Facade → services → repositories layers enforced by lint.
- Anonymous reads never reach Lambda or MongoDB: the API writes a JSON read model to S3, split into 0.5° geographic cells, served by CloudFront and invalidated on every change.
- MongoDB with 2dsphere indexes for festivals and events; single-table DynamoDB for each user’s data and shared locations, pushed to friends over WebSocket.
- Bedrock turns the festival programme (PDF or image) into structured events. React dashboard for councils and groups, an Expo PWA and an Astro site in Catalan, Spanish and English.
Stack: NestJS, AWS Lambda, CDK, MongoDB, DynamoDB, CloudFront, Bedrock, Expo, Astro
#### Build the Drop (https://buildthedrop.com)
Dropshipping SaaS that connects AliExpress with Shopify: it imports products, syncs stock and prices, and places and pays orders automatically.
- NestJS backend deployed as a Lambda behind API Gateway, with infrastructure in AWS CDK.
- Every event (order created, stock change, Stripe webhook) goes into SQS and is processed by its own handler; an EventBridge cron triggers the stock sync.
- React dashboard with TanStack Router and Query, embedded Shopify app with Polaris, and a public site in Astro.
- MongoDB, Cognito for auth, Stripe for subscriptions and Bedrock to generate product content.
Stack: NestJS, AWS Lambda, SQS, CDK, React, MongoDB, Shopify API, Stripe
#### AliExpress Reviews (https://aliexpress-reviews.com)
Paid API to fetch the reviews of any AliExpress product, with the same filters as the product page and FREE, PRO and MAX plans.
- TypeScript Lambdas (Node 22, ARM) on an HTTP API, with an authorizer that validates the API key and caches it for 5 minutes.
- Single-table DynamoDB: the daily quota is a single conditional UpdateItem per request.
- Every response is cached for 24 h in S3, so each request makes at most one call to AliExpress.
- Astro site in six languages on S3 and CloudFront, Cognito login, Stripe billing and CDK deploys from GitHub Actions (OIDC).
Stack: AWS Lambda, DynamoDB, S3, CDK, Astro, Stripe
### Case studies
#### Performance and resilience at scale (The Knot Worldwide)
Four-engineer team owning the backend and part of the frontend of an e-commerce platform serving 2M+ requests a month.
- 90% faster response time on the core service.
- Internal readstore for more control and resilience over cross-service data.
- Technical debt cut by 80% and coding standards set for the whole department.
- Internal tools that speed up other teams’ content creation by up to 95%.
Stack: TypeScript, NestJS, Next.js, MongoDB, Redis
#### Global payments (Viaplay Group)
Helped build the payments team of the streaming platform.
- Simplified the payment system for better functionality and efficiency.
- Integrated new payment methods, widening user options and revenue potential.
Stack: Payments, Full stack
#### Omnichannel e-commerce with ERP and custom POS (Biciescapa · Tech Lead)
Technical lead of the e-commerce development department for over four years.
- ERP integration and real-time stock sync.
- Custom point of sale for the physical stores, built with Vue.js and PHP.
- PHP and Node.js tools to create and manage the catalogue online and offline.
Stack: PHP, Node.js, Vue.js, ERP
#### Custom integrations (Netenders)
Development across several e-commerce stacks and their third-party integrations.
- Symfony backend on Sylius.
- Fully custom Salesforce integration in Ruby.
- Node.js scripts against the Shopify APIs.
Stack: Symfony, Ruby, Salesforce, Shopify API
## Contact
- Email: mailto:hola@urimarti.com
- LinkedIn: https://www.linkedin.com/in/oriol-marti-collell/
- GitHub: https://github.com/urimarti2610
## Blog posts
### SEO in the age of AI: the basics still rule
> Four SEO fundamentals that haven't changed, the files search engines and agents read, and how to measure it all in Search Console and Analytics.
- URL: https://urimarti.com/en/blog/seo-en-la-era-de-la-ia/
- Date: 2026-11-05
- Language: en
- Tags: seo, ia, web
- Author: Oriol Martí
I've been working on search engine optimization for many years and I've seen the rules change several times: from keywords repeated ad nauseam to links, then mobile experience, then speed, and now AI-generated summaries right on the results page.
What surprises me most about this stage isn't AI itself, but **speed**. New content used to take weeks or months to settle. Today I've seen well-made articles land near the top **within days**. And the opposite too: pages that drop just as fast when something technical breaks.
#### Thesis
My thesis is that **AI hasn't changed the fundamentals of SEO; it has raised the cost of skipping them**. Search engines and the models reading your site need the same as always: to understand what each page is about, to trust that the content answers the question, and to load it fast. What has changed is that there are now more automated readers, and some of them aren't Google.
#### The four fundamentals
##### 1. Always a meaningful H1 and H2
Every page has **a single `
`** stating what it's about, and the content is organized with `` (and `` if needed) like a book's table of contents.
It sounds obvious, but I still find sites with the logo as ``, three ``s on the same page, or headings chosen by font size rather than hierarchy. For a model summarizing your page, headings are the map: if the map is wrong, so is the summary.
```text
h1 SEO in the age of AI
├─ h2 The four fundamentals
│ ├─ h3 H1 and H2
│ └─ h3 Titles and descriptions
├─ h2 The files machines read
└─ h2 Measuring
```
##### 2. Unique titles and descriptions
Every page needs its own `` and its own meta description. **Unique**, not generated from a template that only swaps one word.
The title carries the most weight for the search engine to understand the page, and it's the first thing a person reads in the results. The description doesn't rank on its own, but it decides whether people click. Two pages with the same title tell Google they compete for the same thing, and usually both lose.
My rule of thumb: a title of about 60 characters with the important part first, and a description of about 155 that answers "why should I go in here?".
##### 3. "Long" content
I put it in quotes because **it's not about word count**. A long text full of filler performs worse than a short, precise one. What works is **coverage**: answering the main question and the ones that come right after, with examples, data and first-hand experience.
With AI this matters more, not less. Anyone can generate a thousand generic words in seconds, so that no longer sets anyone apart. What still does is what a model can't make up: **your experience, your cases and your well-argued opinions**. And those are exactly the pieces I see ranking within days.
##### 4. Site performance
A slow site loses visitors before SEO even comes into play. Google measures it with **Core Web Vitals**:
| Metric | What it measures | Good |
| --- | --- | --- |
| **LCP** | How long the main content takes to appear | ≤ 2.5 s |
| **INP** | How long the page takes to respond to an interaction | ≤ 200 ms |
| **CLS** | How much the layout "jumps" while loading | ≤ 0.1 |
The causes are usually the same: huge images or images without dimensions, render-blocking fonts and JavaScript nobody needs. This site, for example, is [static HTML on S3 and CloudFront](/en/blog/web-estatica-astro-s3-cloudfront/): no server to respond and no JavaScript to hydrate, so performance comes almost for free.
#### The files machines read
Besides the pages, there are three small files that decide how search engines and agents discover and read you.
##### sitemap.xml
The list of URLs you want indexed. It guarantees nothing, but it greatly speeds up the discovery of new content, which is exactly what makes ranking within days possible.
What matters: that it's **generated automatically** on publish, that it only includes canonical URLs returning 200, and that you submit it in Search Console. If you have several languages, add the alternates with `hreflang`.
##### robots.txt
It says what bots may crawl and where the sitemap is. Two classic mistakes: blocking CSS or JavaScript (Google needs to see the page like a user does) and a forgotten `Disallow: /` left over from staging.
Now it carries a new decision: **what to do with AI crawlers**. `GPTBot`, `ClaudeBot`, `PerplexityBot`, `Google-Extended`… Blocking them stops them from using your content, but also from citing you. I let them through: I'd rather an AI recommend my work than not know it exists. Note: `Google-Extended` controls use for training Gemini, not your appearance in search results.
```text
User-agent: *
Allow: /
Sitemap: https://urimarti.com/sitemap.xml
```
##### llms.txt
A proposed standard ([llmstxt.org](https://llmstxt.org/)): a Markdown file at the site root that summarizes who you are and links to the important pages, designed so a model can read it without fighting menus, banners and JavaScript.
I'll be honest: **there's no evidence Google uses it for ranking**. But it costs very little, some agents and tools do read it, and it forces a useful exercise: explaining your site in one page. On this site it's generated from the content: [/llms.txt](/llms.txt), plus a full version at [/llms-full.txt](/llms-full.txt).
#### Measuring: Search Console and Analytics
Without measurement, all of the above is faith. I use two tools that answer different questions:
- **Google Search Console** tells you what happens **before the click**: which queries you show up for, at what position, how many impressions and clicks you get, which pages aren't indexed and why. It's the first one I open when I publish something: URL inspection lets you request indexing and see within days whether the content takes off.
- **Google Analytics** tells you what happens **after the click**: how long people stay, what they read next, whether they convert. A page with lots of clicks and zero interest isn't a success.
##### Generative AI performance
The most interesting addition to Search Console is being able to see **performance in Google's generative AI features**, such as AI Overviews and AI Mode: how often your content appears as a source in those answers and how many clicks reach you from them.
This changes how I read the data. There are queries where impressions go up and clicks go down, because the answer is already given on the results page. I used to read that as a title or description problem; now it often means **AI is using your content to answer**. The question is no longer just "what's my position?" but also "am I cited when AI answers about my topic?".
What I do with that data:
1. Compare pages that appear in AI answers with those that don't, and look for what they have in common (almost always: clear structure and direct answers).
2. Watch pages with many impressions and few clicks, and add what a summary can't give: examples, code, real cases.
3. Cross it with Analytics to see whether visitors coming from AI stay longer or shorter than those from classic search.
#### What I'd do today
1. **Review the headings** of the main templates: one `` per page and meaningful ``s.
2. **List duplicate titles and descriptions** (Search Console and any crawler find them in minutes) and rewrite them.
3. **Expand the important pages** with what only you know, not with filler.
4. **Measure Core Web Vitals** and tackle images first.
5. **Check sitemap and robots.txt**, decide what to do with AI bots, and publish an `llms.txt`.
6. **Open Search Console every week**, including generative AI performance, instead of once a quarter.
SEO has never moved this fast. That's good news if you get the basics right, because good content gets rewarded within days. And bad news if you don't, because mistakes get punished within days too.
#### Prompt
```text
Act as a technical SEO consultant and review this website. First audit it and give me a prioritized report; don't change anything until I approve it.
Page structure:
- Exactly one `` stating what the page is about, and ``/`` in logical order, with no skipped levels and no headings used just for styling.
- A unique `` and meta description across the whole site: list duplicates, empty ones and ones that get truncated (title > ~60 characters, description > ~155).
- Correct canonical, `lang` on `` and `hreflang` if there are several languages.
Content:
- Flag pages that should rank (services, categories, articles) but have little useful text, and suggest which real questions they fail to answer. I don't want filler: I want full coverage of the topic.
- Detect duplicate content or pages competing for the same query.
Performance:
- Review what affects LCP, INP and CLS: images without dimensions or modern formats, render-blocking fonts, unnecessary JavaScript, resources without caching.
- If you can, run Lighthouse on the main templates and give me the numbers.
Files for search engines and agents:
- `sitemap.xml`: it exists, it's referenced in `robots.txt`, it only lists canonical URLs that return 200, and it's regenerated automatically on publish.
- `robots.txt`: it doesn't block CSS, JS or sections that should be indexed, and explain what it does with AI crawlers (GPTBot, ClaudeBot, PerplexityBot, Google-Extended…) so I can decide.
- `llms.txt`: if missing, propose one generated from the real content (a site summary and links to key pages in Markdown).
- Structured data (JSON-LD) suited to each page type, validated.
Measurement:
- Check that Google Analytics 4 loads respecting cookie consent and that the site is verified in Search Console.
Deliver a table with each issue, the affected URL, the impact (high/medium/low) and the fix. Start with the highest impact for the least effort, and ask me anything you can't infer from the code.
```
---
### SEO en la era de la IA: lo básico sigue mandando
> Cuatro fundamentos de SEO que no han cambiado, los archivos que leen buscadores y agentes, y cómo medirlo en Search Console y Analytics.
- URL: https://urimarti.com/blog/seo-en-la-era-de-la-ia/
- Date: 2026-11-05
- Language: es
- Tags: seo, ia, web
- Author: Oriol Martí
Llevo muchos años trabajando en posicionamiento web y he visto cambiar las reglas varias veces: de las palabras clave repetidas hasta el aburrimiento a los enlaces, de ahí a la experiencia móvil, después a la velocidad y ahora a los resúmenes generados por IA en la propia página de resultados.
Lo que más me sorprende de esta etapa no es la IA en sí, sino **la velocidad**. Antes, un contenido nuevo tardaba semanas o meses en asentarse. Hoy he visto artículos bien hechos colocarse muy arriba **en cuestión de días**. Y también lo contrario: páginas que caen igual de rápido cuando algo técnico falla.
#### Tesis
Mi tesis es que **la IA no ha cambiado los fundamentos del SEO; ha subido el coste de saltárselos**. Los buscadores y los modelos que leen tu web necesitan lo mismo que siempre: entender de qué va cada página, confiar en que el contenido responde y poder cargarla rápido. Lo que sí ha cambiado es que ahora hay más lectores automáticos, y algunos no son Google.
#### Los cuatro fundamentos
##### 1. Siempre un H1 y H2 con sentido
Cada página tiene **un único ``** que dice de qué trata, y el contenido se ordena con `` (y `` si hace falta) como el índice de un libro.
Parece obvio, pero sigo encontrando sitios con el logo como ``, tres `` en la misma página o encabezados elegidos por el tamaño de letra en lugar de por la jerarquía. Para un modelo que resume tu página, los encabezados son el mapa: si el mapa está mal, el resumen también.
```text
h1 SEO en la era de la IA
├─ h2 Los cuatro fundamentos
│ ├─ h3 H1 y H2
│ └─ h3 Títulos y descripciones
├─ h2 Los archivos que leen las máquinas
└─ h2 Medir
```
##### 2. Títulos y descripciones únicos
Cada página necesita su propio `` y su propia meta description. **Únicos**, no generados con una plantilla que solo cambia una palabra.
El título es lo que más pesa para que el buscador entienda la página y lo primero que lee una persona en los resultados. La descripción no posiciona por sí misma, pero decide si te hacen clic. Dos páginas con el mismo título le dicen a Google que compiten por lo mismo, y normalmente pierden las dos.
Mi regla práctica: título de unos 60 caracteres con lo importante al principio, y descripción de unos 155 que responda "¿por qué debería entrar aquí?".
##### 3. Contenido "largo"
Lo pongo entre comillas porque **no se trata de contar palabras**. Un texto largo lleno de relleno funciona peor que uno corto y preciso. Lo que funciona es la **cobertura**: responder la pregunta principal y las que vienen justo después, con ejemplos, datos y experiencia propia.
Con la IA esto importa más, no menos. Cualquiera puede generar mil palabras genéricas en segundos, así que eso ya no diferencia a nadie. Lo que sigue diferenciando es lo que un modelo no puede inventar: **tu experiencia, tus casos y tus opiniones con argumentos**. Y son precisamente esos contenidos los que veo posicionar en días.
##### 4. Rendimiento del sitio
Una web lenta pierde visitas antes de que el SEO entre en juego. Google lo mide con las **Core Web Vitals**:
| Métrica | Qué mide | Bueno |
| --- | --- | --- |
| **LCP** | Cuánto tarda en verse el contenido principal | ≤ 2,5 s |
| **INP** | Cuánto tarda la página en responder a una interacción | ≤ 200 ms |
| **CLS** | Cuánto "salta" el diseño mientras carga | ≤ 0,1 |
Las causas suelen ser las mismas: imágenes enormes o sin dimensiones, fuentes que bloquean el render y JavaScript que nadie necesita. Esta web, por ejemplo, es [HTML estático en S3 y CloudFront](/blog/web-estatica-astro-s3-cloudfront/): sin servidor que responder ni JavaScript que hidratar, el rendimiento sale casi gratis.
#### Los archivos que leen las máquinas
Además de las páginas, hay tres archivos pequeños que deciden cómo te descubren y te leen buscadores y agentes.
##### sitemap.xml
La lista de URLs que quieres que se indexen. No garantiza nada, pero acelera mucho el descubrimiento de contenido nuevo, que es justo lo que permite posicionar en días.
Lo importante: que **se genere solo** al publicar, que solo incluya URLs canónicas que devuelven 200 y que lo envíes en Search Console. Si tienes varios idiomas, añade las alternativas con `hreflang`.
##### robots.txt
Dice qué pueden rastrear los bots y dónde está el sitemap. Dos errores clásicos: bloquear CSS o JavaScript (Google necesita ver la página como un usuario) y dejar un `Disallow: /` olvidado del entorno de pruebas.
Ahora tiene una decisión nueva: **qué hacer con los rastreadores de IA**. `GPTBot`, `ClaudeBot`, `PerplexityBot`, `Google-Extended`… Bloquearlos evita que usen tu contenido, pero también que te citen. En mi caso los dejo pasar: prefiero que una IA recomiende mi trabajo a que no sepa que existe. Ojo: `Google-Extended` controla el uso para entrenar Gemini, no tu aparición en los resultados de búsqueda.
```text
User-agent: *
Allow: /
Sitemap: https://urimarti.com/sitemap.xml
```
##### llms.txt
Una propuesta de estándar ([llmstxt.org](https://llmstxt.org/)): un archivo en Markdown en la raíz del sitio que resume quién eres y enlaza a las páginas importantes, pensado para que un modelo lo lea sin pelearse con menús, banners y JavaScript.
Seré honesto: **no hay pruebas de que Google lo use para posicionar**. Pero cuesta muy poco, algunos agentes y herramientas sí lo leen, y te obliga a un ejercicio útil: explicar tu web en una página. En esta web se genera solo a partir del contenido: [/llms.txt](/llms.txt) y una versión completa en [/llms-full.txt](/llms-full.txt).
#### Medir: Search Console y Analytics
Sin medir, todo lo anterior es fe. Uso dos herramientas que responden preguntas distintas:
- **Google Search Console** te dice qué pasa **antes del clic**: para qué búsquedas apareces, en qué posición, cuántas impresiones y clics tienes, qué páginas no se indexan y por qué. Es la primera que abro cuando publico algo: la inspección de URL permite pedir la indexación y ver en pocos días si el contenido arranca.
- **Google Analytics** te dice qué pasa **después del clic**: cuánto se quedan, qué leen después, si convierten. Una página con muchos clics y cero interés no es un éxito.
##### El rendimiento en IA generativa
La novedad más interesante de Search Console es poder ver **el rendimiento en las funciones de IA generativa** de Google, como los resúmenes de IA y el modo IA: cuántas veces aparece tu contenido como fuente en esas respuestas y cuántos clics te llegan desde ellas.
Esto cambia cómo leo los datos. Hay búsquedas en las que las impresiones suben y los clics bajan, porque la respuesta ya se da en la página de resultados. Antes lo habría interpretado como un problema del título o de la descripción; ahora, a menudo, significa que **la IA está usando tu contenido para responder**. La pregunta deja de ser solo "¿en qué posición estoy?" y pasa a ser "¿me citan cuando la IA responde sobre mi tema?".
Lo que hago con esos datos:
1. Comparar las páginas que aparecen en respuestas de IA con las que no, y buscar qué tienen en común (casi siempre: estructura clara y respuestas directas).
2. Vigilar las páginas con muchas impresiones y pocos clics, y añadir lo que un resumen no puede dar: ejemplos, código, casos reales.
3. Cruzarlo con Analytics para ver si las visitas que llegan desde la IA se quedan más o menos que las del buscador clásico.
#### Qué haría hoy
1. **Revisar los encabezados** de las plantillas principales: un `` por página y `` con sentido.
2. **Listar títulos y descripciones duplicados** (Search Console y cualquier rastreador los encuentran en minutos) y reescribirlos.
3. **Ampliar las páginas importantes** con lo que solo tú sabes, no con relleno.
4. **Medir las Core Web Vitals** y atacar primero las imágenes.
5. **Comprobar sitemap y robots.txt**, decidir qué haces con los bots de IA y publicar un `llms.txt`.
6. **Abrir Search Console cada semana**, incluido el rendimiento en IA generativa, en lugar de una vez al trimestre.
El SEO nunca había ido tan rápido. Eso es una buena noticia si haces lo básico bien, porque el contenido bueno se recompensa en días. Y una mala si no, porque los fallos también se pagan en días.
#### Prompt
```text
Actúa como consultor de SEO técnico y revisa este sitio web. Primero audita y dame un informe priorizado; no cambies nada hasta que lo apruebe.
Estructura de cada página:
- Exactamente un `` que diga de qué va la página, y ``/`` en orden lógico, sin saltos de nivel ni encabezados usados solo por estilo.
- `` y meta description únicos en todo el sitio: lista los duplicados, los vacíos y los que se cortan (título > ~60 caracteres, descripción > ~155).
- Canonical correcto, `lang` en `` y `hreflang` si hay varios idiomas.
Contenido:
- Señala las páginas con poco texto útil que deberían posicionar (servicios, categorías, artículos) y propón qué preguntas reales les faltan por responder. No quiero relleno: quiero cobertura completa del tema.
- Detecta contenido duplicado o páginas que compiten por la misma búsqueda.
Rendimiento:
- Revisa lo que afecta a LCP, INP y CLS: imágenes sin dimensiones ni formato moderno, fuentes que bloquean, JavaScript innecesario, recursos sin caché.
- Si puedes, ejecuta Lighthouse en las plantillas principales y dame las cifras.
Archivos para buscadores y agentes:
- `sitemap.xml`: que exista, que esté enlazado en `robots.txt`, que solo incluya URLs canónicas que devuelven 200 y que se regenere solo al publicar.
- `robots.txt`: que no bloquee CSS, JS ni secciones que deben indexarse, y explícame qué hace con los rastreadores de IA (GPTBot, ClaudeBot, PerplexityBot, Google-Extended…) para que yo decida.
- `llms.txt`: si no existe, propón uno generado a partir del contenido real (resumen del sitio y enlaces a las páginas clave en Markdown).
- Datos estructurados (JSON-LD) adecuados al tipo de página, validados.
Medición:
- Comprueba que Google Analytics 4 se carga respetando el consentimiento de cookies y que el sitio está verificado en Search Console.
Entrega una tabla con cada problema, la URL afectada, el impacto (alto/medio/bajo) y el arreglo. Empieza por lo que más impacto tenga con menos esfuerzo y pregúntame lo que no puedas deducir del código.
```
---
### Anatomy of this site: Astro, S3 and CloudFront for next to nothing
> The full diagram of a static site on AWS: what each piece does, what was ruled out and what it costs.
- URL: https://urimarti.com/en/blog/web-estatica-astro-s3-cloudfront/
- Date: 2026-10-09
- Language: en
- Tags: aws, cloudfront, astro
- Author: Oriol Martí
For years this site lived on shared PHP hosting, uploaded over FTP. It worked, but it had everything I don't want in a system: a server to maintain, a database to serve text that almost never changes, and manual deployments.
When I rebuilt it, I started from one idea: **a content site doesn't need a server**. The cheapest and most resilient architecture is the one with nothing that can go down. This post is the full diagram of where it ended up, why each piece is there and what it costs.
#### The diagram
```text
┌──────────────┐
urimarti.com ──DNS──▶│ Route53 │──▶ A/AAAA alias
└──────────────┘ │
▼
Browser ──HTTPS────▶ ┌────────────────────────────────────┐
│ CloudFront │
│ ├─ ACM certificate (us-east-1) │
│ ├─ CloudFront Function (router) │
│ └─ Edge cache │
└────────────────────────────────────┘
│ OAC (signed)
▼
┌────────────────────────────────────┐
│ Private S3 (eu-west-1) │
│ HTML + assets built by Astro │
└────────────────────────────────────┘
git push main ──▶ GitHub Actions ──▶ astro build ──▶ cdk deploy
```
Six pieces, each with a single job:
| Piece | Job |
| --- | --- |
| **Astro** | Generate static HTML at build time. |
| **S3** | Store the files. Nothing else. |
| **CloudFront** | Serve them over HTTPS, cached, close to the user. |
| **CloudFront Function** | Rewrite and redirect URLs at the edge. |
| **Route53 + ACM** | DNS and certificate. |
| **CDK + GitHub Actions** | Make all of the above code that deploys itself. |
#### Astro: zero JavaScript by default
For a content site, Astro has the advantage I care about most: **by default it ships no JavaScript to the browser**. Every page is HTML and CSS. The little interactivity there is (the theme toggle, the cookie banner) comes from small, independent scripts.
What I ruled out was Next.js. I use it every day and it's a great tool, but here it would solve problems I don't have: server rendering, API routes, incremental caching. With `output: export` it can generate static files too, but it drags along a React runtime this site doesn't need.
Content is Markdown files in the repo with a schema validated at build time. If a post is missing its description, the build fails. Languages are resolved at build time as well: each page is a single file that generates both `/about/` and `/en/about/`.
#### S3: a disk, not a web server
S3 has a "static website hosting" mode, but it requires a public bucket and doesn't serve HTTPS. I ruled it out.
The bucket is **private**: public access blocked, encrypted and TLS-only. The only way to read it is through CloudFront, which signs every request with **Origin Access Control (OAC)**. If someone finds out the bucket name, there's nothing they can do with it.
One consequence of this design: with OAC, when a file doesn't exist S3 doesn't answer 404 but **403**, because from the outside it won't tell "doesn't exist" apart from "not allowed". CloudFront turns both 403 and 404 into the `404.html` page with a 404 status.
#### CloudFront: where everything happens
CloudFront is AWS's CDN: it terminates HTTPS, caches across its servers worldwide and is the only door into S3. It forces HTTPS and uses the managed `CachingOptimized` cache policy.
The interesting part is caching, with two policies depending on the file type:
| Files | `Cache-Control` | Why |
| --- | --- | --- |
| `/_astro/*` (CSS, JS, fonts) | `max-age=31536000, immutable` | The name includes a content hash: if it changes, the name changes. Safe to cache for a year. |
| HTML and everything else | `max-age=0, s-maxage=3600, must-revalidate` | The browser always revalidates; CloudFront keeps it for an hour. |
Each deploy invalidates `/*`, so new HTML shows up within seconds instead of waiting out that hour.
##### The router: a 15-line CloudFront Function
A static site on S3 has a problem: `/about/` isn't a file, `/about/index.html` is. Something has to translate one URL into the other. That something is a **CloudFront Function**, JavaScript that runs at the edge before the cache is checked:
```js
function handler(event) {
var req = event.request;
var host = req.headers.host.value;
var uri = req.uri;
if (host !== 'urimarti.com') {
return { statusCode: 301, statusDescription: 'Moved Permanently',
headers: { location: { value: 'https://urimarti.com' + uri } } };
}
if (uri.endsWith('/')) {
req.uri = uri + 'index.html';
} else if (uri.split('/').pop().indexOf('.') === -1) {
return { statusCode: 301, statusDescription: 'Moved Permanently',
headers: { location: { value: uri + '/' } } };
}
return req;
}
```
It does three things: redirects `www` to the bare domain, adds the trailing slash to URLs missing it, and rewrites `/path/` to `/path/index.html`. That way every page has a single canonical URL, which also matters for SEO.
The alternative was **Lambda@Edge**, which can do much more (network calls, reading the request body). But it has slower cold starts, slower deploys and higher cost. For rewriting headers and URLs, CloudFront Functions are more than enough.
#### Route53 and ACM: the domain
The domain is still registered where it always was; only its nameservers point to **Route53**. The `A` and `AAAA` records are *aliases* to the CloudFront distribution: no IPs to maintain, and the alias works at the zone apex, which a CNAME can't do.
The HTTPS certificate comes from **ACM**, free and auto-renewing. There's one quirk: CloudFront only accepts certificates from **us-east-1**, even though the rest of the infrastructure lives in Ireland. That's why the certificate has its own stack in another region. It validates itself, because CDK writes the validation record into the Route53 zone.
Since the domain doesn't send email, the zone also publishes a *null MX*, an SPF `-all` and a DMARC `p=reject`. Those three records stop anyone from sending spam pretending to be `@urimarti.com`.
#### Everything is code: CDK and GitHub Actions
No resource was created by hand in the console, except the DNS zone. Everything lives in a single **AWS CDK** file with three stacks:
- **Dns**: zone records.
- **Cert**: the certificate, in us-east-1.
- **Site**: bucket, CloudFront, the function, the file upload and the DNS aliases.
**GitHub Actions** deploys on every push to `main`: `astro build`, then `cdk deploy`. CDK uploads the files to the bucket, deletes the ones that no longer exist and invalidates the CloudFront cache.
The pipeline's credentials belong to an IAM user that **can only assume the roles created by `cdk bootstrap`**. It has no direct permissions on anything. If the key leaked, the possible damage would be limited to what CDK can deploy.
#### What it costs
| Item | Monthly cost |
| --- | --- |
| Route53 hosted zone | $0.50 |
| CloudFront | $0 (free tier: 1 TB and 10M requests per month) |
| CloudFront Functions | $0 (2M free invocations per month) |
| S3 | Cents (a few MB) |
| ACM certificate | $0 |
| Invalidations | $0 (first 1,000 paths per month are free; `/*` counts as one) |
| **Total** | **< $1/month** |
Less than the PHP hosting cost, with a global CDN in front.
#### What I'd do differently at scale
This architecture is right for a personal site. If it were a product with a team and serious traffic, I'd change a few things:
- **OIDC instead of access keys.** GitHub Actions can assume an AWS role with short-lived tokens, without storing any key. For a personal project, scoped keys are enough; in a company, they aren't.
- **Preview environments.** One stack per pull request to review changes before they reach production.
- **Security headers** (CSP, HSTS, `X-Content-Type-Options`) through a CloudFront *response headers policy*. That's the next thing I'll add here.
- **Separate infrastructure from content.** Today every push re-evaluates the whole infrastructure, even when only a post changed. CDK leaves untouched what hasn't changed, but the pipeline takes longer than it needs to.
- **Observability.** CloudFront logs and alarms on the 4xx/5xx error rate.
What matters isn't the list of services but the idea behind it: **each piece has a single job, and none of them is a server to maintain**. When content rarely changes, building it once and serving it from a cache isn't an optimisation: it's the right architecture.
#### Prompt
```text
Act as a senior cloud engineer. I want to publish a static content website on AWS, with no servers to maintain and for under $1 a month.
Stack and pieces:
- Astro with no JavaScript by default; content as Markdown in the repo with a schema validated at build time (a missing field fails the build). Languages resolved at build time (/about/ and /en/about/).
- Private S3: public access blocked, encryption and TLS only. Don't use "static website hosting" mode (it requires a public bucket and has no HTTPS).
- CloudFront as the only door, reading S3 through Origin Access Control (OAC). Turn S3's 403 and 404 into 404.html with a 404 status. Force HTTPS.
- Caching: /_astro/* with max-age=31536000, immutable (hashed names); HTML with max-age=0, s-maxage=3600, must-revalidate. Invalidate /* on every deploy.
- A CloudFront Function (not Lambda@Edge) that redirects www to the bare domain, adds the trailing slash with a 301 to URLs without an extension, and rewrites /path/ to /path/index.html.
- Route53 with A/AAAA alias records to CloudFront. ACM certificate in us-east-1, in its own stack, DNS-validated. If the domain sends no email: null MX, SPF -all and DMARC p=reject.
- AWS CDK with three stacks (Dns, Cert, Site) and GitHub Actions on every push to main: astro build and cdk deploy, which uploads the files, prunes old ones and invalidates the cache. Credentials via OIDC or, at least, limited to assuming the cdk bootstrap roles.
Optional: security headers (CSP, HSTS, X-Content-Type-Options) through a response headers policy, and alarms on the 4xx/5xx error rate.
Verify: /path redirects to /path/, www redirects, a missing URL returns 404, the bucket can't be read directly and assets carry the immutable header.
Before starting, ask me whatever you can't infer (domain, region, languages) and explain each decision.
```
---
### Anatomía de esta web: Astro, S3 y CloudFront por casi cero euros
> El diagrama completo de un sitio estático en AWS: qué hace cada pieza, qué se descartó y cuánto cuesta.
- URL: https://urimarti.com/blog/web-estatica-astro-s3-cloudfront/
- Date: 2026-10-09
- Language: es
- Tags: aws, cloudfront, astro
- Author: Oriol Martí
Esta web vivió años en un hosting PHP compartido, subida por FTP. Funcionaba, pero tenía todo lo que no quiero en un sistema: un servidor que mantener, una base de datos para servir texto que casi nunca cambia, y despliegues a mano.
Al rehacerla partí de una idea: **una web de contenido no necesita servidor**. La arquitectura más barata y más resiliente es la que no tiene nada que se pueda caer. Este post es el diagrama completo de cómo quedó, por qué cada pieza está ahí y cuánto cuesta.
#### El diagrama
```text
┌──────────────┐
urimarti.com ──DNS──▶│ Route53 │──▶ alias A/AAAA
└──────────────┘ │
▼
Navegador ──HTTPS──▶ ┌────────────────────────────────────┐
│ CloudFront │
│ ├─ Certificado ACM (us-east-1) │
│ ├─ CloudFront Function (router) │
│ └─ Caché en el edge │
└────────────────────────────────────┘
│ OAC (firmado)
▼
┌────────────────────────────────────┐
│ S3 privado (eu-west-1) │
│ HTML + assets generados por Astro │
└────────────────────────────────────┘
git push main ──▶ GitHub Actions ──▶ astro build ──▶ cdk deploy
```
Seis piezas, cada una con un solo trabajo:
| Pieza | Trabajo |
| --- | --- |
| **Astro** | Generar HTML estático en el build. |
| **S3** | Guardar los archivos. Nada más. |
| **CloudFront** | Servirlos con HTTPS, caché y desde cerca del usuario. |
| **CloudFront Function** | Reescribir y redirigir URLs en el edge. |
| **Route53 + ACM** | DNS y certificado. |
| **CDK + GitHub Actions** | Que todo lo anterior sea código y se despliegue solo. |
#### Astro: cero JavaScript por defecto
Para una web de contenido, Astro tiene la ventaja que más me importa: **por defecto no envía JavaScript al navegador**. Cada página es HTML y CSS. Lo poco interactivo que hay (el cambio de tema, el banner de cookies) son scripts pequeños e independientes.
Lo descartado fue Next.js. Lo uso a diario y es muy buena herramienta, pero aquí resolvería problemas que no tengo: renderizado en servidor, rutas de API, caché incremental. Con `output: export` también genera estáticos, pero arrastra un runtime de React que esta web no necesita.
El contenido son archivos Markdown en el repo con un esquema validado en el build. Si a un post le falta la descripción, el build falla. Los idiomas se resuelven también en el build: cada página es un único archivo que genera `/about/` y `/en/about/`.
#### S3: un disco, no un servidor web
S3 tiene un modo "static website hosting", pero obliga a hacer el bucket público y no sirve HTTPS. Lo descarté.
El bucket es **privado**: bloqueo de acceso público activado, cifrado y solo TLS. La única forma de leerlo es a través de CloudFront, que firma cada petición con **Origin Access Control (OAC)**. Si alguien descubre el nombre del bucket, no puede hacer nada con él.
Un detalle de este diseño: con OAC, cuando un archivo no existe, S3 no responde 404 sino **403**, porque desde fuera no permite distinguir "no existe" de "no tienes permiso". CloudFront transforma tanto el 403 como el 404 en la página `404.html` con estado 404.
#### CloudFront: donde pasa todo
CloudFront es el CDN de AWS: termina el HTTPS, cachea en sus servidores de todo el mundo y es la única puerta hacia S3. Fuerza HTTPS y usa la política de caché gestionada `CachingOptimized`.
La parte interesante es la caché, que tiene dos políticas según el tipo de archivo:
| Archivos | `Cache-Control` | Por qué |
| --- | --- | --- |
| `/_astro/*` (CSS, JS, fuentes) | `max-age=31536000, immutable` | El nombre lleva un hash del contenido: si cambia, cambia el nombre. Se pueden cachear un año. |
| HTML y el resto | `max-age=0, s-maxage=3600, must-revalidate` | El navegador siempre revalida; CloudFront lo guarda una hora. |
En cada deploy se invalida `/*`, así que el HTML nuevo se ve en segundos sin esperar a esa hora.
##### El router: una CloudFront Function de 15 líneas
Una web estática en S3 tiene un problema: `/about/` no es un archivo, `/about/index.html` sí. Algo tiene que traducir una URL en la otra. Ese algo es una **CloudFront Function**, JavaScript que se ejecuta en el edge antes de mirar la caché:
```js
function handler(event) {
var req = event.request;
var host = req.headers.host.value;
var uri = req.uri;
if (host !== 'urimarti.com') {
return { statusCode: 301, statusDescription: 'Moved Permanently',
headers: { location: { value: 'https://urimarti.com' + uri } } };
}
if (uri.endsWith('/')) {
req.uri = uri + 'index.html';
} else if (uri.split('/').pop().indexOf('.') === -1) {
return { statusCode: 301, statusDescription: 'Moved Permanently',
headers: { location: { value: uri + '/' } } };
}
return req;
}
```
Hace tres cosas: redirige `www` al dominio sin `www`, añade la barra final a las URLs que no la llevan, y reescribe `/ruta/` a `/ruta/index.html`. Así cada página tiene una única URL canónica, lo que también importa para el SEO.
La alternativa era **Lambda@Edge**, que puede hacer mucho más (llamadas de red, leer el cuerpo de la petición). Pero tarda más en arrancar, se despliega más despacio y cuesta más. Para manipular cabeceras y URLs, las CloudFront Functions bastan y sobran.
#### Route53 y ACM: el dominio
El dominio sigue registrado donde estaba; solo sus nameservers apuntan a **Route53**. Los registros `A` y `AAAA` son *alias* a la distribución de CloudFront: no hay IPs que mantener, y el alias en la raíz del dominio funciona, cosa que un CNAME no permite.
El certificado HTTPS es de **ACM**, gratuito y con renovación automática. Hay una peculiaridad: CloudFront solo acepta certificados de **us-east-1**, aunque el resto de la infraestructura esté en Irlanda. Por eso el certificado va en su propio stack, en otra región. Se valida solo, porque CDK escribe el registro de validación en la zona de Route53.
Como el dominio no envía correo, la zona publica también un *null MX*, un SPF `-all` y un DMARC `p=reject`. Son tres registros que impiden que nadie envíe spam suplantando `@urimarti.com`.
#### Todo es código: CDK y GitHub Actions
Ningún recurso se ha creado a mano en la consola, salvo la zona DNS. Todo está en un único archivo de **AWS CDK** con tres stacks:
- **Dns**: registros de la zona.
- **Cert**: el certificado, en us-east-1.
- **Site**: bucket, CloudFront, la función, la subida de archivos y los alias DNS.
El deploy lo hace **GitHub Actions** en cada push a `main`: `astro build` y después `cdk deploy`. CDK sube los archivos al bucket, borra los que ya no existen e invalida la caché de CloudFront.
Las credenciales del pipeline son de un usuario IAM que **solo puede asumir los roles que crea `cdk bootstrap`**. No tiene permisos directos sobre nada. Si la clave se filtrara, el daño posible quedaría acotado a lo que CDK puede desplegar.
#### Cuánto cuesta
| Concepto | Coste mensual |
| --- | --- |
| Zona de Route53 | 0,50 $ |
| CloudFront | 0 $ (capa gratuita: 1 TB y 10 M de peticiones al mes) |
| CloudFront Functions | 0 $ (2 M de invocaciones gratis al mes) |
| S3 | Céntimos (unos pocos MB) |
| Certificado ACM | 0 $ |
| Invalidaciones | 0 $ (las primeras 1.000 rutas al mes son gratis; `/*` cuenta como una) |
| **Total** | **< 1 $/mes** |
Menos de lo que costaba el hosting PHP, y con un CDN global delante.
#### Lo que haría distinto a escala
Esta arquitectura es la adecuada para una web personal. Si fuera un producto con equipo y tráfico serio, cambiaría algunas cosas:
- **OIDC en lugar de claves de acceso.** GitHub Actions puede asumir un rol de AWS con tokens temporales sin guardar ninguna clave. Para un proyecto personal las claves acotadas son suficientes; en una empresa, no.
- **Entornos de preview.** Un stack por pull request para revisar cambios antes de que lleguen a producción.
- **Cabeceras de seguridad** (CSP, HSTS, `X-Content-Type-Options`) con una *response headers policy* de CloudFront. Es lo siguiente que añadiré aquí.
- **Separar la infraestructura del contenido.** Hoy cada push vuelve a evaluar toda la infra, aunque solo cambie un post. CDK no toca lo que no ha cambiado, pero el pipeline tarda más de lo necesario.
- **Observabilidad.** Logs de CloudFront y alarmas sobre la tasa de errores 4xx/5xx.
Lo importante no es la lista de servicios, sino la idea de fondo: **cada pieza tiene un solo trabajo, y ninguna es un servidor que mantener**. Cuando el contenido cambia poco, generarlo una vez y servirlo desde una caché no es una optimización: es la arquitectura correcta.
#### Prompt
```text
Actúa como ingeniero cloud senior. Quiero publicar una web de contenido estática en AWS, sin servidores que mantener y por menos de 1 $ al mes.
Stack y piezas:
- Astro sin JavaScript por defecto; contenido en Markdown en el repo con un esquema validado en el build (si falta un campo, el build falla). Idiomas resueltos en el build (/about/ y /en/about/).
- S3 privado: bloqueo de acceso público, cifrado y solo TLS. No uses el modo "static website hosting" (obliga a bucket público y no tiene HTTPS).
- CloudFront como única puerta, leyendo S3 con Origin Access Control (OAC). Convierte los 403 y 404 de S3 en 404.html con estado 404. Fuerza HTTPS.
- Caché: /_astro/* con max-age=31536000, immutable (nombres con hash); HTML con max-age=0, s-maxage=3600, must-revalidate. Invalida /* en cada deploy.
- Una CloudFront Function (no Lambda@Edge) que redirija www al dominio sin www, añada la barra final con 301 a las URLs sin extensión y reescriba /ruta/ a /ruta/index.html.
- Route53 con registros A/AAAA alias a CloudFront. Certificado ACM en us-east-1, en su propio stack, validado por DNS. Si el dominio no envía correo: null MX, SPF -all y DMARC p=reject.
- AWS CDK con tres stacks (Dns, Cert, Site) y GitHub Actions en cada push a main: astro build y cdk deploy, que sube los archivos, borra los que sobran e invalida la caché. Credenciales por OIDC o, como mínimo, limitadas a asumir los roles de cdk bootstrap.
Opcional: cabeceras de seguridad (CSP, HSTS, X-Content-Type-Options) con una response headers policy, y alarmas sobre la tasa de 4xx/5xx.
Verifica: /ruta redirige a /ruta/, www redirige, una URL inexistente da 404, el bucket no es accesible directamente y los assets llevan la cabecera immutable.
Antes de empezar, pregúntame lo que no puedas deducir (dominio, región, idiomas) y explica cada decisión.
```
---
### Backend in the age of AI agents
> If your clients become agents, your APIs are your product. What changes in design, limits and observability.
- URL: https://urimarti.com/en/blog/el-backend-en-la-era-de-los-agentes/
- Date: 2026-09-15
- Language: en
- Tags: ia, apis, futuro
- Author: Oriol Martí
For years we've designed backends with two kinds of client in mind: a frontend we control and, sometimes, an outside developer who reads the docs carefully, writes an integration and leaves it running for months.
A third kind of client is showing up that looks like neither: **an AI agent** that discovers your API on the fly, decides what to call based on a goal, and does it on behalf of a person who isn't watching.
#### Thesis
My thesis is simple: **if a growing share of your users arrive through agents, your API stops being an implementation detail and becomes your product**. The interface that matters is no longer the screen, it's the contract.
That doesn't mean rewriting anything. It means many things that were always "good practice" become requirements. And some assumptions, like there being a human on the other side reading the error messages, stop being true.
#### APIs designed for machines that reason
An agent isn't a script. A script always does the same thing; an agent **interprets**. It reads an endpoint's description, decides whether it fits and builds the call. That changes what makes an API good.
##### Documentation is part of the interface
For an agent, the description of an endpoint and its parameters is literally what it uses to decide what to do. An ambiguous name or an unexplained parameter isn't an annoyance: it's a wrong call.
What used to be documentation for humans becomes **an executable contract**: precise schemas, descriptions that say what each operation is for and when not to use it, and real examples.
##### Errors have to teach
A bare `400 Bad Request` is useless to an agent. An error that says which field is wrong, why, and which values are valid lets it fix itself on the next attempt without help.
```text
Bad: 400 { "error": "invalid request" }
Good: 400 { "error": "invalid_date_range",
"message": "'to' must be after 'from'",
"field": "to",
"hint": "Maximum range: 90 days" }
```
This was always good practice. With agents, it's the difference between a task that gets done and one that's abandoned after ten wasted calls.
##### Operations with intent, not just CRUD
An agent that has to chain five generic calls to "book an appointment" gets five chances to get it wrong. An operation that expresses the whole intent, with its validation built in, cuts errors and makes it easier to understand what happened.
I'm not arguing for dropping resource APIs, but for **adding a layer of business operations** for the things an agent will actually want to do.
##### Idempotency and confirmation
Agents retry, sometimes without anyone explicitly deciding to. Any operation that changes something should accept an **idempotency key**, so repeating it doesn't duplicate an order or a payment.
And for anything irreversible, a two-step pattern makes sense: the agent **proposes** the action, the system returns what's going to happen, and execution needs an explicit confirmation, ideally from the person.
#### Limits, cost and abuse
A human clicks a few times a minute. An agent can make hundreds of calls to solve a single task, especially when it gets something wrong and tries again.
That changes how I think about limits:
| Before | With agents |
| --- | --- |
| Limit per IP or per user | Limit per **agent and per person it acts for** |
| Requests per minute | Requests **and cost** per task (not every call weighs the same) |
| Block abuse | Tell **abuse** apart from a **clumsy** agent retrying in a loop |
| Generic 429s | 429 with `Retry-After` and a reason, so the agent waits instead of insisting |
Authentication changes too. An agent shouldn't use a person's full credentials, but **delegated, scoped, revocable permissions**: it can read the calendar but not delete events; it can prepare an order but not pay for it. Fine-grained OAuth scopes, which many APIs already have, become the norm rather than the exception.
And there's a new risk classic clients didn't have: **the agent can be manipulated** by the content it reads. If your API returns text that comes from other users, that text may contain instructions aimed at the agent. The backend can't solve that alone, but it can limit the damage: least privilege, confirmation for anything irreversible, and no dangerous actions by default.
#### Observability when the client isn't human
Today, when something breaks, you can usually reconstruct what the user did: a session, some clicks, a reasonable sequence. With an agent, the sequence can be long, odd and perfectly valid all at once.
What I think is essential:
- **Identify the agent and the person** on every request, separately. Knowing it was "assistant X acting for user Y" is the foundation for everything else.
- **Group calls by task.** An identifier the agent carries across every call for the same goal turns a hundred loose requests into a story you can read.
- **Measure success per task, not per request.** 99% of requests returning 200 means nothing if half the tasks are abandoned halfway.
- **Watch for repeated errors.** If many agents fail the same way on the same endpoint, the problem probably isn't the agents, it's your contract.
```text
task "book appointment" (task_id=abc)
├─ GET /availability 200
├─ POST /bookings 400 invalid_date_range ◀─ contract problem?
├─ POST /bookings 200 (idempotency-key=k1)
└─ POST /bookings/confirm 200
```
#### What I'd do today
I don't think you need to wait for this to go mainstream to get ready. Almost everything here makes the API better for humans too:
1. **Review the errors** on your most-used endpoints so they say what's wrong and how to fix it.
2. **Complete your API schemas and descriptions** as if they were code, because they are.
3. **Add idempotency keys** to every operation that creates or changes something.
4. **Split permissions** into small scopes, designing for delegated credentials from the start.
5. **Add client and task identifiers** to your logs, even if at first only your own frontend uses them.
6. **Design a couple of business operations** for the most common flows, instead of forcing CRUD chains.
None of this is new. What's new is that it stops being optional. For years, a poorly documented API with confusing errors was compensated for by a patient human on the other side. That human will be around less and less, and **the quality of the contract will become the quality of the product**.
#### Prompt
```text
Act as a backend engineer and get this project's API ready to be used by AI agents on behalf of people, without breaking existing clients.
Contract:
- Complete the schemas and descriptions of every endpoint and parameter as if they were code: what each operation is for, when not to use it, and real examples.
- Make errors teach: a stable code (e.g. `invalid_date_range`), a message, the failing field and a hint with valid values. No generic `400 invalid request`.
- For the most common flows, add a couple of business operations that express the whole intent with their validations, on top of the CRUD resources.
Safety and side effects:
- Accept an idempotency key on every operation that creates or changes something.
- For irreversible actions, use two steps: propose (returns what will happen) and explicitly confirm.
- Use delegated, scoped and revocable permissions in small scopes, never the person's full credentials.
- Treat text coming from other users as untrusted: minimal permissions and nothing dangerous by default.
Limits:
- Limit per agent and per person, and account for the cost of each call, not just the count.
- Return 429 with `Retry-After` and the reason.
Observability:
- Log the agent's and the person's identifiers separately, and propagate a task identifier across every call made for the same goal.
- Measure success rate per task and detect errors that repeat on the same endpoint.
Start with the most used endpoints and verify with tests that errors, idempotency and limits behave as described.
Before you start, ask me about anything you can't infer from the code (stack, current auth, which flows agents will use) and explain every decision.
```
---
### El backend en la era de los agentes de IA
> Si tus clientes pasan a ser agentes, tus APIs son tu producto. Qué cambia en diseño, límites y observabilidad.
- URL: https://urimarti.com/blog/el-backend-en-la-era-de-los-agentes/
- Date: 2026-09-15
- Language: es
- Tags: ia, apis, futuro
- Author: Oriol Martí
Durante años hemos diseñado backends pensando en dos tipos de cliente: un frontend que controlamos y, a veces, un desarrollador externo que lee la documentación con calma, escribe una integración y la deja funcionando durante meses.
Está apareciendo un tercer cliente que no se parece a ninguno de los dos: **un agente de IA** que descubre tu API sobre la marcha, decide qué llamar en función de un objetivo y lo hace en nombre de una persona que no está mirando.
#### Tesis
Mi tesis es sencilla: **si una parte creciente de tus usuarios llega a través de agentes, tu API deja de ser un detalle de implementación y pasa a ser tu producto**. La interfaz que importa ya no es la pantalla, sino el contrato.
Eso no significa reescribir nada. Significa que muchas cosas que siempre fueron "buenas prácticas" pasan a ser requisitos. Y que algunas suposiciones, como que al otro lado hay un humano que lee los errores, dejan de ser ciertas.
#### APIs pensadas para máquinas que razonan
Un agente no es un script. Un script hace siempre lo mismo; un agente **interpreta**. Lee la descripción de un endpoint, decide si le sirve y construye la llamada. Eso cambia qué hace buena a una API.
##### La documentación es parte de la interfaz
Para un agente, la descripción de un endpoint y de sus parámetros es literalmente el material con el que decide qué hacer. Un nombre ambiguo o un parámetro sin explicar no es una molestia: es una llamada equivocada.
Lo que antes era documentación para humanos pasa a ser **contrato ejecutable**: esquemas precisos, descripciones que dicen para qué sirve cada operación y cuándo no usarla, y ejemplos reales.
##### Los errores tienen que enseñar
Un `400 Bad Request` sin más es inútil para un agente. Un error que dice qué campo falla, por qué y qué valores son válidos le permite corregirse en el siguiente intento sin ayuda.
```text
Mal: 400 { "error": "invalid request" }
Bien: 400 { "error": "invalid_date_range",
"message": "'to' debe ser posterior a 'from'",
"field": "to",
"hint": "Rango máximo: 90 días" }
```
Esto siempre fue una buena práctica. Con agentes, marca la diferencia entre una tarea que se completa y una que se abandona tras gastar diez llamadas.
##### Operaciones con intención, no solo CRUD
Un agente que tiene que encadenar cinco llamadas genéricas para "reservar una cita" tiene cinco oportunidades de equivocarse. Una operación que expresa la intención completa, con sus validaciones dentro, reduce los errores y facilita entender qué ha pasado.
No defiendo abandonar las APIs de recursos, sino **añadir una capa de operaciones de negocio** para lo que un agente va a querer hacer de verdad.
##### Idempotencia y confirmación
Los agentes reintentan, a veces sin que nadie lo decida explícitamente. Cualquier operación que cambie algo debería aceptar una **clave de idempotencia**, para que repetirla no duplique un pedido o un pago.
Y para lo que es irreversible, tiene sentido un patrón en dos pasos: el agente **propone** la acción, el sistema devuelve qué va a pasar, y la ejecución requiere una confirmación explícita, idealmente de la persona.
#### Límites, coste y abuso
Un humano hace clic unas pocas veces por minuto. Un agente puede hacer cientos de llamadas para resolver una sola tarea, sobre todo si se equivoca y lo vuelve a intentar.
Eso cambia cómo pienso los límites:
| Antes | Con agentes |
| --- | --- |
| Límite por IP o por usuario | Límite por **agente y por la persona en cuyo nombre actúa** |
| Peticiones por minuto | Peticiones **y coste** por tarea (no todas las llamadas pesan igual) |
| Bloquear el abuso | Distinguir el **abuso** del agente **torpe** que reintenta en bucle |
| Errores 429 genéricos | 429 con `Retry-After` y el motivo, para que el agente espere en lugar de insistir |
La autenticación también cambia. Un agente no debería usar las credenciales completas de una persona, sino **permisos delegados, acotados y revocables**: puede leer el calendario pero no borrar eventos; puede preparar un pedido pero no pagarlo. Los modelos de OAuth con permisos finos, que muchas APIs ya tienen, se vuelven la norma en lugar de la excepción.
Y hay un riesgo nuevo que no tenían los clientes clásicos: **el agente puede ser manipulado** por el contenido que lee. Si tu API devuelve texto que viene de otros usuarios, ese texto puede contener instrucciones dirigidas al agente. El backend no puede resolver eso solo, pero sí puede reducir el daño: permisos mínimos, confirmación para lo irreversible y nada de acciones peligrosas por defecto.
#### Observabilidad cuando el cliente no es humano
Hoy, cuando algo falla, sueles poder reconstruir lo que hizo el usuario: una sesión, unos clics, una secuencia razonable. Con un agente, la secuencia puede ser larga, extraña y perfectamente válida a la vez.
Lo que me parece imprescindible:
- **Identificar al agente y a la persona** en cada petición, por separado. Saber que fue "el asistente X actuando para el usuario Y" es la base de todo lo demás.
- **Agrupar las llamadas por tarea.** Un identificador que el agente propague en todas las llamadas de un mismo objetivo convierte cien peticiones sueltas en una historia que se puede leer.
- **Medir la tasa de éxito por tarea, no por petición.** Que el 99 % de las peticiones respondan 200 no dice nada si la mitad de las tareas se abandonan a medias.
- **Vigilar los errores que se repiten.** Si muchos agentes fallan igual en el mismo endpoint, el problema probablemente no está en los agentes, sino en tu contrato.
```text
tarea "reservar cita" (task_id=abc)
├─ GET /availability 200
├─ POST /bookings 400 invalid_date_range ◀─ ¿error de contrato?
├─ POST /bookings 200 (idempotency-key=k1)
└─ POST /bookings/confirm 200
```
#### Qué haría ya hoy
No creo que haga falta esperar a que esto se generalice para prepararse. Casi todo lo que propongo mejora la API también para los humanos:
1. **Revisar los errores** de los endpoints más usados para que digan qué falla y cómo arreglarlo.
2. **Completar los esquemas y descripciones** de la API como si fueran código, porque lo son.
3. **Añadir claves de idempotencia** a todas las operaciones que crean o cambian algo.
4. **Separar permisos** en ámbitos pequeños, pensando en credenciales delegadas desde el principio.
5. **Añadir un identificador de cliente y de tarea** a los logs, aunque al principio solo los use el frontend propio.
6. **Diseñar un par de operaciones de negocio** para los flujos más habituales, en lugar de obligar a encadenar CRUD.
Nada de esto es nuevo. Lo nuevo es que deja de ser opcional. Durante años, una API mal documentada o con errores confusos se compensaba con un humano paciente al otro lado. Ese humano va a estar cada vez menos presente, y **la calidad del contrato pasará a ser la calidad del producto**.
#### Prompt
```text
Actúa como ingeniero de backend y prepara la API de este proyecto para que la usen agentes de IA en nombre de personas, sin romper a los clientes actuales.
Contrato:
- Completa los esquemas y descripciones de cada endpoint y parámetro como si fueran código: para qué sirve cada operación, cuándo no usarla y ejemplos reales.
- Haz que los errores enseñen: un código estable (p. ej. `invalid_date_range`), un mensaje, el campo que falla y una pista con los valores válidos. Nada de `400 invalid request` genérico.
- Para los flujos más habituales, añade un par de operaciones de negocio que expresen la intención completa con sus validaciones, encima de los recursos CRUD.
Seguridad y efectos:
- Acepta una clave de idempotencia en toda operación que cree o cambie algo.
- Para lo irreversible, usa dos pasos: proponer (devuelve qué va a pasar) y confirmar explícitamente.
- Usa permisos delegados, acotados y revocables en ámbitos pequeños, no las credenciales completas de la persona.
- Trata como no confiable el texto que viene de otros usuarios: permisos mínimos y nada peligroso por defecto.
Límites:
- Limita por agente y por persona, y ten en cuenta el coste de cada llamada, no solo el número.
- Responde 429 con `Retry-After` y el motivo.
Observabilidad:
- Registra por separado el identificador del agente y el de la persona, y propaga un identificador de tarea en todas las llamadas de un mismo objetivo.
- Mide la tasa de éxito por tarea y detecta errores que se repiten en el mismo endpoint.
Empieza por los endpoints más usados y verifica con tests que los errores, la idempotencia y los límites se comportan como se describe.
Antes de empezar, pregúntame lo que no puedas deducir del código (stack, autenticación actual, qué flujos usarán los agentes) y explica cada decisión.
```
---
### Anatomy of a serverless SaaS on AWS
> The architecture of Build the Drop: bulk syncing between marketplaces with functions, queues and a small budget.
- URL: https://urimarti.com/en/blog/anatomia-de-un-saas-serverless/
- Date: 2026-08-11
- Language: en
- Tags: aws, serverless, saas
- Author: Oriol Martí
Build the Drop is my SaaS: it syncs catalogues and inventory between AliExpress and Shopify. I build it alone. That shapes every architectural decision more than any technical requirement does: there's nobody else to pick up an alert at three in the morning.
This post isn't about specific services. It's about the shape of the system and the reasoning behind it. It's the kind of architecture I'd pick today for any product that has to move a lot of data between other people's APIs, on a small budget, with a team of one.
#### Diagram
```text
Shopify ──webhooks──▶ ┌──────────────┐
│ API Gateway │──▶ "ingest" function
Dashboard (user) ────▶│ + auth │ │ validates and enqueues
└──────────────┘ ▼
┌──────────────┐
Scheduler ──── every N minutes ───────▶│ Queue (SQS) │◀── retries / DLQ
└──────────────┘
│ batches
▼
┌───────────────────────────┐
│ "sync" functions │
│ ├─ read from AliExpress │
│ ├─ compute the change │
│ └─ write to Shopify │
└───────────────────────────┘
│
▼
┌───────────────────────────┐
│ Database │
│ state of each product │
│ + last applied sync │
└───────────────────────────┘
```
Four ideas hold the diagram together:
| Idea | What it solves |
| --- | --- |
| **Nothing is processed in the request** | The entry point only validates and enqueues. Answering a webhook fast is what stops Shopify from retrying or disabling it. |
| **The queue is the centre** | It absorbs spikes, enables retries, and separates "what needs doing" from "when I'm allowed to do it". |
| **Idempotent jobs** | Running the same sync twice leaves the same result. |
| **State lives in the database, not in the function** | Any function can die halfway through a batch without losing anything. |
#### Why serverless for a solo founder
When you're one person, the scarce resource isn't CPU, it's your attention. Every server is a to-do list that adds nothing to the product: security patches, disks filling up, hung processes, manual scaling when a big customer shows up.
With functions and managed services, that list shrinks to almost nothing. In exchange you accept three costs, and it's worth being clear about them from day one:
- **Execution limits.** A function can't work for hours. It forces you to split work into small pieces, which in this kind of system is exactly what you want anyway.
- **Cold starts.** Irrelevant for background jobs. Barely noticeable in the user dashboard if functions stay small.
- **Harder debugging.** There's no server to log into and poke around. Without structured logs and per-job tracing, you're flying blind.
The other argument is economic: **cost scales with usage, not with time**. A young SaaS spends many hours with no traffic at all. Paying for servers that are up during those hours is paying to wait.
#### Bulk sync: queues, batches and third-party API limits
The core problem of Build the Drop isn't technical in the classic sense: **you depend on two APIs you don't control**, each with its own rate limits, outages and formats. The architecture exists mainly to live with that.
##### API limits are in charge
Shopify and AliExpress cap how many requests you can make per unit of time. If a user with thousands of products triggers a full sync, you can't do it all at once. And with many users, their limits are per store, but your infrastructure is shared.
So the scheduler doesn't do the work: **it puts messages on the queue**, and consumers process them at whatever pace the APIs allow. When an API answers "too many requests", the message goes back to the queue with a delay instead of failing. The queue turns an external limit into a plain delay.
##### Batches, not single products
Processing one product at a time multiplies API calls and function starts. Processing the whole catalogue in one run hits the time limits. The middle ground is **fixed-size batches**: big enough to amortise each run, small enough to finish with headroom and be retried whole without drama.
##### Idempotency: a sync you can repeat
In a system with queues and retries, **every message will be processed more than once** at some point. The question isn't whether it'll happen, but what happens when it does.
My rule is that a sync job doesn't say "subtract 3 from stock", it says **"stock must be 12"**. You compute the desired state from the source, compare it with the last applied state, and only write if there's a difference. Running it ten times gives the same result as running it once.
```text
source (AliExpress) ──▶ desired state ──┐
├─▶ different? ──yes──▶ write to Shopify
last applied state (DB) ────────────────┘ │ │
no ▼
▼ save new state
nothing to do
```
Comparing before writing also saves most write calls: in a large catalogue, only a small share of products changes between passes.
##### What fails gets set aside
Some messages will never succeed: a product deleted at the source, a revoked user token, data in an unexpected format. Retrying them forever just burns money and floods the logs. After a few attempts they go to a **dead-letter queue (DLQ)**, which I review and can replay once I've fixed the cause.
#### Real costs
I won't give numbers here: they depend on the number of stores, catalogue sizes and sync frequency, and I'd rather publish them once I have data worth showing.
What I can explain is **what drives cost** in an architecture like this:
| Item | Driven by |
| --- | --- |
| Function invocations | Number of batches × duration of each |
| Queue messages | Number of batches and retries |
| Database | Per-product state reads and writes |
| API Gateway | Webhooks and dashboard usage |
| Data transfer | Negligible: small JSON payloads |
The practical consequence is that **cost grows roughly in a straight line with customers**, and is close to zero at rest. For a young SaaS, that means you know your margin per customer from day one. It also means the biggest cost risk isn't traffic, it's **a bug retrying in a loop**. That's why billing alarms and retry limits go in from the start, not "when needed".
#### What doesn't scale (yet)
This architecture is meant to grow for quite a while without changes, but I know where it'll break first:
- **Customers with huge catalogues.** A full sync of a very large catalogue is still slow, because the bottleneck is the source API's limit, not my infrastructure. The fix is to sync only what changed, where the source allows it, instead of walking everything.
- **Fairness between customers.** With a shared queue, one big customer can delay the small ones. The next step is splitting work per customer or by priority.
- **Per-customer observability.** Today I know whether the system works. I want to know, without digging through logs, when each store last synced successfully and why the last failure failed.
- **Me.** The real limit of a one-person SaaS isn't technical. Every decision in this post has the same goal: a system that needs me as little as possible.
The lesson I take away: **in a system that depends on other people's APIs, good architecture isn't the one that's fast, it's the one that assumes everything will fail and still reaches the right state**. Queues, batches and idempotency aren't optimisations. They're what lets you sleep.
#### Prompt
```text
Act as a senior backend engineer. I want to build a serverless SaaS on AWS that syncs data between two third-party APIs (for example, a supplier and an online store), run by a single person on a small budget.
Architecture:
- API Gateway with authentication receives the webhooks and the dashboard requests. An ingest function only validates and enqueues: nothing heavy runs inside the request, so webhooks get a fast response.
- A queue (SQS) is the centre of the system, with retries and a dead-letter queue (DLQ).
- A scheduler that runs every N minutes does no work itself: it generates messages in the queue.
- Sync functions that consume fixed-size batches: they read from the source, compute the change and write to the destination.
- A database with the state of each product and the last sync applied. No function keeps state.
Rules:
- Idempotent jobs: the message says "stock should be 12", never "subtract 3". Compute the desired state, compare it with the last one applied and write only if they differ.
- When an API answers "too many requests", put the message back in the queue with a delay instead of failing.
- Batches big enough to amortise each run and small enough to finish with margin and be retried whole.
- After several attempts, impossible messages (deleted product, revoked token, unexpected format) go to the DLQ and can be reprocessed.
- Structured logs and per-job traces, billing alarms and a retry limit from day one: the biggest cost risk is a bug retrying in a loop.
Tests: processing the same message twice gives the same result; a 429 re-enqueues with a delay; a batch that dies halfway is retried without losing anything; no differences means no writes.
Before starting, ask me whatever you can't infer (specific APIs, database, language, data volume) and explain each decision.
```
---
### Anatomía de un SaaS serverless en AWS
> La arquitectura de Build the Drop: sincronización masiva entre marketplaces con funciones, colas y poco presupuesto.
- URL: https://urimarti.com/blog/anatomia-de-un-saas-serverless/
- Date: 2026-08-11
- Language: es
- Tags: aws, serverless, saas
- Author: Oriol Martí
Build the Drop es mi SaaS: sincroniza catálogos e inventario entre AliExpress y Shopify. Lo construyo solo. Eso condiciona cada decisión de arquitectura más que cualquier requisito técnico: no hay nadie más para atender una alarma a las tres de la mañana.
Este post no va de servicios concretos, sino de la forma del sistema y del razonamiento que hay detrás. Es el tipo de arquitectura que elegiría hoy para cualquier producto que tenga que mover muchos datos entre APIs ajenas con poco presupuesto y un equipo de una persona.
#### Diagrama
```text
Shopify ──webhooks──▶ ┌──────────────┐
│ API Gateway │──▶ Función "ingesta"
Panel (usuario) ─────▶│ + auth │ │ valida y encola
└──────────────┘ ▼
┌──────────────┐
Planificador ─── cada N minutos ──────▶│ Cola (SQS) │◀── reintentos / DLQ
└──────────────┘
│ lotes
▼
┌───────────────────────────┐
│ Funciones "sync" │
│ ├─ leen de AliExpress │
│ ├─ calculan el cambio │
│ └─ escriben en Shopify │
└───────────────────────────┘
│
▼
┌───────────────────────────┐
│ Base de datos │
│ estado de cada producto │
│ + último sync aplicado │
└───────────────────────────┘
```
Cuatro ideas sostienen el diagrama:
| Idea | Qué resuelve |
| --- | --- |
| **Nada se procesa en la petición** | La entrada solo valida y encola. Responder rápido a un webhook es lo que evita que Shopify lo reintente o lo desactive. |
| **La cola es el centro** | Absorbe picos, permite reintentar y separa "qué hay que hacer" de "cuándo puedo hacerlo". |
| **Trabajos idempotentes** | Ejecutar dos veces el mismo sync deja el mismo resultado. |
| **El estado vive en la base de datos, no en la función** | Cualquier función puede morir a mitad de un lote sin perder nada. |
#### Por qué serverless para un solo-founder
Cuando eres una sola persona, el recurso escaso no es la CPU: es tu atención. Cada servidor es una lista de tareas que no aportan nada al producto: parches de seguridad, discos que se llenan, procesos que se quedan colgados, escalado manual cuando llega un cliente grande.
Con funciones y servicios gestionados, esa lista se reduce casi a cero. A cambio, aceptas tres costes que conviene tener claros desde el principio:
- **Límites de ejecución.** Una función no puede pasar horas trabajando. Obliga a partir el trabajo en piezas pequeñas, cosa que en este tipo de sistema es justo lo que quieres.
- **Arranques en frío.** Para procesos en segundo plano dan igual. Para el panel del usuario se notan poco si las funciones son pequeñas.
- **Depuración más difícil.** No hay un servidor donde entrar a mirar. Sin logs estructurados y trazas por trabajo, estás a ciegas.
El otro argumento es económico: **el coste escala con el uso, no con el tiempo**. Un SaaS que empieza tiene muchas horas sin tráfico. Pagar por servidores encendidos esas horas es pagar por esperar.
#### Sincronización masiva: colas, lotes y límites de APIs de terceros
El problema de fondo de Build the Drop no es técnico en sentido clásico: es que **dependes de dos APIs que no controlas**, cada una con sus límites de peticiones, sus caídas y sus formatos. La arquitectura existe sobre todo para convivir con eso.
##### Los límites de las APIs mandan
Shopify y AliExpress limitan cuántas peticiones puedes hacer por unidad de tiempo. Si un usuario con miles de productos lanza un sync completo, no puedes hacerlo todo de golpe. Y si tienes muchos usuarios, sus límites se reparten por tienda, pero tu infraestructura es compartida.
Por eso el planificador no ejecuta trabajo: **genera mensajes en la cola**, y los consumidores los procesan al ritmo que permiten las APIs. Cuando una API responde "demasiadas peticiones", el mensaje vuelve a la cola con un retraso en lugar de fallar. La cola convierte un límite externo en un simple retraso.
##### Lotes, no productos sueltos
Procesar producto a producto multiplica las llamadas y los arranques de funciones. Procesar todo el catálogo en una sola ejecución choca con los límites de tiempo. El punto intermedio son **lotes de tamaño fijo**: lo bastante grandes para amortizar cada ejecución, y lo bastante pequeños para terminar con margen y reintentarse enteros sin drama.
##### Idempotencia: el sync que se puede repetir
En un sistema con colas y reintentos, **todo mensaje se va a procesar más de una vez** en algún momento. La pregunta no es si pasará, sino qué ocurre cuando pase.
La regla que sigo es que un trabajo de sync no dice "resta 3 al stock", sino **"el stock debe ser 12"**. Se calcula el estado deseado a partir de la fuente, se compara con el último estado aplicado y solo se escribe si hay diferencia. Repetirlo diez veces da el mismo resultado que hacerlo una.
```text
fuente (AliExpress) ──▶ estado deseado ──┐
├─▶ ¿difieren? ──sí──▶ escribir en Shopify
último estado aplicado (BD) ─────────────┘ │ │
no ▼
▼ guardar nuevo estado
nada que hacer
```
Además, comparar antes de escribir ahorra la mayoría de las llamadas de escritura: en un catálogo grande, en cada pasada solo cambia una parte pequeña de los productos.
##### Lo que falla, se aparta
Algunos mensajes no van a funcionar nunca: un producto borrado en origen, un token de usuario revocado, un dato con un formato inesperado. Reintentarlos para siempre solo gasta dinero y llena los logs. Tras varios intentos, van a una **cola de mensajes fallidos (DLQ)**, que reviso y desde la que puedo reprocesarlos cuando arreglo la causa.
#### Costes reales
No voy a dar cifras aquí: dependen del número de tiendas, del tamaño de los catálogos y de la frecuencia de sync, y prefiero publicarlas cuando tenga datos que merezca la pena enseñar.
Lo que sí puedo explicar es **de qué depende el coste** en una arquitectura así:
| Concepto | De qué depende |
| --- | --- |
| Ejecuciones de funciones | Número de lotes × duración de cada uno |
| Mensajes en cola | Número de lotes y de reintentos |
| Base de datos | Lecturas y escrituras de estado por producto |
| API Gateway | Webhooks y uso del panel |
| Transferencia de datos | Casi despreciable: son JSON pequeños |
La consecuencia práctica es que **el coste crece casi en línea recta con los clientes**, y en reposo es prácticamente cero. Para un SaaS que empieza, eso significa que el margen por cliente se conoce desde el primer día. También significa que el mayor riesgo de coste no es el tráfico, sino **un bug que reintenta en bucle**. Por eso las alarmas de facturación y los límites de reintentos van desde el principio, no "cuando haga falta".
#### Lo que no escala (todavía)
Esta arquitectura está pensada para crecer sin tocar nada durante bastante tiempo, pero sé dónde se romperá primero:
- **Clientes con catálogos enormes.** Un sync completo de un catálogo muy grande sigue siendo lento, porque el cuello de botella es el límite de la API de origen y no mi infraestructura. La solución pasa por sincronizar solo lo que ha cambiado, si la fuente lo permite, en lugar de recorrerlo todo.
- **Equidad entre clientes.** Con una cola compartida, un cliente grande puede retrasar a los pequeños. El siguiente paso es repartir el trabajo por cliente o por prioridad.
- **Observabilidad por cliente.** Hoy sé si el sistema funciona; quiero saber, sin consultar logs a mano, cuándo fue el último sync correcto de cada tienda y por qué falló el último que no lo fue.
- **Yo.** El límite real de un SaaS de una sola persona no es técnico. Cada decisión de arquitectura de este post tiene el mismo objetivo: que el sistema necesite de mí lo menos posible.
La lección que me llevo: **en un sistema que depende de APIs ajenas, la arquitectura no es la que hace las cosas rápido, sino la que asume que todo va a fallar y aun así llega al estado correcto**. Colas, lotes e idempotencia no son optimizaciones. Son lo que te deja dormir.
#### Prompt
```text
Actúa como ingeniero backend senior. Quiero construir un SaaS serverless en AWS que sincronice datos entre dos APIs de terceros (por ejemplo, un proveedor y una tienda online), mantenido por una sola persona y con poco presupuesto.
Arquitectura:
- API Gateway con autenticación recibe los webhooks y las peticiones del panel. Una función de ingesta solo valida y encola: nada pesado se procesa dentro de la petición, para responder rápido a los webhooks.
- Una cola (SQS) es el centro del sistema, con reintentos y una cola de mensajes fallidos (DLQ).
- Un planificador que cada N minutos no ejecuta trabajo: genera mensajes en la cola.
- Funciones de sync que consumen lotes de tamaño fijo: leen de la fuente, calculan el cambio y escriben en el destino.
- Una base de datos con el estado de cada producto y el último sync aplicado. Ninguna función guarda estado.
Reglas:
- Trabajos idempotentes: el mensaje dice "el stock debe ser 12", nunca "resta 3". Calcula el estado deseado, compáralo con el último aplicado y escribe solo si difieren.
- Si una API responde "demasiadas peticiones", devuelve el mensaje a la cola con retraso en lugar de fallar.
- Lotes lo bastante grandes para amortizar cada ejecución y lo bastante pequeños para terminar con margen y reintentarse enteros.
- Tras varios intentos, los mensajes imposibles (producto borrado, token revocado, formato inesperado) van a la DLQ y se pueden reprocesar.
- Logs estructurados y trazas por trabajo, alarmas de facturación y límite de reintentos desde el primer día: el mayor riesgo de coste es un bug que reintenta en bucle.
Tests: procesar el mismo mensaje dos veces da el mismo resultado; un 429 reencola con retraso; un lote que muere a mitad se reintenta sin perder nada; sin diferencias no hay escrituras.
Antes de empezar, pregúntame lo que no puedas deducir (APIs concretas, base de datos, lenguaje, volumen de datos) y explica cada decisión.
```
---
### Technical debt is a product decision
> Cutting technical debt by 80% wasn't a technical project: it was a conversation about priorities.
- URL: https://urimarti.com/en/blog/la-deuda-tecnica-es-una-decision-de-producto/
- Date: 2026-07-14
- Language: en
- Tags: deuda-tecnica, liderazgo
- Author: Oriol Martí
In my current team we've cut technical debt by roughly 80%. When I mention it, the follow-up question is almost always technical: which tool, which refactor, which pattern. My answer tends to disappoint: what made the difference wasn't technical.
Technical debt is usually framed as an engineering problem that engineering should fix in its spare time. I think that framing is exactly why it so rarely gets fixed.
#### The thesis
**Technical debt isn't paid off with clean-up sprints. It's paid off by making it visible in the same language as the rest of the roadmap.**
As long as debt lives in an engineering document only engineering reads, it will lose to the next feature every time. Not because product doesn't care, but because nobody can weigh something they understand ("this feature brings X") against something they don't ("this module is badly coupled").
Ward Cunningham's original metaphor already said it: debt isn't bad by definition. Borrowing to ship sooner can be an excellent call. What's bad is not knowing how much you owe or how much interest you're paying. And deciding when to borrow and when to repay is, by definition, a product decision.
#### How to measure it
The first step is to stop talking about debt in the abstract. "There's a lot of debt" doesn't let anyone prioritise anything. What works is translating each piece of debt into its **interest**: what it costs us to keep it.
| Debt (engineering language) | Interest (product language) |
| --- | --- |
| "The orders service is tightly coupled" | Every checkout change takes twice as long and breaks something elsewhere. |
| "The pricing module has no tests" | Nobody wants to touch it; pricing improvements keep getting postponed. |
| "Each team shapes API responses its own way" | Integrating a new service takes days instead of hours. |
| "Config is edited by hand" | Mistakes reach production and get fixed in a hurry. |
The right-hand column is the one that matters. It doesn't need to be precise; it needs to be concrete and readable by someone outside engineering.
For tracking it over time, simple signals work and don't require a metrics project:
- **Change frequency × pain.** Code nobody touches can be ugly without hurting anyone. The debt that matters is where people work every week.
- **Incidents by area.** If production bugs cluster in two modules, you know where the interest-bearing debt is.
- **Lead time by type of change.** If changes in one area are consistently slower than everywhere else, there's a reason.
#### How to prioritise it against features
Once debt has a visible interest rate, it can be compared with everything else. From there, my criteria are simple:
**1. Pay debt where you're about to build.** The best time to clean up a module is right before adding features to it. The refactor stops being a separate cost and becomes part of shipping that feature faster and with less risk. Product doesn't have to choose.
**2. Leave the quiet areas alone.** Rewriting stable code nobody touches is the most expensive way to feel productive. If it doesn't charge interest, it isn't urgent.
**3. Name new debt.** Borrowing on purpose to hit a date is legitimate, as long as it's recorded: which shortcut, what it will cost, when it gets revisited. The dangerous debt is the debt nobody decided to take on.
**4. A small, steady budget beats a big project.** "Tech debt quarters" tend to get cancelled the moment a commercial priority shows up. A sustained share of every cycle survives almost anything.
None of these is an engineering rule. They're agreements between engineering and product, and that's why they work: both sides understand what's gained and what's lost.
#### Coding standards as prevention
Paying debt off is half the job. The other half is creating less of it.
Part of our work was **defining and rolling out coding standards across the whole department**. Not a style guide nobody reads, but agreements on how a service is structured, how things are named, how errors are handled and what a code review checks.
Standards reduce debt for a slightly counter-intuitive reason: **most debt isn't born from bad decisions, but from many reasonable decisions made differently**. Five reasonable ways of solving the same thing add up to a system nobody fully understands. A standard removes four of them and frees attention for what's actually new.
For a standard to stick, I've seen three things work:
- **A machine enforces it, not a person.** Anything a linter, a formatter or CI can check shouldn't consume human review time.
- **It's written with the teams, not for them.** An imposed standard is respected only while someone is watching.
- **It explains the why.** Rules without a reason get broken as soon as they get in the way. Rules with one can be debated and improved.
Internal tooling follows the same logic. When a repetitive task is done by hand, everyone does it slightly differently, and every variant leaves a small trail of debt. Automating it with a shared tool standardises it without asking anyone to comply. In our case, some of those tools sped up other teams' content creation by up to 95% and cut down their errors.
#### What doesn't work
Some patterns show up in almost every conversation about technical debt, and they're worth avoiding:
- **The big rewrite.** It promises to fix everything at once, takes three times as long as planned, and meanwhile you maintain two systems. Replacing piece by piece is almost always better.
- **The endless inventory.** Listing every bit of existing debt without prioritising produces a list so long it paralyses instead of helping.
- **Debt as a bargaining chip.** If engineering uses debt to justify delays, product stops trusting any conversation about it.
- **Counting lines of code or isolated code smells.** Easy to count, but they don't tell you where it hurts.
#### The uncomfortable conclusion
If your team's technical debt isn't going down, the problem probably isn't technical skill. It's that debt isn't part of the same conversation as everything else.
Getting it there takes something less glamorous than a big refactor: translating it into cost, deciding with product what gets paid and what doesn't, and keeping it from growing back with standards nobody has to remember. **Reducing debt isn't a technical project: it's a way of making product decisions with all the information on the table.**
#### Prompt
```text
Act as a tech lead and audit this repository to build a prioritised technical-debt register that can be discussed with product.
How to find it:
- Cross change frequency (git history per file or module) with pain: the debt that matters is where people work every week. Ignore ugly but stable code that nobody touches.
- Look for where bug fixes concentrate and where changes take longer or touch more files than usual.
- Spot several different ways of solving the same thing (errors, service structure, naming, response format): they are usually debt even when each one is reasonable.
- Don't count lines or isolated code smells: they don't say where it hurts.
For each entry in the register:
- What it is, in engineering language.
- Its interest, in product language: what it costs us to keep it (slower changes, production errors, postponed improvements). Concrete, even if not exact.
- Where it is and the evidence behind it.
- Recommendation: pay it alongside the next feature in that area, leave it alone, or watch it.
Prioritise few and well: a handful of entries that really charge interest, not an endless inventory. Rule out full rewrites; propose replacing things piece by piece.
Add a prevention section: which standards a machine could enforce (linter, formatter, CI checks) and which repetitive tasks are worth automating, each with its reason.
Before you start, ask me about anything you can't infer from the repository (what will be built next, where incidents happen, how the team works) and explain every prioritisation decision.
```
---
### La deuda técnica es una decisión de producto
> Reducir un 80% la deuda técnica no fue un proyecto técnico: fue una conversación de prioridades.
- URL: https://urimarti.com/blog/la-deuda-tecnica-es-una-decision-de-producto/
- Date: 2026-07-14
- Language: es
- Tags: deuda-tecnica, liderazgo
- Author: Oriol Martí
En mi equipo actual hemos reducido la deuda técnica en torno a un 80%. Cuando lo cuento, la pregunta casi siempre es técnica: qué herramienta, qué refactor, qué patrón. Mi respuesta decepciona un poco: lo decisivo no fue técnico.
La deuda técnica se suele presentar como un problema de ingeniería que ingeniería tiene que resolver en sus ratos libres. Creo que ese planteamiento es justo la razón por la que casi nunca se resuelve.
#### La tesis
**La deuda técnica no se paga con sprints de limpieza. Se paga haciéndola visible en el mismo idioma que el resto del roadmap.**
Mientras la deuda viva en un documento de ingeniería que solo lee ingeniería, siempre perderá contra la siguiente funcionalidad. No porque producto no la valore, sino porque no puede comparar algo que entiende ("esta feature aporta X") con algo que no ("este módulo está mal acoplado").
La metáfora original de Ward Cunningham ya lo decía: la deuda no es mala por definición. Pedir prestado para salir antes al mercado puede ser una decisión excelente. Lo malo es no saber cuánto debes ni cuánto interés pagas. Y decidir cuándo endeudarse y cuándo amortizar es, por definición, una decisión de producto.
#### Cómo medirla
Lo primero es dejar de hablar de la deuda en abstracto. "Hay mucha deuda" no permite priorizar nada. Lo que funciona es traducir cada deuda a su **interés**, es decir, a lo que nos cuesta seguir teniéndola:
| Deuda (en idioma de ingeniería) | Interés (en idioma de producto) |
| --- | --- |
| "El servicio de pedidos está muy acoplado" | Cada cambio en checkout tarda el doble y rompe algo en otro sitio. |
| "No hay tests en el módulo de precios" | Nadie quiere tocarlo; las mejoras de pricing se aplazan. |
| "Cada equipo formatea las respuestas de API a su manera" | Integrar un servicio nuevo cuesta días en lugar de horas. |
| "La configuración se edita a mano" | Los errores llegan a producción y se arreglan con prisas. |
La columna de la derecha es la que importa. No hace falta que sea exacta; basta con que sea concreta y que alguien de fuera de ingeniería pueda entenderla.
Para el seguimiento funcionan indicadores sencillos, que no requieren un proyecto de métricas:
- **Frecuencia de cambio × dolor.** El código que nadie toca puede estar feo sin hacer daño. La deuda que importa está donde se trabaja cada semana.
- **Incidencias por área.** Si los errores en producción se concentran en dos módulos, ya sabes dónde está la deuda que cobra intereses.
- **Tiempo de entrega por tipo de cambio.** Si los cambios en una zona tardan sistemáticamente más que en el resto, hay una razón.
#### Cómo priorizarla frente a features
Una vez la deuda tiene un interés visible, ya se puede comparar con lo demás. A partir de ahí, mi criterio es sencillo:
**1. Pagar la deuda donde vas a construir.** El mejor momento para sanear un módulo es justo antes de añadirle funcionalidad. La refactorización deja de ser un coste aparte: es parte de entregar la feature más rápido y con menos riesgo. Producto no tiene que elegir entre los dos.
**2. Dejar en paz la deuda de las zonas tranquilas.** Rehacer código estable que nadie toca es la forma más cara de sentirse productivo. Si no cobra intereses, no corre prisa.
**3. Ponerle nombre a la deuda nueva.** Endeudarse a propósito para llegar a una fecha es legítimo, siempre que quede registrado: qué atajo se toma, qué costará y cuándo se revisa. La deuda peligrosa es la que nadie decidió.
**4. Un presupuesto pequeño y constante vale más que un gran proyecto.** Los "trimestres de deuda técnica" tienden a cancelarse en cuanto aparece una prioridad comercial. Un porcentaje sostenido de cada ciclo, en cambio, sobrevive a casi todo.
Nada de esto es una regla de ingeniería. Son acuerdos entre ingeniería y producto, y por eso funcionan: los dos lados entienden qué se gana y qué se pierde.
#### Estándares de código como prevención
Pagar deuda es la mitad del trabajo. La otra mitad es generar menos.
Parte de nuestro trabajo fue **definir e implantar estándares de programación para todo el departamento**. No hablo de una guía de estilo que nadie lee, sino de acuerdos sobre cómo se estructura un servicio, cómo se nombran las cosas, cómo se gestionan los errores y qué se revisa en una *code review*.
Los estándares reducen la deuda por una razón poco intuitiva: **la mayor parte de la deuda no nace de malas decisiones, sino de muchas decisiones razonables tomadas de forma distinta**. Cinco formas razonables de resolver lo mismo suman un sistema que nadie entiende entero. Un estándar elimina cuatro y libera la atención para lo que de verdad es nuevo.
Para que un estándar se cumpla, he visto que funcionan tres cosas:
- **Que lo haga cumplir una máquina, no una persona.** Todo lo que pueda comprobar un linter, un formateador o el CI no debería consumir tiempo de revisión humana.
- **Que se escriba con los equipos, no para ellos.** Un estándar impuesto se respeta mientras alguien vigila.
- **Que explique el porqué.** Las reglas sin motivo se rompen en cuanto estorban. Las que lo tienen se pueden discutir y mejorar.
Las herramientas internas van en la misma línea. Cuando una tarea repetitiva se hace a mano, cada persona la hace a su manera y cada variante deja un pequeño rastro de deuda. Automatizarla con una herramienta compartida la estandariza sin necesidad de pedírselo a nadie. En nuestro caso, algunas de esas herramientas aceleraron hasta un 95% la creación de contenido de otros equipos y redujeron sus errores.
#### Lo que no funciona
Hay patrones que se repiten en casi cualquier conversación sobre deuda técnica, y que conviene evitar:
- **La reescritura total.** Promete resolverlo todo a la vez, tarda el triple de lo previsto y mientras tanto hay dos sistemas que mantener. Casi siempre es mejor sustituir por partes.
- **El inventario infinito.** Listar toda la deuda existente sin priorizarla genera una lista tan larga que paraliza en lugar de ayudar.
- **La deuda como moneda de negociación.** Si ingeniería usa la deuda para justificar retrasos, producto deja de creerse cualquier conversación sobre el tema.
- **Medir líneas de código o *code smells* aislados.** Son fáciles de contar, pero no dicen dónde duele.
#### La conclusión incómoda
Si la deuda técnica de tu equipo no baja, probablemente el problema no es de capacidad técnica. Es que la deuda no está en la misma conversación que todo lo demás.
Sacarla de ahí exige algo menos glamuroso que un gran refactor: traducirla a coste, decidir con producto qué se paga y qué no, y evitar que vuelva a crecer con estándares que nadie tenga que recordar. **Reducir la deuda no es un proyecto técnico: es una forma de tomar decisiones de producto con toda la información delante.**
#### Prompt
```text
Actúa como tech lead y audita este repositorio para crear un registro de deuda técnica priorizado que se pueda discutir con producto.
Cómo encontrarla:
- Cruza la frecuencia de cambio (historial de git por archivo o módulo) con el dolor: la deuda que importa está donde se trabaja cada semana. Ignora el código feo pero estable que nadie toca.
- Busca dónde se concentran los arreglos de errores y dónde los cambios tardan o tocan más archivos de lo normal.
- Detecta varias formas distintas de resolver lo mismo (errores, estructura de servicios, nombres, formato de respuestas): suelen ser deuda aunque cada una sea razonable.
- No cuentes líneas ni code smells aislados: no dicen dónde duele.
Para cada entrada del registro:
- Qué es, en idioma de ingeniería.
- Su interés, en idioma de producto: qué nos cuesta seguir teniéndola (cambios más lentos, errores en producción, mejoras que se aplazan). Concreto, aunque no sea exacto.
- Dónde está y la evidencia que la respalda.
- Recomendación: pagarla junto a la próxima funcionalidad en esa zona, dejarla en paz o vigilarla.
Prioriza poco y bien: unas pocas entradas que cobran intereses de verdad, no un inventario infinito. Descarta reescrituras totales; propone sustituciones por partes.
Añade una sección de prevención: qué estándares podría hacer cumplir una máquina (linter, formateador, comprobaciones en CI) y qué tareas repetitivas valdría la pena automatizar, cada una con su porqué.
Antes de empezar, pregúntame lo que no puedas deducir del repositorio (qué se va a construir próximamente, dónde hay incidencias, cómo trabaja el equipo) y explica cada decisión de prioridad.
```
---
### Designing a payment system that never charges twice
> Idempotency, states and reconciliation: the diagram of a checkout flow with several providers.
- URL: https://urimarti.com/en/blog/pagos-que-no-cobran-dos-veces/
- Date: 2026-06-09
- Language: en
- Tags: pagos, idempotencia
- Author: Oriol Martí
In most systems, a bug is fixed with a deploy. Not in payments. If you charge a customer twice, no deploy gives them their money back. You need a support ticket, a refund and a chunk of their trust.
So I design payment systems around an uncomfortable premise: **the network will fail at the worst possible moment**. The request will reach the provider and the response will get lost on the way back. The user will hit "Pay" twice. The webhook will arrive before the synchronous response, or three times, or never.
This post is the diagram of a checkout flow that survives all of that. It doesn't describe any specific system: these are the principles I'd apply today to any platform that charges through one or more providers.
#### The diagram
```text
Client ──POST /payments──▶ ┌──────────────────────┐
(Idempotency-Key) │ Payments API │
│ └─ key registry │
│ (DB) │
└──────────┬───────────┘
▼
┌──────────────────────┐ ┌────────────┐
│ Orchestrator │─────▶│ Provider A │
│ └─ payment state │ └────────────┘
│ machine │ ┌────────────┐
│ │─────▶│ Provider B │
└──────────▲───────────┘ └─────┬──────┘
│ │
┌──────────┴───────────┐ webhooks │
│ Webhook receiver │◀───────────┘
│ └─ deduplication │
└──────────────────────┘
Nightly: Reconciliation ── compares ──▶ our DB ⇄ provider reports
```
Four pieces, each there to absorb a different failure:
| Piece | Failure it absorbs |
| --- | --- |
| **Idempotency key** | The client retries the same operation. |
| **State machine** | Responses that arrive out of order or contradict each other. |
| **Webhook receiver** | Duplicate, late or missing notifications. |
| **Reconciliation** | Whatever slips past the other three. |
#### Idempotency: same request, same result
An idempotent operation has the same effect whether you run it once or ten times. A charge isn't idempotent by nature, so you have to make it so.
The standard technique is the **idempotency key**. The client generates a unique identifier per payment attempt (not per HTTP request) and sends it with every retry. The server stores the key together with the result. If it sees the same key again, it returns the stored result instead of charging again.
```ts
async function createPayment(key: string, input: PaymentInput) {
// The UNIQUE constraint on the key is what prevents the race,
// not the if: two concurrent requests can't both insert it.
const claimed = await db.idempotency.insertIfAbsent({
key,
requestHash: hash(input),
status: 'in_progress',
});
if (!claimed) {
const existing = await db.idempotency.get(key);
if (existing.requestHash !== hash(input)) throw new Conflict('Same key, different request');
if (existing.status === 'in_progress') throw new Conflict('Payment in progress, retry later');
return existing.response; // already processed: same result, no second charge
}
const response = await orchestrator.charge(input, { idempotencyKey: key });
await db.idempotency.complete(key, response);
return response;
}
```
Three details in that code are easy to miss.
**The database enforces exclusivity.** "Does the key exist?" followed by "insert it" is two operations, and another request fits in between. A `UNIQUE` constraint closes that gap; application code doesn't.
**The key is bound to the payload.** The same key with a different amount is a client bug. Reject it instead of returning the previous result.
**The key travels to the provider too.** Most payment providers accept their own idempotency key. If the orchestrator retries after a timeout, the provider recognises the retry and doesn't charge twice. Without this, idempotency stops at your boundary and the risk is still there one hop further.
#### The state machine
A payment isn't just "done" or "not done". It moves through states, and what matters most is defining which transitions are allowed and which aren't.
```text
┌──────────┐
│ created │
└────┬─────┘
▼
┌──────────┐ timeout / no response ┌──────────┐
│ pending │────────────────────────────▶│ unknown │
└─┬──────┬─┘ └────┬─────┘
authorised │ │ declined ask the │
▼ ▼ provider │
┌────────────┐ ┌──────────┐ │
│ authorized │ │ failed │◀─────────────────────────┤
└─────┬──────┘ └──────────┘ │
▼ │
┌────────────┐ │
│ captured │◀──────────────────────────────────────┘
└─────┬──────┘
▼
┌────────────┐
│ refunded │
└────────────┘
```
The key state is **`unknown`**. It's the honest one: if the provider didn't respond, you don't know whether it charged. Marking the payment `failed` invites the user to retry, and that's your double charge. Marking it `captured` might ship something nobody paid for. `unknown` forces you to resolve it by asking the provider before deciding.
The other rule is that **transitions only move forward**. If a payment is already `captured` and a stale `pending` event shows up, it's ignored. You enforce that with a conditional update (`UPDATE ... WHERE status = 'pending'`), not a separate read and write. If the update touches zero rows, another process got there first, and that's not an error.
#### Webhooks: late, duplicated or missing
The provider confirms the final outcome asynchronously, via webhook. Design the receiver assuming webhooks:
- **Arrive duplicated.** The provider retries until it gets a 2xx. Every event carries an ID; storing it under a unique constraint turns the second processing into a no-op.
- **Arrive out of order.** A `refunded` can land before its `captured`. A forward-only state machine absorbs it.
- **Never arrive.** A deploy, a network blip, an expired certificate. That's why they can't be the only source of truth: payments stuck in `pending` or `unknown` for too long get actively polled from the provider.
- **Can be forged.** Verify the signature before processing anything.
And one operational rule: the receiver **answers fast and processes later**. Verify the signature, store the event, return 200 and process it from a queue. If processing takes longer than the provider's timeout, the provider retries and you create the duplicates yourself.
#### Reconciliation: the safety net
With all of the above, the vast majority of cases are covered. Reconciliation exists for the rest.
Every day, payments in our database are compared against the provider's reports. Mismatches fall into three groups:
| Mismatch | What it means |
| --- | --- |
| Charged at the provider, not in our DB | A lost webhook or an unresolved `unknown`. Fulfil or refund. |
| Charged in our DB, not at the provider | A bug on our side. Serious: we're treating something as paid that isn't. |
| Different amounts | Currencies, fees or partial captures modelled wrong. |
Reconciliation should find almost nothing. If it finds a lot, it isn't the solution: it's the sign that one of the earlier pieces is broken.
#### Several providers: the orchestrator
With more than one provider comes the temptation of automatic failover: if provider A fails, retry with B. It's the easiest way to charge twice. "Failed" rarely means "didn't charge": a timeout from A may well be a charge that went through.
The orchestrator should only switch providers when the error is **definitive and happened before the charge**: the provider was down before accepting the request, the payment method isn't supported, or there was an explicit decline. On `unknown`, resolve it with A before touching B.
#### Common mistakes
- **Generating the idempotency key on the server.** Then it protects nothing: every client retry would create a new key.
- **Expiring keys too early.** They must outlive the client's longest retry window.
- **Treating a timeout as a failure.** It's an `unknown`.
- **Processing the webhook inside the HTTP request.** The provider's retries create duplicates.
- **Trusting webhooks as the only source of truth.** You need active polling and reconciliation.
- **Not storing the provider's raw response.** When a dispute comes, it's the only evidence of what happened.
None of these pieces is hard on its own. What makes the system robust is assuming, from the design onwards, that any call can half-fail, and that **the right question isn't "did it work?" but "what do I know for sure about the state of this payment?"**.
#### Prompt
```text
Act as a senior backend engineer specialised in payments. I want to implement a charge flow, with one or several payment providers, that never charges twice even when the network fails at the worst moment.
Pieces:
- Idempotency key: the client generates one per payment attempt (not per HTTP request) and resends it on every retry. Store it under a UNIQUE constraint in the database together with a hash of the request and the response. Same key with different content: conflict error. Key in progress: conflict, retry. Completed key: return the stored response. Also pass the key to the provider.
- State machine: created → pending → authorized → captured → refunded, plus failed and unknown. A timeout or missing response is unknown, never failed or captured; it is resolved by asking the provider. Transitions only move forward, using conditional updates (UPDATE ... WHERE status = ...); if no row is touched, another process got there first and that is not an error.
- Webhook receiver: verify the signature, store the event with its unique id (deduplication), answer 200 and process from a queue. Payments stuck too long in pending or unknown are actively queried.
- Daily reconciliation against the provider's reports, classifying: charged there but not here, charged here but not there, different amounts.
- With several providers, switch provider only on an error that is final and happens before the charge; an unknown is resolved with the first provider.
Don't: generate the key on the server, give it an expiry shorter than the client's retry window, process the webhook inside the HTTP request, rely on webhooks alone, throw away the provider's original response.
Tests: two simultaneous requests with the same key charge once; a timeout leaves the payment in unknown; a duplicated or out-of-order webhook doesn't change the final state; reconciliation detects each type of mismatch.
Before starting, ask me whatever you can't infer (providers, database, language) and explain each decision.
```
---
### Diseñar un sistema de pagos que no cobre dos veces
> Idempotencia, estados y reconciliación: el diagrama de un flujo de cobro con varios proveedores.
- URL: https://urimarti.com/blog/pagos-que-no-cobran-dos-veces/
- Date: 2026-06-09
- Language: es
- Tags: pagos, idempotencia
- Author: Oriol Martí
En casi cualquier sistema, un error se arregla con un deploy. En pagos no. Si cobras dos veces a un cliente, el deploy no le devuelve el dinero. Toca abrir una incidencia, hacer un reembolso y perder parte de su confianza.
Por eso diseño un sistema de pagos partiendo de una premisa incómoda: **la red va a fallar en el peor momento posible**. La petición llegará al proveedor y la respuesta se perderá por el camino. El usuario pulsará "Pagar" dos veces. El webhook llegará antes que la respuesta síncrona, o tres veces, o nunca.
Este post es el diagrama de un flujo de cobro que aguanta todo eso. No describe ningún sistema concreto: son los principios que aplicaría hoy a cualquier plataforma que cobre a través de uno o varios proveedores.
#### El diagrama
```text
Cliente ──POST /payments──▶ ┌──────────────────────┐
(Idempotency-Key) │ API de pagos │
│ └─ registro de │
│ claves (BD) │
└──────────┬───────────┘
▼
┌──────────────────────┐ ┌─────────────┐
│ Orquestador │─────▶│ Proveedor A │
│ └─ máquina de │ └─────────────┘
│ estados del pago │ ┌─────────────┐
│ │─────▶│ Proveedor B │
└──────────▲───────────┘ └──────┬──────┘
│ │
┌──────────┴───────────┐ webhooks │
│ Receptor de webhooks │◀────────────┘
│ └─ deduplicación │
└──────────────────────┘
Cada noche: Reconciliación ── compara ──▶ nuestra BD ⇄ informes del proveedor
```
Hay cuatro piezas, y cada una existe para cubrir un fallo distinto:
| Pieza | Fallo que cubre |
| --- | --- |
| **Clave de idempotencia** | El cliente reintenta la misma operación. |
| **Máquina de estados** | Respuestas que llegan fuera de orden o se contradicen. |
| **Receptor de webhooks** | Notificaciones duplicadas, tardías o perdidas. |
| **Reconciliación** | Todo lo que se escape a las tres anteriores. |
#### Idempotencia: la misma petición, el mismo resultado
Una operación idempotente produce el mismo efecto la ejecutes una vez o diez. Un cobro no lo es por naturaleza, así que hay que hacerlo idempotente.
La técnica estándar es la **clave de idempotencia**. El cliente genera un identificador único por intento de pago, no por petición HTTP, y lo envía en cada reintento. El servidor guarda la clave junto con el resultado. Si vuelve a ver la misma clave, devuelve el resultado guardado en lugar de cobrar otra vez.
```ts
async function createPayment(key: string, input: PaymentInput) {
// La restricción UNIQUE sobre la clave es la que evita la carrera,
// no el if: dos peticiones simultáneas no pueden insertar las dos.
const claimed = await db.idempotency.insertIfAbsent({
key,
requestHash: hash(input),
status: 'in_progress',
});
if (!claimed) {
const existing = await db.idempotency.get(key);
if (existing.requestHash !== hash(input)) throw new Conflict('Misma clave, otra petición');
if (existing.status === 'in_progress') throw new Conflict('Pago en curso, reintenta');
return existing.response; // ya procesado: mismo resultado, sin cobrar de nuevo
}
const response = await orchestrator.charge(input, { idempotencyKey: key });
await db.idempotency.complete(key, response);
return response;
}
```
Hay tres detalles en ese código que suelen pasarse por alto.
**La exclusión la garantiza la base de datos.** Comprobar "¿existe la clave?" y luego insertarla son dos operaciones, y entre ellas cabe otra petición. Una restricción `UNIQUE` en la base de datos cierra ese hueco; el código de aplicación, no.
**La clave va ligada al contenido.** Si llega la misma clave con otro importe, es un error del cliente, y hay que rechazarlo en lugar de devolver el resultado anterior.
**La clave también viaja al proveedor.** La mayoría de proveedores de pago aceptan su propia clave de idempotencia. Si el orquestador reintenta tras un timeout, el proveedor reconoce el reintento y no cobra dos veces. Sin esto, la idempotencia acaba en tu frontera y el riesgo sigue intacto un paso más allá.
#### La máquina de estados
Un pago no está "hecho" o "no hecho". Pasa por estados, y lo más importante es definir qué transiciones están permitidas y cuáles no.
```text
┌──────────┐
│ created │
└────┬─────┘
▼
┌──────────┐ timeout / sin respuesta ┌──────────┐
│ pending │────────────────────────────▶│ unknown │
└─┬──────┬─┘ └────┬─────┘
autorizado │ │ rechazado consulta al│proveedor
▼ ▼ │
┌────────────┐ ┌──────────┐ │
│ authorized │ │ failed │◀─────────────────────────┤
└─────┬──────┘ └──────────┘ │
▼ │
┌────────────┐ │
│ captured │◀──────────────────────────────────────┘
└─────┬──────┘
▼
┌────────────┐
│ refunded │
└────────────┘
```
El estado clave es **`unknown`**. Es honesto: si el proveedor no respondió, no sabes si cobró. Marcar ese pago como `failed` invita al usuario a reintentar, y ahí está el doble cobro. Marcarlo como `captured` puede entregar algo que no se ha pagado. `unknown` obliga a resolverlo consultando al proveedor antes de decidir.
La otra regla es que **las transiciones solo van hacia delante**. Si un pago ya está `captured` y llega un evento `pending` atrasado, se ignora. Esto se garantiza con una actualización condicional (`UPDATE ... WHERE status = 'pending'`) y no leyendo y escribiendo por separado. Si la actualización no toca ninguna fila, otro proceso llegó antes, y eso no es un error.
#### Webhooks: tardíos, duplicados o ausentes
El proveedor confirma el resultado final de forma asíncrona, por webhook. Hay que diseñar el receptor contando con que los webhooks:
- **Llegan duplicados.** El proveedor reintenta hasta recibir un 2xx. Cada evento trae un identificador; guardarlo con una restricción única convierte el segundo procesamiento en una operación vacía.
- **Llegan desordenados.** Un `refunded` puede llegar antes que su `captured`. La máquina de estados, que solo avanza, absorbe ese caso.
- **No llegan.** Por un despliegue, un fallo de red o un certificado caducado. Por eso nunca deben ser la única fuente de verdad: los pagos que llevan demasiado tiempo en `pending` o `unknown` se consultan activamente al proveedor.
- **Pueden ser falsos.** Antes de procesar nada, hay que verificar la firma.
Y una regla operativa: el receptor **responde rápido y procesa después**. Valida la firma, guarda el evento, responde 200 y lo procesa en una cola. Si el procesamiento tarda más que el timeout del proveedor, este reintenta y generas tú mismo los duplicados.
#### Reconciliación: la red de seguridad
Con todo lo anterior, la gran mayoría de casos quedan cubiertos. La reconciliación existe para el resto.
Cada día se comparan los pagos de nuestra base de datos con los informes del proveedor. Las discrepancias se agrupan en tres tipos:
| Discrepancia | Qué significa |
| --- | --- |
| Cobrado en el proveedor, no en nuestra BD | Un webhook perdido o un `unknown` sin resolver. Hay que entregar o reembolsar. |
| Cobrado en nuestra BD, no en el proveedor | Un bug nuestro. Grave: estamos dando por pagado algo que no lo está. |
| Importes distintos | Divisas, comisiones o capturas parciales mal modeladas. |
La reconciliación no debería encontrar casi nada. Si encuentra muchas discrepancias, no es la solución: es la señal de que alguna de las piezas anteriores falla.
#### Varios proveedores: el orquestador
Con varios proveedores aparece la tentación de añadir un *failover* automático: si el proveedor A falla, reintentar con B. Es justo la forma más fácil de cobrar dos veces. "Falló" casi nunca significa "no cobró": un timeout de A puede ser un cobro que sí se hizo.
El orquestador solo debería cambiar de proveedor cuando el error es **definitivo y anterior al cobro**: proveedor caído antes de aceptar la petición, método de pago no soportado o rechazo explícito. Ante un `unknown`, se resuelve con A antes de tocar B.
#### Errores típicos
- **Generar la clave de idempotencia en el servidor.** Así no protege nada: cada reintento del cliente crearía una clave nueva.
- **Darle una caducidad demasiado corta.** Debe durar más que la ventana más larga de reintentos del cliente.
- **Tratar el timeout como un fallo.** Es un `unknown`.
- **Procesar el webhook dentro de la petición HTTP.** Genera duplicados por los reintentos del proveedor.
- **Confiar en el webhook como única fuente de verdad.** Hace falta consulta activa y reconciliación.
- **No guardar la respuesta original del proveedor.** Cuando haya una disputa, será la única prueba de lo que pasó.
Ninguna de estas piezas es complicada por separado. Lo que hace robusto el sistema es asumir desde el diseño que cada llamada puede fallar a medias, y que **la pregunta correcta no es "¿ha ido bien?", sino "¿qué sé con seguridad sobre el estado de este pago?"**.
#### Prompt
```text
Actúa como ingeniero backend senior especializado en pagos. Quiero implementar un flujo de cobro, con uno o varios proveedores de pago, que nunca cobre dos veces aunque la red falle en el peor momento.
Piezas:
- Clave de idempotencia: el cliente genera una por intento de pago (no por petición HTTP) y la reenvía en cada reintento. Guárdala con una restricción UNIQUE en la base de datos junto con un hash de la petición y la respuesta. Misma clave con otro contenido: error de conflicto. Clave en curso: conflicto, reintenta. Clave completada: devuelve la respuesta guardada. Envía también la clave al proveedor.
- Máquina de estados: created → pending → authorized → captured → refunded, más failed y unknown. Un timeout o falta de respuesta es unknown, nunca failed ni captured; se resuelve consultando al proveedor. Las transiciones solo avanzan, con actualizaciones condicionales (UPDATE ... WHERE status = ...); si no toca filas, otro proceso llegó antes y no es un error.
- Receptor de webhooks: verifica la firma, guarda el evento con su id único (deduplicación), responde 200 y procesa en una cola. Los pagos que llevan demasiado en pending o unknown se consultan activamente.
- Reconciliación diaria contra los informes del proveedor, clasificando: cobrado allí y no aquí, cobrado aquí y no allí, importes distintos.
- Con varios proveedores, cambia de proveedor solo ante un error definitivo y anterior al cobro; un unknown se resuelve con el primero.
No hagas: generar la clave en el servidor, darle una caducidad más corta que la ventana de reintentos, procesar el webhook dentro de la petición HTTP, confiar solo en webhooks, descartar la respuesta original del proveedor.
Tests: dos peticiones simultáneas con la misma clave cobran una vez; un timeout deja el pago en unknown; un webhook duplicado o desordenado no cambia el estado final; la reconciliación detecta cada tipo de discrepancia.
Antes de empezar, pregúntame lo que no puedas deducir (proveedores, base de datos, lenguaje) y explica cada decisión.
```
---
### Real-time stock across e-commerce, ERP and physical stores
> Anonymised case study: syncing inventory across three systems that never agreed on who was in charge.
- URL: https://urimarti.com/en/blog/stock-en-tiempo-real-ecommerce-erp-tiendas/
- Date: 2026-05-12
- Language: en
- Tags: integraciones, erp, eventos
- Author: Oriol Martí
Selling the same product through three channels at once looks like a business problem. It's actually a distributed systems problem: three systems, each with its own idea of how many units are left, and a customer who doesn't want to hear "sorry, we didn't have it after all".
This is the case study of how we synced stock in real time between an online store, an ERP and a retailer's physical shops, as the tech team responsible for all three pieces.
#### Context
The business sold through three channels:
| Channel | System | Who controlled it |
| --- | --- | --- |
| Online store | E-commerce | Us |
| Management, purchasing and warehouse | ERP | An external vendor |
| Physical shops | POS (point of sale) | Us: we built it from scratch |
We built the POS from the ground up with **Vue.js and PHP**, designed for the business's own shops. That gave us a real advantage: two of the three systems were ours, and we could decide how they talked to each other.
The third, the ERP, wasn't. It had its own data model, its own timing and its own rules. Like almost every ERP, it wasn't designed with real time in mind.
#### The real problem: who's in charge?
Before talking about queues or APIs, one question decides everything: **which system is the source of truth for stock?**
If every system believes it is, the outcome is predictable:
- An online sale decrements stock in the e-commerce, but the ERP only finds out later.
- Meanwhile, a physical shop sells the last unit of the same product.
- A restock arrives at the warehouse and the ERP records it, but the online store still says "sold out".
Every system is right given the information it has. The problem is that none of them has all of it.
There are several ways to solve this, each with its costs:
| Approach | Upside | Cost |
| --- | --- | --- |
| The ERP is the only truth and everyone asks it | One place to look | Every sale depends on the ERP being fast and available |
| Each channel keeps its own stock, reconciled periodically | Independent channels | Lag windows where you can sell what isn't there |
| The ERP is the truth, but every movement propagates as an event | Near real time without coupling each sale to the ERP | More moving parts: queues, retries, idempotency |
#### Design
The key idea is to stop thinking of "stock" as a number copied from one place to another, and start thinking in **stock movements**: an online sale, an in-store sale, a return, an incoming delivery. Each movement is a fact that happens in one system and that the others need to know about.
```text
Online sale ──┐ ┌─▶ E-commerce (visible stock)
POS sale ─────┼─▶ stock movement ────┼─▶ ERP (accounting, purchasing)
Restock ──────┘ (queue + retries) └─▶ POS in each shop
```
##### Movements, not numbers
Propagating "3 units left" is fragile: if two messages arrive out of order, the final number is wrong. Propagating "1 unit sold" is more robust, as long as each movement is applied **exactly once**. So every movement needs a unique identifier, and whoever receives it must be able to ignore duplicates.
##### The ERP, behind a queue
The ERP can't sit on the critical path of a sale. If it's slow or down, the online store and the POS still have to sell. So communication with the ERP is decoupled: movements are queued and sent with retries. If the ERP goes down, movements wait; when it comes back, they're processed in order.
##### Conflicts and overselling
With three channels selling in parallel, overselling can't be eliminated entirely; it can only be made unlikely, with a plan for when it happens anyway. The usual levers are:
- **Shrinking the lag window**, propagating movements as soon as they happen.
- **Safety buffers** on low-stock products, showing "sold out" online slightly earlier.
- **Reservations**: decrementing stock when checkout starts, not when it ends.
- **A clear process** for when something is sold that isn't there.
##### Catalogue tooling
Syncing stock only works if all three systems are talking about the same product. Part of the work, less glamorous, was building tools in **PHP and Node.js** to create and maintain the catalogue, both online and offline. If product references don't match across systems, no event architecture will fix it.
#### Outcome
The result was **real-time** stock across the online store, the ERP and the physical shops, with an in-house POS that was part of the same architecture rather than an island.
#### What I'd change today
The architecture solved the problems we had. With what I know now, I'd change a few things:
- **A movement log as the internal source of truth**, with each channel's stock as a projection of it. That makes it easy to audit what happened and to rebuild state if something gets corrupted.
- **More visibility into lag**: knowing at any moment how far behind each system is, and alerting when the ERP queue grows.
- **Automatic periodic reconciliation** between systems, to catch differences before a customer does.
#### What I learned
**The important question isn't technical.** "Who's the source of truth?" is answered by the business: who buys, who sells and who's accountable when the numbers don't match. The architecture comes after.
**Propagate facts, not state.** Movements can be ordered, deduplicated and replayed. Numbers copied from one place to another can't.
**Decouple what you don't control.** The ERP was the most important system and the one we controlled least. Putting it behind a queue meant its problems didn't become the store's problems.
**Building the POS was an architecture decision.** Owning the point of sale wasn't just about features: it let the third channel speak the same language as the other two.
Syncing stock looks like just another integration. It's actually one of the most real consistency problems in e-commerce, because when it goes wrong, a customer notices.
#### Prompt
```text
Act as an integration architect: design and implement real-time stock sync between my online store, an external ERP and the point-of-sale systems in the physical stores.
First, the business decision:
- Ask me what the source of truth for stock is and who answers when the numbers don't add up. Architecture comes after that.
Design:
- Propagate stock movements, not numbers: online sale, in-store sale, return, goods received. Never "3 units left".
- Every movement has a unique ID and the receiver ignores duplicates, so it is applied exactly once.
- The ERP is not on the critical path of a sale: movements to it go through a queue with retries. If it's down, they wait; when it comes back, they're processed in order.
- A movement log as the internal source of truth, with each channel's stock as a projection of it, so you can audit and rebuild state.
Overselling (you can't eliminate it, only make it unlikely):
- Propagate each movement as soon as it happens.
- A safety margin on low-stock products: show "sold out" online a little earlier.
- Reserve stock when payment starts, not when it finishes.
- A clear process for when it happens anyway.
Catalogue:
- Product references must match across all three systems; set up the reference mapping with the ERP before the events.
Verify:
- Tests with duplicated and out-of-order movements, and with the ERP down.
- Metrics on how far behind each system is, and an alert when the ERP queue grows.
- An automatic periodic reconciliation that spots differences before a customer does.
Before starting, ask me whatever you can't infer (stack, ERP API, which systems I control) and explain each decision.
```
---
### Stock en tiempo real entre e-commerce, ERP y tiendas físicas
> Caso real anonimizado: sincronizar inventario entre tres sistemas que no se pusieron de acuerdo en quién manda.
- URL: https://urimarti.com/blog/stock-en-tiempo-real-ecommerce-erp-tiendas/
- Date: 2026-05-12
- Language: es
- Tags: integraciones, erp, eventos
- Author: Oriol Martí
Vender el mismo producto por tres canales a la vez parece un problema de negocio. En realidad es un problema de sistemas distribuidos: tres sistemas, cada uno con su propia idea de cuántas unidades quedan, y un cliente que no quiere oír "lo sentimos, al final no lo teníamos".
Este es el caso de cómo sincronizamos el stock en tiempo real entre una tienda online, un ERP y las tiendas físicas de un retailer, siendo el equipo técnico responsable de las tres piezas.
#### Contexto
El negocio vendía por tres vías:
| Canal | Sistema | Quién lo controlaba |
| --- | --- | --- |
| Tienda online | E-commerce | Nosotros |
| Gestión, compras y almacén | ERP | Un proveedor externo |
| Tiendas físicas | TPV (punto de venta) | Nosotros: lo construimos a medida |
El TPV lo desarrollamos desde cero con **Vue.js y PHP**, pensado para las tiendas del propio negocio. Eso nos dio una ventaja importante: dos de los tres sistemas eran nuestros y podíamos decidir cómo se comunicaban.
El tercero, el ERP, no. Tenía su propio modelo de datos, sus propios tiempos y sus propias reglas. Como casi todos los ERPs, no se diseñó pensando en el tiempo real.
#### El problema de fondo: ¿quién manda?
Antes de hablar de colas o de APIs, hay una pregunta que lo decide todo: **¿cuál es la fuente de verdad del stock?**
Si cada sistema cree que es él, el resultado es predecible:
- Una venta online descuenta stock en el e-commerce, pero el ERP no se entera hasta más tarde.
- Mientras tanto, una tienda física vende la última unidad del mismo producto.
- Llega una reposición al almacén que el ERP registra, pero el e-commerce sigue mostrando "agotado".
Cada sistema tiene razón con la información que tiene. El problema es que ninguno la tiene completa.
Hay varias formas de resolverlo, cada una con sus costes:
| Enfoque | Ventaja | Coste |
| --- | --- | --- |
| El ERP es la única verdad y todos le preguntan | Un solo sitio que mirar | Cada venta depende de que el ERP responda rápido y esté disponible |
| Cada canal tiene su stock y se reconcilia periódicamente | Canales independientes | Ventanas de desfase donde se puede vender lo que no hay |
| El ERP es la verdad, pero cada movimiento se propaga como evento | Casi tiempo real sin acoplar cada venta al ERP | Más piezas: colas, reintentos, idempotencia |
#### Diseño
La idea central es dejar de pensar en "el stock" como un número que se copia de un sitio a otro, y empezar a pensar en **movimientos de stock**: una venta online, una venta en tienda, una devolución, una entrada de mercancía. Cada movimiento es un hecho que ocurre en un sistema y que los demás necesitan conocer.
```text
Venta online ─┐ ┌─▶ E-commerce (stock visible)
Venta en TPV ─┼─▶ movimiento de stock ─┼─▶ ERP (contabilidad, compras)
Reposición ───┘ (cola + reintentos) └─▶ TPV de cada tienda
```
##### Movimientos, no números
Propagar "quedan 3 unidades" es frágil: si dos mensajes llegan desordenados, el número final es incorrecto. Propagar "se ha vendido 1 unidad" es más robusto, siempre que cada movimiento se aplique **una sola vez**. Por eso cada movimiento necesita un identificador único, y quien lo recibe tiene que poder ignorar los duplicados.
##### El ERP, detrás de una cola
El ERP no puede estar en el camino crítico de una venta. Si tarda o no está disponible, la tienda online y los TPV tienen que seguir vendiendo. Por eso la comunicación con el ERP va desacoplada: los movimientos se encolan y se envían con reintentos. Si el ERP cae, los movimientos esperan; cuando vuelve, se procesan en orden.
##### Conflictos y sobreventa
Con tres canales vendiendo en paralelo, la sobreventa no se puede eliminar del todo; solo se puede hacer improbable y tener un plan para cuando ocurra. Las palancas habituales son:
- **Reducir la ventana de desfase**, propagando los movimientos en cuanto ocurren.
- **Márgenes de seguridad** en productos con pocas unidades, mostrando "agotado" online un poco antes.
- **Reservas**: descontar stock al empezar el pago, no al terminarlo.
- **Un proceso claro** para cuando, aun así, se vende algo que no hay.
##### Herramientas para el catálogo
Sincronizar stock solo funciona si los tres sistemas hablan del mismo producto. Una parte del trabajo, menos vistosa, fueron las herramientas en **PHP y Node.js** para crear y mantener el catálogo, tanto online como offline. Si las referencias no cuadran entre sistemas, ninguna arquitectura de eventos lo arregla.
#### Resultado
El resultado fue un stock **en tiempo real** entre la tienda online, el ERP y las tiendas físicas, con un TPV propio que formaba parte de la misma arquitectura en lugar de ser una isla.
#### Lo que cambiaría hoy
La arquitectura respondía bien a los problemas que teníamos. Con lo que sé ahora, cambiaría algunas cosas:
- **Un registro de movimientos como fuente de verdad interna**, del que el stock de cada canal sea una proyección. Facilita auditar qué pasó y reconstruir el estado si algo se corrompe.
- **Más observabilidad sobre el desfase**: saber en cada momento cuánto va por detrás cada sistema, y avisar si la cola con el ERP crece.
- **Reconciliación automática** periódica entre sistemas, para detectar diferencias antes de que las detecte un cliente.
#### Lo que aprendí
**La pregunta importante no es técnica.** "¿Quién es la fuente de verdad?" se responde con el negocio: quién compra, quién vende y quién responde si los números no cuadran. La arquitectura viene después.
**Propaga hechos, no estados.** Los movimientos se pueden ordenar, deduplicar y volver a aplicar. Los números copiados de un sitio a otro, no.
**Desacopla lo que no controlas.** El ERP era el sistema más importante y el que menos controlábamos. Ponerlo detrás de una cola permitió que sus problemas no fueran problemas de la tienda.
**Construir el TPV fue una decisión de arquitectura.** Tener el punto de venta en casa no era solo cuestión de funcionalidades: permitió que el tercer canal hablara el mismo idioma que los otros dos.
Sincronizar stock parece una integración más. En realidad es uno de los problemas de consistencia más reales que hay en e-commerce, porque cuando sale mal, lo nota un cliente.
#### Prompt
```text
Actúa como arquitecto de integraciones: diseña e implementa la sincronización de stock en tiempo real entre mi tienda online, un ERP externo y los TPV de las tiendas físicas.
Primero, la decisión de negocio:
- Pregúntame cuál es la fuente de verdad del stock y quién responde si los números no cuadran. La arquitectura viene después.
Diseño:
- Propaga movimientos de stock, no números: venta online, venta en TPV, devolución, entrada de mercancía. Nunca "quedan 3 unidades".
- Cada movimiento lleva un identificador único y el receptor ignora los duplicados, así se aplica una sola vez.
- El ERP no está en el camino crítico de una venta: los movimientos hacia él van por una cola con reintentos. Si cae, esperan; cuando vuelve, se procesan en orden.
- Un registro de movimientos como fuente de verdad interna, del que el stock de cada canal es una proyección, para auditar y reconstruir el estado.
Sobreventa (no se elimina, se hace improbable):
- Propagar cada movimiento en cuanto ocurre.
- Margen de seguridad en productos con pocas unidades: "agotado" online un poco antes.
- Reservar stock al empezar el pago, no al terminarlo.
- Un proceso claro para cuando aun así ocurra.
Catálogo:
- Las referencias deben cuadrar entre los tres sistemas; prepara el mapeo de referencias con el ERP antes que los eventos.
Verifica:
- Tests con movimientos duplicados, desordenados y con el ERP caído.
- Métricas del desfase de cada sistema y alerta si crece la cola hacia el ERP.
- Una reconciliación automática periódica que detecte diferencias antes que un cliente.
Antes de empezar, pregúntame lo que no puedas deducir (stack, API del ERP, qué sistemas controlo) y explícame cada decisión.
```
---
### Modular monolith before microservices
> Microservices solve organisational problems, not code problems. When to split a system and when not to.
- URL: https://urimarti.com/en/blog/monolito-modular-antes-que-microservicios/
- Date: 2026-04-14
- Language: en
- Tags: arquitectura, microservicios
- Author: Oriol Martí
I've worked on monoliths people were afraid to touch, and on microservice systems where a small change meant coordinating three deployments. Both happen, and the pattern is rarely to blame: it's **when** and **why** it was chosen.
My position is simple: **splitting a system before its boundaries are clear multiplies coupling instead of reducing it**. Microservices don't make code better organised. They make every organisational mistake more expensive, because now it goes over the network.
#### What microservices actually solve
Before deciding, it's worth being honest about which problem you're trying to solve. Microservices are good at these:
- **Teams stepping on each other.** Several teams touching the same code and having to coordinate every release.
- **Different rhythms.** One part of the system changes ten times a day, another once a quarter.
- **Different scaling needs.** One component needs far more capacity than the rest, or a very different resource profile.
- **Fault isolation.** A problem in one part shouldn't take everything else down.
- **Different technologies.** One piece has a good reason to live in another language or on another database.
Notice that almost all of these are **organisational and operational** problems, not code design problems. If the code is messy, splitting it into services doesn't tidy it: it spreads the mess across several repositories and adds latency.
#### Signs it's time to split
I'd split a system when several of these show up at once:
- **Deployment is the bottleneck.** Teams wait on each other to ship, or a small change requires testing everything.
- **There's a boundary that already behaves like one.** A module with a stable contract that rarely needs to change together with the rest.
- **The load profile is very different.** One part gets spikes that have nothing to do with the rest of the system.
- **A local failure takes down too much.** A bug in a secondary feature brings the main one down.
- **There's a team willing to own it.** A service without a clear owner becomes the one nobody wants to touch.
#### Signs it isn't yet
And I'd wait if I recognise any of these:
- **Boundaries change every month.** If you still don't know where "orders" ends and "payments" begins, any boundary you draw now will be the wrong one, and moving it between services costs far more than moving it between folders.
- **Every change touches several parts.** If each new feature modifies three modules, splitting them turns each feature into three coordinated deployments.
- **The team is small.** A handful of people running ten services spend more time maintaining infrastructure than building product.
- **There's no operational foundation.** Without automated deploys, centralised logs, tracing and alerts, debugging a failure that crosses services is guesswork.
- **The reason is "that's how it's done".** The worst reason, and more common than it seems.
#### The middle ground: modules with explicit boundaries
Between the messy monolith and microservices there's an option that is often the best one for a long time: the **modular monolith**.
It's a single application and a single deployment, but organised inside as if it were services:
```text
┌──────────────────────────── Application (one deployment) ────────────────────────────┐
│ │
│ ┌─────────────┐ public API ┌─────────────┐ public API ┌─────────────┐ │
│ │ Catalogue │ ◀────────────▶ │ Orders │ ◀────────────▶ │ Payments │ │
│ │ (own tables)│ │ (own tables)│ │ (own tables)│ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ Not allowed: importing another module's internals or reading its tables │
└──────────────────────────────────────────────────────────────────────────────────────┘
```
The rules that make it work:
- **Each module exposes a public API** and hides everything else. Other modules may only use that API.
- **Each module owns its data.** Nobody reads another module's tables directly.
- **Boundaries are checked automatically**, with lint rules or architecture tests that fail when someone imports something internal. A convention that only lives in the wiki ends up ignored.
- **Communication can use internal events** where it makes sense, just as it would between services.
The best thing about this approach is that **it keeps the decision open**. If a module has a stable boundary and a real reason to split it appears, extracting it as a service is almost mechanical: the public API becomes an HTTP or event contract, and its tables were already its own. And if you got a boundary wrong, fixing it is a refactor inside one repository, not a migration between systems.
#### The real operational cost of each new service
A new service isn't just one more folder. It's all of this, multiplied by every service:
| Item | Modular monolith | Each new service |
| --- | --- | --- |
| CI/CD pipeline | One | One more |
| Infrastructure and environments | Shared | Its own (or templates to maintain) |
| Calls between parts | An in-memory function call | Network: latency, timeouts, retries |
| Data consistency | Transactions | Sagas, events, eventual consistency |
| Debugging | One local trace | Distributed tracing |
| Contract versioning | Refactor in a single commit | Backwards compatibility across deploys |
| Monitoring and alerts | One set | One set per service |
None of these rows is impossible. But each one costs engineering time that isn't spent on the product. That cost only pays off when the problem the service solves (independent teams, scaling, isolation) is bigger than it.
#### How I decide
When I have to decide whether to split something, I ask myself three questions:
1. **What concrete problem does the split solve?** If the answer is "the code is messy", the network won't fix it.
2. **Has the boundary been stable for a while?** If it's still moving, it's not yet time to turn it into a contract between services.
3. **Can I afford the operational cost?** Pipelines, observability, on-call and versioned contracts, forever.
If all three answers are clear, I split without hesitation. If any of them isn't, I first get things in order inside a modular monolith, and let the system tell me where its real boundaries are.
Microservices are an excellent tool for scaling an organisation. For organising code, the tool is the same as ever: **good boundaries, even when they live inside a single deployment**.
#### Prompt
```text
Act as a software architect and organise this project as a modular monolith: one application and one deployment, with explicit internal boundaries.
Before touching code:
- Analyse the domain and propose the modules (for example catalogue, orders, payments) and what each one owns. If a boundary isn't clear, say so instead of forcing it.
- Don't propose microservices unless there is a concrete problem that justifies them: teams blocking each other's deploys, very different rates of change or load profiles, or failures that need isolating.
Rules for every module:
- It exposes a public API and hides everything else; other modules only use that API.
- It owns its data: no module reads another module's tables.
- Use internal events where communication makes sense as an event, just as it would between services.
- Boundaries are checked automatically (lint rules or architecture tests) and CI fails if anyone imports another module's internal code. No conventions that only live in a document.
How to work:
- If the project already exists, refactor in small, deployable steps, starting with the module whose boundary is most stable.
- Keep calls between modules in memory and transactions local while it's a single deployment.
- Leave each module ready to be extracted as a service if that's ever needed: its public API would become an HTTP or event contract.
Verify that the boundary check fails on a deliberately forbidden import and that the tests still pass.
Before you start, ask me about anything you can't infer from the code (stack, team size, deployment constraints) and explain every boundary decision you make.
```
---
### Monolito modular antes que microservicios
> Los microservicios resuelven problemas de organización, no de código. Cuándo partir un sistema y cuándo no.
- URL: https://urimarti.com/blog/monolito-modular-antes-que-microservicios/
- Date: 2026-04-14
- Language: es
- Tags: arquitectura, microservicios
- Author: Oriol Martí
He trabajado en monolitos que daban miedo tocar y en sistemas de microservicios donde un cambio pequeño exigía coordinar tres despliegues. Las dos cosas son posibles, y casi nunca la culpa es del patrón: es de **cuándo** y **por qué** se eligió.
Mi posición es sencilla: **partir un sistema antes de tener sus límites claros multiplica el acoplamiento en lugar de reducirlo**. Los microservicios no hacen que el código esté mejor organizado. Hacen que cada error de organización sea más caro, porque ahora pasa por la red.
#### Qué resuelven de verdad los microservicios
Antes de decidir, conviene ser honesto sobre qué problema se quiere resolver. Los microservicios resuelven bien estos:
- **Equipos que se pisan.** Varios equipos que tocan el mismo código y tienen que coordinar cada despliegue.
- **Ritmos distintos.** Una parte del sistema cambia diez veces al día y otra, una vez al trimestre.
- **Necesidades de escalado distintas.** Un componente necesita mucha más capacidad que el resto, o un perfil de recursos muy diferente.
- **Aislamiento de fallos.** Que un problema en una parte no tumbe todo lo demás.
- **Tecnologías distintas.** Una pieza tiene un buen motivo para estar en otro lenguaje o con otra base de datos.
Fíjate en que casi todos son problemas **de organización y de operación**, no de diseño del código. Si el código está desordenado, partirlo en servicios no lo ordena: reparte el desorden entre varios repositorios y le añade latencia.
#### Señales de que toca partir
Partiría un sistema cuando aparecen varias de estas señales a la vez:
- **El despliegue es el cuello de botella.** Los equipos esperan unos a otros para salir a producción, o un cambio pequeño exige probarlo todo.
- **Hay una frontera que ya funciona como tal.** Un módulo con un contrato estable, que casi nunca necesita cambiar a la vez que el resto.
- **El perfil de carga es muy distinto.** Una parte recibe picos que no tienen nada que ver con el resto del sistema.
- **Un fallo localizado tumba demasiado.** Un error en una funcionalidad secundaria deja caída la principal.
- **Hay un equipo dispuesto a ser el dueño.** Un servicio sin dueño claro se convierte en el que nadie quiere tocar.
#### Señales de que todavía no
Y esperaría si reconozco alguna de estas:
- **Los límites cambian cada mes.** Si todavía no sabes dónde acaba "pedidos" y empieza "pagos", cualquier frontera que pongas ahora será la equivocada, y moverla entre servicios cuesta mucho más que moverla entre carpetas.
- **Todo cambio toca varias partes.** Si cada funcionalidad nueva modifica tres módulos, partirlos convierte cada funcionalidad en tres despliegues coordinados.
- **El equipo es pequeño.** Un equipo de pocas personas con diez servicios dedica más tiempo a mantener la infraestructura que a construir producto.
- **No hay base operativa.** Sin despliegue automatizado, logs centralizados, trazas y alertas, depurar un fallo que cruza servicios es adivinar.
- **El motivo es "así es como se hace".** Es el peor motivo, y es más habitual de lo que parece.
#### El camino intermedio: módulos con fronteras explícitas
Entre el monolito desordenado y los microservicios hay una opción que suele ser la mejor durante mucho tiempo: el **monolito modular**.
Es una sola aplicación y un solo despliegue, pero organizada por dentro como si fueran servicios:
```text
┌───────────────────────────── Aplicación (un despliegue) ─────────────────────────────┐
│ │
│ ┌─────────────┐ API pública ┌─────────────┐ API pública ┌─────────────┐ │
│ │ Catálogo │ ◀─────────────▶ │ Pedidos │ ◀─────────────▶ │ Pagos │ │
│ │ (sus tablas)│ │ (sus tablas)│ │ (sus tablas)│ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ Prohibido: importar código interno de otro módulo o leer sus tablas │
└──────────────────────────────────────────────────────────────────────────────────────┘
```
Las reglas que lo hacen funcionar:
- **Cada módulo expone una API pública** y esconde todo lo demás. Los otros módulos solo pueden usar esa API.
- **Cada módulo es dueño de sus datos.** Nadie lee las tablas de otro módulo directamente.
- **Las fronteras se comprueban de forma automática**, con reglas de lint o tests de arquitectura que fallan si alguien importa algo interno. Una convención que solo vive en la wiki acaba ignorándose.
- **La comunicación puede ser por eventos internos** donde tenga sentido, igual que lo sería entre servicios.
Lo mejor de este enfoque es que **mantiene abierta la decisión**. Si un módulo tiene una frontera estable y aparece un motivo real para separarlo, extraerlo como servicio es casi mecánico: la API pública se convierte en un contrato HTTP o de eventos, y sus tablas ya eran solo suyas. En cambio, si te equivocaste de frontera, corregirla es un refactor dentro de un mismo repositorio, no una migración entre sistemas.
#### El coste operativo real de cada servicio nuevo
Un servicio nuevo no es solo una carpeta más. Es todo esto, multiplicado por cada servicio:
| Concepto | Monolito modular | Cada servicio nuevo |
| --- | --- | --- |
| Pipeline de CI/CD | Uno | Uno más |
| Infraestructura y entornos | Compartida | Propia (o plantillas que mantener) |
| Llamadas entre partes | Una función en memoria | Red: latencia, timeouts, reintentos |
| Consistencia de datos | Transacciones | Sagas, eventos, consistencia eventual |
| Depuración | Una traza local | Trazado distribuido |
| Versionado de contratos | Refactor en un mismo commit | Compatibilidad hacia atrás entre despliegues |
| Monitorización y alertas | Un conjunto | Un conjunto por servicio |
Ninguna de estas filas es imposible. Pero todas cuestan tiempo de ingeniería que no se dedica al producto. Ese coste solo compensa cuando el problema que resuelve el servicio (equipos independientes, escalado, aislamiento) es mayor que él.
#### Cómo lo decido
Cuando me toca decidir si partir algo, me hago tres preguntas:
1. **¿Qué problema concreto resuelve la separación?** Si la respuesta es "el código está desordenado", el problema no se arregla con red.
2. **¿La frontera lleva tiempo estable?** Si sigue moviéndose, todavía no es momento de convertirla en un contrato entre servicios.
3. **¿Puedo pagar el coste operativo?** Pipelines, observabilidad, guardias y contratos versionados, para siempre.
Si las tres respuestas son claras, separo sin dudarlo. Si alguna no lo es, ordeno primero por dentro con un monolito modular, y dejo que el sistema me diga dónde están sus fronteras de verdad.
Los microservicios son una herramienta excelente para escalar una organización. Para ordenar el código, la herramienta sigue siendo la misma de siempre: **buenos límites, aunque estén dentro del mismo despliegue**.
#### Prompt
```text
Actúa como arquitecto de software y organiza este proyecto como un monolito modular: una sola aplicación y un solo despliegue, con fronteras internas explícitas.
Antes de tocar código:
- Analiza el dominio y propón los módulos (por ejemplo catálogo, pedidos, pagos) con lo que es dueño cada uno. Si una frontera no está clara, dilo en lugar de forzarla.
- No propongas microservicios salvo que haya un problema concreto que lo justifique: equipos que se pisan al desplegar, ritmos de cambio o perfiles de carga muy distintos, o fallos que necesitan aislarse.
Reglas de cada módulo:
- Expone una API pública y esconde todo lo demás; los otros módulos solo usan esa API.
- Es dueño de sus datos: ningún módulo lee las tablas de otro.
- Usa eventos internos donde la comunicación tenga sentido como evento, igual que entre servicios.
- Las fronteras se comprueban de forma automática (reglas de lint o tests de arquitectura) y el CI falla si alguien importa código interno de otro módulo. Nada de convenciones que solo vivan en un documento.
Cómo trabajar:
- Si el proyecto ya existe, refactoriza por pasos pequeños y desplegables, empezando por el módulo con la frontera más estable.
- Mantén las llamadas entre módulos en memoria y las transacciones locales mientras sea un solo despliegue.
- Deja cada módulo listo para extraerse como servicio si algún día hace falta: su API pública se convertiría en un contrato HTTP o de eventos.
Verifica que la comprobación de fronteras falla con una importación prohibida de prueba y que los tests siguen pasando.
Antes de empezar, pregúntame lo que no puedas deducir del código (stack, tamaño del equipo, restricciones de despliegue) y explica cada decisión de frontera que tomes.
```
---
### Cutting response time by 90% without rewriting the service
> Anonymised case study: finding the bottleneck on a platform serving more than 2M requests a month.
- URL: https://urimarti.com/en/blog/recortar-un-90-el-tiempo-de-respuesta/
- Date: 2026-03-10
- Language: en
- Tags: rendimiento, redis, observabilidad
- Author: Oriol Martí
There's a very common temptation when a service is slow: rewrite it. Another language, another framework, another architecture. It's almost always a bad idea. What's slow is rarely the *whole* service; it's usually one specific part nobody has measured yet.
This is the story of how a four-engineer team cut the **response time of a core service by 90%** on an e-commerce platform, without rewriting it. There was no silver bullet. There was a method.
#### Context
The platform handles **more than two million requests a month**. The backend is split across several TypeScript services built with NestJS, with MongoDB as the database and Redis available for caching.
One of those services sits at the centre of everything: almost every important page or flow goes through it. When that service is slow, everything else is slow. And it was.
We were a team of four, owning the backend and part of the frontend. At that size there's no room for a months-long project that freezes all other work.
#### Constraints
Before touching anything, we set the rules of the game:
| Constraint | Consequence |
| --- | --- |
| No business downtime | No maintenance windows, no big-bang migrations. |
| No full rewrite | Incremental changes, each one deployable on its own. |
| Small team | Prioritise by impact: the heaviest problem first. |
| Other teams depend on the service | The API contract can't change. |
These constraints weren't an obstacle; they were the best guide. They force you to do what you should be doing anyway: measure, go after what hurts most, and ship in small steps.
#### Diagnosis: measure before you argue
On any team, everyone has a theory about why something is slow. "It's the database." "It's the ORM." "It's Node." Theories are worthless until they're measured.
The first step was getting real visibility into **where each request spends its time**. Not the service's average latency, which hides almost everything, but the breakdown: how much is our own logic, how much is MongoDB queries and how much is calls to other services.
```text
Request to the core service
│
├─ own logic ................ small
├─ MongoDB queries .......... how many? which indexes?
└─ calls to other services
├─ service A ........... sequential or parallel?
├─ service B ........... repeated on every request?
└─ service C ........... what happens when it's slow?
```
With that breakdown, the usual suspects in a service that has grown for years show up:
- **Chained synchronous calls** to other services: each one's latency adds to the next.
- **Data that rarely changes but is fetched on every request**, from the database or another service.
- **Queries without the right index**, or fetching far more than is used.
- **Repeated work** within the same request.
The point of diagnosis isn't to find *one* culprit but to rank the culprits by weight. That ranking decides the plan.
#### Design: attack in order of impact
With causes ranked, the plan almost writes itself. Each change had to meet two conditions: target a measured cause, and be deployable on its own.
##### 1. Stop asking for what we already know
One of the most expensive patterns in a service like this is repeatedly fetching data that belongs to other services and rarely changes. The textbook answer is a Redis cache in front of those calls. It works, but it has limits: the first access is still slow, and if the other service goes down, the cache eventually expires.
So we went a step further and built an **internal readstore**: a local copy of the data from other services that we need to answer requests, kept up to date when that data changes. The core service no longer asks anyone on the critical path; it reads from its own store.
```text
Before: client ─▶ core service ─▶ service A ─▶ service B ─▶ ...
After: client ─▶ core service ─▶ local readstore
▲
services A, B ───────┘ (update the readstore when their data changes)
```
That paid off twice: **lower latency** and **more resilience**. If another service has a bad day, the core one keeps answering with the latest data it knows.
The price is eventual consistency: for a moment, the readstore may lag behind the source. You have to decide deliberately which data can tolerate that and which can't. That trade-off deserves its own post.
##### 2. Better communication between services
The calls that remained necessary were reviewed one by one: which could run in parallel instead of in sequence, which had sensible timeouts, and which could degrade gracefully when the other side failed.
##### 3. The basics, done right
The rest was unglamorous but necessary work: MongoDB indexes aligned with the real queries, projections that only fetch the fields in use, and removing duplicated work within a single request.
#### Outcome
The core service's response time dropped by **90%**. Without a rewrite, without stopping the business and without changing its contract with other teams.
None of it was free:
- **More moving parts.** The readstore is one more component to watch: you need to know when it falls behind and how to rebuild it.
- **Eventual consistency.** Some data may take a moment to reflect a change. That's acceptable where it is, and it has to be documented.
- **Measurement discipline.** Keeping the gain means measuring continuously; otherwise latency creeps back with every new feature.
#### What I learned
**Measure before you argue.** The team's gut feeling about the cause rarely matches the data completely. The per-request breakdown reshuffled our priorities.
**A service's latency is the sum of its dependencies'.** In a distributed system, the fastest service in the world is slow if its critical path waits on three others. Taking dependencies off that path is worth more than optimising code.
**A cache and a readstore are not the same thing.** A cache speeds up what's already been requested. A readstore changes who owns the data. The second is more work, but it buys resilience as well as speed.
**Constraints help.** "No rewrite, no downtime" forced us into small, measurable, reversible changes. Which is exactly how it should always be done.
Rewriting is tempting because it feels like a fresh start. But what's needed is almost never a new service; it's knowing exactly where the existing one loses its time.
#### Prompt
```text
Act as a performance engineer: audit and optimise my slow endpoint (or service) following this method, without rewriting it.
Constraints:
- No downtime and no big-bang migrations: small changes, each deployable and reversible on its own.
- The API contract doesn't change.
- Prioritise by measured impact: what weighs most goes first.
Step 1, measure before you opine:
- Instrument the breakdown of each request, not the average: own logic, database queries (how many, which indexes they use) and calls to other services (serial or parallel, repeated or not, what happens when they're slow).
- Look at percentiles (p95/p99), not just the mean.
- Rank the causes by weight and show me the list before changing code.
Step 2, attack in order:
- Data from other services that rarely changes but is requested on every call: a cache (e.g. Redis) or, if it's on the critical path, a local readstore updated when the data changes. State which data tolerates eventual consistency and which doesn't.
- Calls that are still needed: in parallel where possible, sensible timeouts and graceful degradation when the other side fails.
- The basics: indexes aligned with the real queries, projections that fetch only the fields used, and no repeated work within the same request.
Don't:
- Rewrite the service or switch language or framework.
- Optimise code before taking dependencies off the critical path.
Verify:
- Measure before and after each change (mean, p95, p99) and attribute the gain to each one.
- Keep the measurement running so latency doesn't creep back with every new feature.
Before starting, ask me whatever you can't infer (stack, observability tools, which data tolerates lag) and explain each decision.
```
---
### Recortar un 90% el tiempo de respuesta sin reescribir el servicio
> Caso real anonimizado: cómo se localizó el cuello de botella en una plataforma de más de 2M de peticiones al mes.
- URL: https://urimarti.com/blog/recortar-un-90-el-tiempo-de-respuesta/
- Date: 2026-03-10
- Language: es
- Tags: rendimiento, redis, observabilidad
- Author: Oriol Martí
Hay una tentación muy común cuando un servicio va lento: reescribirlo. Otro lenguaje, otro framework, otra arquitectura. Casi siempre es una mala idea. Lo lento rara vez es *todo* el servicio; suele ser una parte concreta que nadie ha medido todavía.
Este es el caso de cómo un equipo de cuatro ingenieros redujo un **90% el tiempo de respuesta** del servicio principal de una plataforma e-commerce sin reescribirlo. No hubo bala de plata: hubo método.
#### Contexto
La plataforma atiende **más de dos millones de peticiones al mes**. El backend está repartido en varios servicios en TypeScript con NestJS, con MongoDB como base de datos y Redis disponible como caché.
Uno de esos servicios es el centro de todo: casi cualquier página o flujo importante pasa por él. Cuando ese servicio va lento, va lento todo lo demás. Y lo iba.
El equipo éramos cuatro personas, responsables del backend y de parte del frontend. Con ese tamaño no hay margen para un proyecto de meses que congele el resto del trabajo.
#### Restricciones
Antes de tocar nada, dejamos claras las reglas del juego:
| Restricción | Consecuencia |
| --- | --- |
| Sin parar el negocio | Nada de ventanas de mantenimiento ni migraciones *big bang*. |
| Sin reescritura completa | Cambios incrementales, cada uno desplegable por separado. |
| Equipo pequeño | Priorizar por impacto: primero lo que más pesa. |
| Otros equipos dependen del servicio | El contrato de la API no puede cambiar. |
Estas restricciones no eran un obstáculo, sino la mejor guía. Obligan a hacer lo que hay que hacer de todas formas: medir, atacar lo que más duele y desplegar en pasos pequeños.
#### Diagnóstico: medir antes de opinar
En un equipo, todo el mundo tiene una teoría sobre por qué algo va lento. "Es la base de datos." "Es el ORM." "Es Node." Las teorías no sirven de nada hasta que se miden.
El primer paso fue tener visibilidad real de **dónde se va el tiempo de cada petición**. No el tiempo medio del servicio, que esconde casi todo, sino el desglose: cuánto es lógica propia, cuánto consultas a MongoDB y cuánto llamadas a otros servicios.
```text
Petición al servicio principal
│
├─ lógica propia ............ poco
├─ consultas a MongoDB ...... ¿cuántas? ¿qué índices usan?
└─ llamadas a otros servicios
├─ servicio A .......... ¿en serie o en paralelo?
├─ servicio B .......... ¿se repiten en cada petición?
└─ servicio C .......... ¿qué pasa si tarda?
```
Con ese desglose aparecen las sospechosas habituales en un servicio que ha crecido durante años:
- **Llamadas síncronas encadenadas** a otros servicios: la latencia de cada una se suma a la siguiente.
- **Datos que cambian poco pero se piden en cada petición**, a la base de datos o a otro servicio.
- **Consultas sin el índice adecuado** o que traen mucho más de lo que se usa.
- **Trabajo repetido** dentro de la misma petición.
Lo importante del diagnóstico no es encontrar *un* culpable, sino ordenar los culpables por peso. Eso decide el plan.
#### Diseño: atacar por orden de impacto
Con las causas ordenadas, el plan se escribe casi solo. Cada cambio debía cumplir dos condiciones: atacar una causa medida y poder desplegarse solo.
##### 1. Dejar de preguntar lo que ya sabemos
Uno de los patrones más caros en un servicio así es pedir, una y otra vez, datos que pertenecen a otros servicios y que cambian poco. La respuesta clásica es una caché en Redis delante de esas llamadas. Funciona, pero tiene un límite: el primer acceso sigue siendo lento, y si el otro servicio cae, la caché acaba expirando.
Por eso fuimos un paso más allá y montamos un **readstore interno**: una copia local de los datos de otros servicios que necesitamos para responder, mantenida al día cuando esos datos cambian. El servicio principal ya no pregunta a nadie en el camino crítico: lee de lo suyo.
```text
Antes: cliente ─▶ servicio principal ─▶ servicio A ─▶ servicio B ─▶ ...
Después: cliente ─▶ servicio principal ─▶ readstore local
▲
servicios A, B ──┘ (actualizan el readstore cuando cambian sus datos)
```
Esto tuvo un efecto doble: **menos latencia** y **más resiliencia**. Si otro servicio tiene un mal día, el principal sigue respondiendo con los últimos datos que conoce.
El precio es la consistencia eventual: durante un instante, el readstore puede ir por detrás del original. Hay que decidir conscientemente qué datos lo toleran y cuáles no. De ese dilema hablaré en otro post.
##### 2. Mejorar la comunicación entre servicios
Las llamadas que siguieron siendo necesarias se revisaron una a una: cuáles podían ir en paralelo en lugar de en serie, cuáles tenían timeouts razonables y cuáles podían degradarse con elegancia si el otro lado fallaba.
##### 3. Lo básico, bien hecho
El resto fue trabajo poco vistoso pero necesario: índices de MongoDB alineados con las consultas reales, proyecciones que solo traen los campos que se usan y eliminar trabajo duplicado dentro de una misma petición.
#### Resultado
El tiempo de respuesta del servicio principal bajó un **90%**. Sin reescribirlo, sin parar el negocio y sin cambiar su contrato con el resto de equipos.
No todo fue gratis:
- **Más piezas que mantener.** El readstore es otro componente que vigilar: hay que saber si se queda atrás y cómo reconstruirlo.
- **Consistencia eventual.** Algunos datos pueden tardar un momento en reflejar un cambio. Es aceptable donde lo es, y hay que documentarlo.
- **Disciplina de medición.** Mantener la mejora exige seguir midiendo; si no, la latencia vuelve poco a poco con cada funcionalidad nueva.
#### Lo que aprendí
**Medir antes de opinar.** La intuición del equipo sobre la causa rara vez coincide del todo con lo que dicen los datos. El desglose por petición cambió el orden de prioridades.
**La latencia de un servicio es la suma de la de sus dependencias.** En una arquitectura distribuida, el servicio más rápido del mundo es lento si en el camino crítico espera a otros tres. Sacar dependencias de ese camino vale más que optimizar código.
**Caché y readstore no son lo mismo.** Una caché acelera lo que ya se ha pedido. Un readstore cambia quién tiene los datos. El segundo es más trabajo, pero aporta resiliencia además de velocidad.
**Las restricciones ayudan.** "Sin reescribir y sin parar" nos obligó a hacer cambios pequeños, medibles y reversibles. Es exactamente como debería hacerse siempre.
Reescribir es tentador porque parece un nuevo comienzo. Pero casi siempre lo que hace falta no es un servicio nuevo, sino saber exactamente dónde se pierde el tiempo en el que ya existe.
#### Prompt
```text
Actúa como ingeniero de rendimiento: audita y optimiza mi endpoint (o servicio) lento siguiendo este método, sin reescribirlo.
Restricciones:
- Sin parar el negocio ni migraciones big bang: cambios pequeños, cada uno desplegable y reversible por separado.
- El contrato de la API no cambia.
- Prioriza por impacto medido: primero lo que más pesa.
Paso 1, medir antes de opinar:
- Instrumenta el desglose de cada petición, no la media: lógica propia, consultas a la base de datos (cuántas, qué índices usan) y llamadas a otros servicios (en serie o en paralelo, si se repiten, qué pasa si tardan).
- Mira percentiles (p95/p99), no solo la media.
- Ordena las causas por peso y enséñame la lista antes de cambiar código.
Paso 2, atacar por orden:
- Datos de otros servicios que cambian poco y se piden en cada petición: caché (p. ej. Redis) o, si están en el camino crítico, un readstore local alimentado cuando cambian. Indica qué datos toleran consistencia eventual y cuáles no.
- Llamadas que siguen siendo necesarias: en paralelo cuando se pueda, timeouts razonables y degradación elegante si el otro lado falla.
- Lo básico: índices alineados con las consultas reales, proyecciones que traen solo los campos usados y eliminar trabajo repetido dentro de la misma petición.
No hagas:
- Reescribir el servicio ni cambiar de lenguaje o framework.
- Optimizar código antes de sacar dependencias del camino crítico.
Verifica:
- Mide antes y después de cada cambio (media, p95, p99) y atribuye la mejora a cada uno.
- Deja la medición en marcha para que la latencia no vuelva con cada funcionalidad nueva.
Antes de empezar, pregúntame lo que no puedas deducir (stack, herramientas de observabilidad, qué datos toleran retraso) y explícame cada decisión.
```
---
### Readstore vs synchronous calls between services
> When keeping a local copy of another service's data pays off, and what it costs in consistency.
- URL: https://urimarti.com/en/blog/readstore-frente-a-llamadas-sincronas/
- Date: 2026-02-12
- Language: en
- Tags: microservicios, resiliencia, datos
- Author: Oriol Martí
As soon as a system is split into services, the same question shows up: **when service A needs data owned by service B, does it ask for it on the spot or keep a copy?**
The default answer is usually to ask: an HTTP call, and if it's slow, a cache in front. It's the simplest thing to build and it works well at first. The trouble starts when those calls chain together.
On an e-commerce platform serving more than two million requests a month, one of the decisions that did the most for stability between services was building an **internal readstore**: a local, read-only copy of the data other services needed to query all the time. This post isn't about how to implement one. It's about when it pays off and what you give up in exchange.
#### The problem: synchronous chains
A user request that crosses several services synchronously has two problems that grow with every hop.
**Latency adds up.** If the catalogue service calls the pricing service, which calls the stock service, the user waits for all three plus the network in between. The 99th percentile is worse still, because any one of the three having a bad moment is enough.
**Availability multiplies down.** If each service is up 99.9% of the time, a request that needs all three at once is up 99.7% at best. Every synchronous dependency you add is one more way for your service to fail for reasons that aren't its fault.
```text
User ──▶ Catalogue ──HTTP──▶ Pricing ──HTTP──▶ Stock
│ │ │
latency = L1 + L2 + L3
availability = A1 × A2 × A3
```
There's a third, less visible effect: **operational coupling**. If Pricing goes down, Catalogue goes down with it. The two teams can no longer deploy, scale or have incidents independently, which was the whole point of splitting them.
#### The options
##### 1. Synchronous call plus cache
It's the first reaction, and often it's enough. The cache lowers average latency and absorbs some of the remote service's failures.
But it has clear limits. A cache only helps with what it has already seen: a miss still depends on the remote service. TTLs force a choice between fresh data and protection. And when the cache is emptied (a deploy, a restart, a new key), all traffic hits the origin at once, exactly when it can least take it.
##### 2. Event-fed readstore
The service that owns the data publishes an event whenever it changes. The consuming service listens to those events and keeps its own copy, shaped exactly the way its queries need it.
```text
Pricing ──"price updated" event──▶ queue/bus ──▶ projector ──▶ Readstore
│
User ──▶ Catalogue ──local query───────────────────────────────────┘
```
At request time there's no remote call anymore: the read is local. If Pricing is down, Catalogue keeps answering with the latest data it knows. And when Pricing comes back, the pending events bring the copy up to date.
##### 3. Shared database
Both services reading from the same tables. It's the fastest option to build, and I rule it out almost every time: it turns one service's schema into a public contract nobody can change without coordinating with everyone else. It's a distributed monolith with extra latency.
#### Side by side
| | Sync + cache | Event-fed readstore | Shared DB |
| --- | --- | --- | --- |
| Read latency | Variable (hits/misses) | Low and stable | Low |
| If the owner is down | Fails on cache miss | Keeps working | Depends on the DB |
| Data freshness | Up to the TTL | Seconds behind | Immediate |
| Build cost | Low | Medium-high | Very low |
| Coupling | Medium (API contract) | Low (event contract) | High (schema) |
#### The price: what nobody tells you about readstores
##### Eventual consistency
The copy is always slightly behind the original. Usually by milliseconds or seconds, but if the queue backs up it can be minutes.
The useful question isn't "is eventual consistency acceptable?" but **"on which screens is it acceptable?"**. Showing a listing with a price that changed five seconds ago is usually perfectly fine. Charging that price isn't. My rule: **the readstore is for displaying; decisions are made against the data owner**.
##### Maintaining the projection
A readstore isn't a cache that fills itself. It's code you have to maintain:
- **Replays.** If the projection had a bug, or you need a new field, you must be able to rebuild it from scratch. That means being able to re-read events or do an initial load from the owner.
- **Event versioning.** The event is now a contract between teams. Adding fields is easy; renaming or removing them means living with two versions for a while.
- **Ordering and duplicates.** Events can arrive twice or out of order. The projection has to be idempotent and know how to discard an event older than the data it already holds.
- **Observability.** You need to know how far behind the copy is. A stale readstore nobody knows about is worse than a call that fails visibly.
##### Who owns the data
This is the point most often forgotten. With a readstore, **the data still has a single owner**. The copy is read-only and nothing but the projector writes to it. If a service starts writing to its copy "because it's faster", you end up with two sources of truth drifting apart, and that's far worse than any latency problem.
#### When I'd pick it and when I wouldn't
**I'd pick it when:**
- One service constantly reads another's data, and that read sits on the critical path of user requests.
- The data changes far less often than it's read.
- The screen can live with a few seconds of delay.
- I want a service to keep working even when its dependency is down.
- Event infrastructure already exists, or it makes sense to build it.
**I wouldn't when:**
- The read needs the exact value right now (balances, payments, stock reservations at checkout).
- Call volume is low and a cache with a sensible TTL does the job.
- The team doesn't yet have the operational maturity to run queues, replays and lag alerts. A poorly maintained readstore causes more problems than it solves.
- The services were split for no clear reason, and the real question is whether they should be one.
#### The underlying idea
A readstore trades **latency and availability for consistency and operational complexity**. It isn't a free upgrade: it's a trade. It pays off when the synchronous dependency is on the critical path and the business can tolerate a few seconds of delay on reads.
Done well, the effect shows up in the hardest thing to achieve in a distributed system: one service failing no longer automatically means the others fail too.
#### Prompt
```text
Act as a backend architect: I want service A to stop calling service B (the owner of the data) synchronously and read from a local readstore fed by events instead.
Before building anything, check that it fits:
- It pays off when the read is on the critical path of requests, the data changes far less often than it is read, and the screen tolerates a few seconds of lag.
- If volume is low and a cache with a good TTL solves it, or if the read needs the exact current value (balances, charges, stock reservation at checkout), tell me and don't do it.
Build:
- In B: publish an event every time the data changes (e.g. "price updated"), with a version or timestamp, to a queue or bus.
- In A: a projector that consumes those events and writes a read-only copy shaped exactly for its queries.
- A's reads go to the readstore, with no remote calls at request time.
Rules:
- A single owner of the data: nobody writes to the copy except the projector.
- The readstore is for display; decisions (charging, reserving) are made against the owner.
- The projection is idempotent and discards events older than the data it already has (duplicates and out-of-order).
- Reprocessing: be able to rebuild the copy from scratch, by replaying events or with an initial load from B.
- Version the events: adding fields is free; renaming or removing them means living with two versions for a while.
- Don't share a database between services.
Verify:
- Projection tests with duplicated and out-of-order events.
- A metric and an alert on how far behind the copy is.
- That A keeps answering with B down and catches up when B comes back.
Before starting, ask me whatever you can't infer (stack, event infrastructure, which screens tolerate lag) and explain each decision.
```
---
### Readstore frente a llamadas síncronas entre servicios
> Cuándo compensa mantener una copia local de los datos de otro servicio y qué precio se paga en consistencia.
- URL: https://urimarti.com/blog/readstore-frente-a-llamadas-sincronas/
- Date: 2026-02-12
- Language: es
- Tags: microservicios, resiliencia, datos
- Author: Oriol Martí
En cuanto un sistema se parte en servicios aparece la misma pregunta: **cuando el servicio A necesita datos que son del servicio B, ¿se los pide en el momento o se guarda una copia?**
La respuesta por defecto suele ser preguntar: una llamada HTTP, y si va lenta, una caché delante. Es lo más sencillo de construir y funciona bien al principio. El problema aparece cuando esas llamadas se encadenan.
En una plataforma e-commerce de más de dos millones de peticiones al mes, una de las decisiones que más mejoró la estabilidad entre servicios fue construir un **readstore interno**: una copia local, de solo lectura, de los datos que otros servicios necesitaban consultar constantemente. Este post no va de cómo se implementa. Va de cuándo compensa hacerlo y de qué se paga a cambio.
#### El problema: las cadenas síncronas
Una petición de usuario que atraviesa varios servicios de forma síncrona tiene dos problemas que crecen con cada salto.
**La latencia se suma.** Si el servicio de catálogo llama al de precios, y este al de stock, el usuario espera la suma de los tres, más la red entre ellos. El percentil 99 es peor todavía, porque basta con que uno de los tres tenga un mal momento.
**La disponibilidad se multiplica a la baja.** Si cada servicio está disponible el 99,9 % del tiempo, una petición que necesita los tres a la vez está disponible, como mucho, el 99,7 %. Cada dependencia síncrona que añades es una forma más de que tu servicio falle por algo que no es culpa suya.
```text
Usuario ──▶ Catálogo ──HTTP──▶ Precios ──HTTP──▶ Stock
│ │ │
latencia = L1 + L2 + L3
disponibilidad = A1 × A2 × A3
```
A esto se suma un tercer efecto, menos visible: **el acoplamiento operativo**. Si Precios se cae, Catálogo se cae con él. Los dos equipos ya no pueden desplegar, escalar ni tener incidentes de forma independiente, que era justo el motivo de separarlos.
#### Las opciones
##### 1. Llamada síncrona más caché
Es la primera reacción, y muchas veces es suficiente. La caché reduce la latencia media y absorbe parte de los fallos del servicio remoto.
Pero tiene límites claros. Una caché solo ayuda con lo que ya ha visto: un fallo de caché sigue dependiendo del servicio remoto. Los TTL obligan a elegir entre datos frescos y protección. Y cuando la caché se vacía (un deploy, un reinicio, una clave nueva), todo el tráfico vuelve de golpe al servicio de origen, justo cuando menos lo puede aguantar.
##### 2. Readstore alimentado por eventos
El servicio dueño del dato publica un evento cada vez que este cambia. El servicio que lo consume escucha esos eventos y mantiene su propia copia, con la forma exacta que necesita para sus consultas.
```text
Precios ──evento "precio actualizado"──▶ cola/bus ──▶ proyector ──▶ Readstore
│
Usuario ──▶ Catálogo ──consulta local────────────────────────────────┘
```
En el momento de la petición ya no hay llamada remota: la lectura es local. Si Precios se cae, Catálogo sigue respondiendo con los últimos datos que conoce. Y cuando Precios vuelve, los eventos pendientes ponen la copia al día.
##### 3. Base de datos compartida
Que los dos servicios lean de las mismas tablas. Es la opción más rápida de construir, y la descarto casi siempre: convierte el esquema de un servicio en un contrato público que nadie puede cambiar sin coordinarse con todos los demás. Es un monolito distribuido con más latencia.
#### Comparativa
| | Síncrona + caché | Readstore por eventos | BD compartida |
| --- | --- | --- | --- |
| Latencia de lectura | Variable (aciertos/fallos) | Baja y estable | Baja |
| Si el dueño se cae | Falla al fallar la caché | Sigue funcionando | Depende de la BD |
| Frescura del dato | Hasta el TTL | Segundos de retraso | Inmediata |
| Coste de construcción | Bajo | Medio-alto | Muy bajo |
| Acoplamiento | Medio (contrato API) | Bajo (contrato de eventos) | Alto (esquema) |
#### El precio: lo que no te cuentan del readstore
##### Consistencia eventual
La copia va siempre un poco por detrás del original. Normalmente son milisegundos o segundos, pero si la cola se atasca pueden ser minutos.
La pregunta útil no es "¿es aceptable la consistencia eventual?", sino **"¿en qué pantallas es aceptable?"**. Mostrar un listado con un precio que cambió hace cinco segundos suele ser perfectamente tolerable. Cobrar ese precio, no. La regla que sigo: **el readstore sirve para mostrar; las decisiones se toman contra el dueño del dato**.
##### Mantener la proyección
Un readstore no es una caché que se llena sola. Es código que hay que mantener:
- **Reprocesado.** Si la proyección tenía un bug, o necesitas un campo nuevo, tienes que poder reconstruirla desde cero. Eso exige poder volver a leer los eventos o hacer una carga inicial desde el dueño.
- **Versionado de eventos.** El evento es ahora un contrato entre equipos. Añadir campos es fácil; renombrarlos o quitarlos requiere convivir con dos versiones durante un tiempo.
- **Orden y duplicados.** Los eventos pueden llegar dos veces o desordenados. La proyección tiene que ser idempotente y saber descartar un evento más antiguo que el dato que ya tiene.
- **Observabilidad.** Necesitas saber cuánto retraso lleva la copia. Un readstore desactualizado sin que nadie lo sepa es peor que una llamada que falla de forma visible.
##### Quién es el dueño del dato
Este es el punto que más se olvida. Con un readstore, **el dato sigue teniendo un único dueño**. La copia es de solo lectura y nadie la modifica salvo el proyector. Si un servicio empieza a escribir en su copia "porque es más rápido", acabas con dos fuentes de verdad que divergen, y eso es mucho peor que cualquier problema de latencia.
#### Cuándo lo elegiría y cuándo no
**Lo elegiría cuando:**
- Un servicio lee constantemente datos de otro, y esa lectura está en el camino crítico de las peticiones de usuario.
- El dato cambia mucho menos de lo que se lee.
- La pantalla tolera unos segundos de retraso.
- Quiero que un servicio siga funcionando aunque su dependencia esté caída.
- Ya existe, o tiene sentido crear, una infraestructura de eventos.
**No lo elegiría cuando:**
- La lectura necesita el valor exacto de este instante (saldos, cobros, reservas de stock en el checkout).
- El volumen de llamadas es bajo y una caché con un buen TTL lo resuelve.
- El equipo no tiene todavía la madurez operativa para mantener colas, reprocesados y alertas de retraso. Un readstore mal mantenido da más problemas que los que resuelve.
- Los servicios se separaron sin un motivo claro, y lo que de verdad toca es plantearse si deberían ser uno solo.
#### La idea de fondo
Un readstore cambia **latencia y disponibilidad por consistencia y complejidad operativa**. No es una mejora gratuita: es un intercambio. Compensa cuando la dependencia síncrona está en el camino crítico y el negocio tolera unos segundos de retraso en la lectura.
Bien aplicado, el efecto se nota en lo que más cuesta conseguir en un sistema distribuido: que el fallo de un servicio deje de convertirse automáticamente en el fallo de los demás.
#### Prompt
```text
Actúa como arquitecto backend: quiero que el servicio A deje de llamar de forma síncrona al servicio B (el dueño del dato) y lea de un readstore local alimentado por eventos.
Antes de construir nada, comprueba que encaja:
- Compensa si la lectura está en el camino crítico de las peticiones, el dato cambia mucho menos de lo que se lee y la pantalla tolera unos segundos de retraso.
- Si el volumen es bajo y una caché con buen TTL lo resuelve, o si la lectura necesita el valor exacto del momento (saldos, cobros, reserva de stock en el checkout), dímelo y no lo hagas.
Construye:
- En B: publicar un evento cada vez que cambia el dato (p. ej. "precio actualizado"), con versión o timestamp, en una cola o bus.
- En A: un proyector que consume esos eventos y escribe una copia de solo lectura con la forma exacta que necesitan sus consultas.
- Las lecturas de A van contra el readstore, sin llamadas remotas en el momento de la petición.
Reglas:
- Un único dueño del dato: nadie escribe en la copia salvo el proyector.
- El readstore sirve para mostrar; las decisiones (cobrar, reservar) se toman contra el dueño.
- La proyección es idempotente y descarta eventos más antiguos que el dato que ya tiene (duplicados y desorden).
- Reprocesado: poder reconstruir la copia desde cero, releyendo eventos o con una carga inicial desde B.
- Versiona los eventos: añadir campos es libre; renombrar o quitar exige convivir con dos versiones.
- No compartas la base de datos entre servicios.
Verifica:
- Tests de la proyección con eventos duplicados y desordenados.
- Una métrica y una alerta del retraso de la copia.
- Que A sigue respondiendo con B caído y que se pone al día cuando B vuelve.
Antes de empezar, pregúntame lo que no puedas deducir (stack, infraestructura de eventos, qué pantallas toleran retraso) y explícame cada decisión.
```