Un caso de estudio: diseñar un SaaS pieza por pieza
Cerramos el nivel construyendo el plano de una aplicación concreta y verosímil: un SaaS multiinquilino con panel de control, subida de documentos y notificaciones en vivo. No es un tutorial de código sino un ejercicio de decisiones, donde cada pieza se elige justificando qué propiedad se compra y qué se renuncia. Recorremos el reparto del estado entre las primitivas, los tres caminos críticos del producto con su presupuesto real, y terminamos con la parte que casi nunca se escribe: qué hemos dejado deliberadamente fuera del diseño y bajo qué señal habría que volver a abrirlo.
Diseñar una arquitectura no consiste en enumerar servicios, sino en tomar una secuencia de decisiones defendibles y dejar constancia de por qué cada una era preferible a su alternativa más razonable. Todo lo que hemos visto en el nivel se juega ahora sobre un caso concreto: una herramienta de gestión documental por suscripción, con varias organizaciones aisladas entre sí, un panel que muestra actividad reciente, subida de ficheros que pueden ser grandes y avisos que deben aparecer sin recargar la página. Es un producto deliberadamente mediano, del tamaño que casi cualquier equipo construye al menos una vez, y su interés está justamente en que ninguna de sus piezas es exótica: la dificultad está entera en el reparto.
- Traducir un enunciado de producto en un reparto explícito de estado entre las primitivas del edge.
- Justificar cada elección nombrando la propiedad que se compra y la que se abandona.
- Presupuestar los tres caminos críticos del producto en latencia, subpeticiones y coste.
- Documentar los límites del diseño y las señales concretas que obligarían a revisarlo.
El enunciado y sus restricciones
El producto guarda documentos para organizaciones. Cada usuario pertenece a una organización y solo ve lo suyo; el panel muestra los últimos movimientos y unas cuantas cifras agregadas; los documentos se suben desde el navegador y pueden llegar a varios cientos de megabytes; cuando un compañero sube algo, los demás lo ven aparecer sin refrescar. Hay además un plan de suscripción con límites por organización y una factura mensual.
De ese párrafo salen cuatro restricciones que gobiernan todo lo demás, y conviene escribirlas antes de tocar ninguna tecnología. Primera: el aislamiento entre organizaciones es un requisito de corrección, no una opción de diseño, así que la clave de inquilino tiene que aparecer en cada consulta y cada clave de objeto. Segunda: los ficheros grandes no pueden atravesar el Worker, porque materializarlos costaría memoria y tiempo de pared que no hay motivo para gastar. Tercera: el panel se lee muchísimo más de lo que se escribe, así que sus cifras admiten estar unos segundos desfasadas. Cuarta: las notificaciones son efímeras y deben llegar a quien esté mirando ahora, no a quien vuelva mañana.
| Módulo | Primitiva elegida | Propiedad que se compra | Lo que se renuncia |
|---|---|---|---|
| Usuarios, organizaciones y documentos | D1 |
Consultas con JOIN, transacciones, integridad referencial |
Escala de escritura, una primaria por base |
| Bytes de los documentos | R2 |
Coste bajo, sin egreso, subida directa desde el navegador | Sin transacciones entre objetos ni consultas |
| Plan, límites y flags | KV |
Lectura casi gratuita en cada petición | Consistencia eventual de segundos |
| Notificaciones y presencia | Durable Objects |
Un punto de coordinación por organización, tiempo real | Latencia de la ubicación del objeto |
| Miniaturas, indexado, avisos externos | Queues |
La respuesta no espera al trabajo derivado | Entrega al menos una vez, hay que ser idempotente |
| Facturación mensual | Workflows |
Proceso largo con memoria y reintentos por paso | Complejidad operativa de un motor más |
flowchart TD N[navegador] --> W[worker unico] W --> A[assets del panel] W --> KV[kv plan y limites] W --> D1[d1 metadatos y permisos] N -->|put firmado directo| R2[r2 documentos] W --> Q[queue documento subido] Q --> C[consumidor] C --> R2 C --> D1 C --> DO[durable object de la organizacion] DO -->|websocket| N WF[workflow de facturacion] --> D1 style W fill:#89b4fa,color:#11111b style DO fill:#cba6f7,color:#11111b
Los tres caminos del producto
Un producto se entiende mejor por sus caminos que por su diagrama, porque el diagrama enseña qué existe y el camino enseña qué cuesta. Estos tres concentran prácticamente todo el tráfico y todo el riesgo.
El primero es cargar el panel. Debe resolverse con una lectura de sesión, una lectura de plan y una única consulta agregada a D1, y el resultado se cachea unos segundos por organización porque nadie va a notar la diferencia. Tres subpeticiones y unos pocos milisegundos de CPU: ese es el presupuesto, y cualquier funcionalidad que pretenda añadir una cuarta consulta tiene que justificarse.
El segundo es subir un documento, y es el que más gente diseña mal. La tentación es que el fichero entre por el Worker, se valide allí y se escriba en R2, que funciona perfectamente con ficheros pequeños y se rompe con los grandes. La alternativa correcta invierte el flujo: el Worker no toca los bytes, solo autoriza. Emite una URL firmada de vida corta contra el endpoint compatible con S3, registra la intención en D1 y devuelve; el navegador sube directo y avisa al terminar.
import { AwsClient } from "aws4fetch";
app.post("/api/documentos/subida", async (c) => {
const usuario = c.get("usuario")!;
const clave = `org/${usuario.orgId}/${crypto.randomUUID()}`;
const r2 = new AwsClient({
accessKeyId: c.env.R2_ACCESS_KEY_ID,
secretAccessKey: c.env.R2_SECRET_ACCESS_KEY,
service: "s3",
});
const destino = new URL(
`https://${c.env.R2_CUENTA}.r2.cloudflarestorage.com/${c.env.R2_BUCKET}/${clave}`,
);
destino.searchParams.set("X-Amz-Expires", "600");
const firmada = await r2.sign(new Request(destino, { method: "PUT" }), {
aws: { signQuery: true },
});
await c.env.DB.prepare(
"INSERT INTO documentos (clave, org, autor, estado) VALUES (?, ?, ?, 'pendiente')",
).bind(clave, usuario.orgId, usuario.id).run();
return c.json({ url: firmada.url, clave });
});
Fíjate en dos detalles que no son cosméticos. El primero es que la clave del objeto empieza por el identificador de la organización: el aislamiento del inquilino queda grabado en el nombre, de modo que un fallo de autorización posterior no permite alcanzar el fichero de otro cliente por accidente. El segundo es que la fila se crea en estado pendiente antes de que exista el objeto, lo que da un rastro para limpiar las subidas abandonadas y una forma barata de aplicar el límite del plan antes de firmar nada.
El tercer camino es la notificación. Cuando el consumidor de la cola termina de procesar el documento, avisa al Durable Object de la organización, que mantiene las conexiones abiertas de quienes están mirando el panel en ese instante. Un objeto por organización es la granularidad correcta aquí: por usuario habría demasiados y no compartirían nada, y uno global sería un cuello de botella con todos los inquilinos serializados en el mismo hilo.
async function avisar(env: Env, orgId: string, evento: unknown): Promise<void> {
const id = env.SALA_ORG.idFromName(`org:${orgId}`);
await env.SALA_ORG.get(id).fetch("https://interno/emitir", {
method: "POST",
body: JSON.stringify(evento),
});
}
Cuando eliges un Durable Object por organización estás decidiendo, a la vez, tres cosas: qué se comparte en memoria, qué se serializa en un hilo y a cuántos clientes afecta que ese objeto tenga un mal día. Las tres decisiones se toman con la misma cadena de texto, y solo la primera es la que la gente cree estar tomando.
Los tres caminos comparten una propiedad que no es casual: en ninguno de ellos el usuario espera a un trabajo que no forma parte de la promesa. El panel espera a una consulta agregada porque sin ella no hay panel; la subida espera a una firma porque sin ella no hay destino; la notificación no la espera nadie, porque llega después por definición. Cuando un producto crece, la degradación empieza casi siempre por la erosión de esa disciplina: alguien añade al camino del panel una llamada al proveedor de facturación para mostrar los días restantes del plan, y una funcionalidad de dos líneas convierte una ruta de tres subpeticiones en una ruta que depende de un tercero.
Lo que dejamos fuera, y cuándo volver
Un diseño sin límites declarados no es un diseño, es una lista de deseos. Estas cuatro renuncias son deliberadas y cada una lleva pegada la señal que obligaría a reabrir la discusión, porque la señal es la parte útil: sin ella, la renuncia se convierte en un olvido.
Búsqueda de texto completo
Empezamos con LIKE sobre D1, que aguanta bien hasta unos miles de documentos por organización. Señal para cambiar: consultas de búsqueda por encima de doscientos milisegundos o quejas por resultados pobres.
Escritura multirregión
D1 tiene una única primaria y las lecturas se sirven de réplicas con la Sessions API. Señal: latencia de escritura inaceptable de forma sostenida en un mercado importante.
Analítica histórica
El panel agrega sobre la tabla viva. Señal: la consulta agregada empieza a leer decenas de miles de filas; entonces toca una tabla de agregados alimentada por la cola.
Un solo despliegue
Todo va junto porque el equipo es pequeño y los ritmos coinciden. Señal: dos áreas que necesitan desplegar con cadencias distintas; ahí entran los service bindings.
Hay además una quinta renuncia que no cabe en una tarjeta porque no es técnica: hemos aceptado que la facturación viva dentro del mismo proyecto en lugar de aislarla en un servicio con su propio ciclo de despliegue y su propio acceso restringido. Es defendible mientras el equipo sea pequeño y todo el mundo tenga el mismo nivel de acceso, y deja de serlo en el momento exacto en que la empresa contrata a la primera persona que debe poder tocar el panel sin poder tocar los cobros. La señal, en este caso, no es una métrica: es una incorporación.
Merece la pena notar lo que no aparece en ninguna de las cuatro tarjetas: ninguna renuncia se justifica por rendimiento teórico ni por elegancia. Todas se justifican por tamaño actual y todas tienen un umbral medible. Esa es la diferencia entre un diseño que envejece bien y uno que se reescribe entero a los dieciocho meses, cuando nadie recuerda qué supuestos lo sostenían y la única salida honesta parece empezar de cero.
Lo que distingue a un arquitecto de alguien que sabe muchas tecnologías se ve exactamente aquí, en la diferencia entre el diagrama y el documento. El diagrama de este SaaS lo puede dibujar cualquiera que haya leído los niveles anteriores: hay una base relacional, un almacén de objetos, un diccionario rápido, unos actores para el tiempo real y una cola. Es un dibujo correcto, útil para una pizarra y absolutamente insuficiente como legado, porque no contiene la única información que resulta escasa con el tiempo. Los nombres de los productos son públicos y estables; lo que se pierde son las razones. Dentro de dos años, cuando alguien que no estuvo en la sala se pregunte por qué las cifras del panel se cachean cinco segundos, no encontrará la respuesta en el diagrama: la respuesta es que se decidió, explícitamente, que una desviación de cinco segundos en un contador de actividad no perjudica a ningún usuario y ahorra un orden de magnitud de consultas. Sin esa frase escrita, el desarrollador nuevo tiene dos opciones y ambas son malas: respetar la decisión sin entenderla, convirtiéndola en superstición, o eliminarla porque le estorba, reintroduciendo un problema que ya estaba resuelto. Este es el motivo de que las secciones más valiosas de un diseño sean siempre las dos que menos se escriben, la de las renuncias y la de las señales de revisión: son las que convierten una arquitectura en algo que se puede seguir pensando, en lugar de en algo que solo se puede obedecer o demoler. Y hay una consecuencia práctica que vale para toda la carrera: la calidad de un sistema no se mide por lo bien que resuelve el problema del día del lanzamiento, sino por lo barato que resulta cambiarlo cuando el problema cambie, cosa que hará. En el edge esto se vuelve especialmente cierto, porque la plataforma ha eliminado tantas fricciones de infraestructura que la única fricción que queda es la que tú mismo has introducido al escribir tus supuestos en el código en lugar de en un párrafo. El código no explica por qué. Solo tú puedes hacerlo, y solo mientras todavía lo recuerdes.
- Escribe el enunciado de un producto real en un párrafo y extrae de él, sin abrir la documentación, las cuatro restricciones que gobiernan su arquitectura.
- Rellena la tabla de reparto con una fila por módulo y obliga a que cada fila diga qué renuncia. Si una fila no renuncia a nada, está mal escrita.
- Implementa el camino de subida directa con URL firmada y demuestra que ningún byte del fichero atraviesa el Worker.
- Elige la granularidad de tu
Durable Objecty argumenta las tres consecuencias simultáneas de esa elección: memoria compartida, serialización y radio de fallo. - Redacta la sección de renuncias con su señal medible para cada una. Ponla en el repositorio junto al diagrama y vuelve a leerla dentro de seis meses.