Elegir una Base de Datos para tu SaaS No Es Una Decisión. Son 4.

11 min read

Pasé una noche entera decidiendo "qué base de datos para mi SaaS" como si fuera una sola decisión. No lo es. Cómo separas los datos de cada tenant es una decisión. Qué evita que los errores de tu propio código filtren datos de un tenant a otro es una segunda. Qué usas para hablar realmente con la base de datos en TypeScript es una tercera. Si gestionas algo de esto tú mismo es una cuarta, y la mayoría de guías nunca pasan de la primera.

Ninguna de estas 4 es difícil por sí sola. El error está en tratar la primera como toda la decisión, y luego sorprenderte 6 meses después cuando la segunda resulta ser la que realmente se rompe en producción.

Trabajador de oficina señalando un árbol de decisiones mientras un superhéroe estudia cuatro diagramas complejos de arquitectura de base de datos en una pizarra
Elegir una base de datos es fácil. ¿Todo lo demás? Giro inesperado.

Pool Gana, A Menos Que Tengas Regulaciones

TITLE "The Pool, Bridge, and Silo Blueprint" + subtitle "3 ways to split tenant data, 1 clear winner for most builders". Metaphor: 3 warehouses side by side, 1 giant shared warehouse with labeled shelves, 1 warehouse split into locked inner rooms, and a row of separate small warehouses. Style: cartoon 90s Hanna-Barbera/Nickelodeon, thick black outlines, halftone dots, bouncy rounded shapes. Palette: mustard #F4C430, hot pink #FF3E7F, sky blue #4FC3F7, cream #FFF8E7, black #111111. Content: 3 labeled warehouses, SILO (1 small building per tenant, tiny door, heavy padlock), BRIDGE (1 building, locked inner rooms labeled by tenant name), POOL (1 open warehouse floor, shelves color coded by tenant, 1 shared forklift). Highlight: POOL warehouse glowing with a gold outline and small sparkle stars, forklift labeled TENANT ID. Legend: sticky note bottom left, lock icon means strict isolation, shelf icon means shared and filtered. Footer: © rentierdigital.xyz bottom-right, small, handwritten. NOT flat corporate vector, NOT minimalist tech startup aesthetic.
Comparación Visual de Tres Patrones de Arquitectura Multi-Tenant

Existen 3 formas de estructurar una base de datos compartida, y solo una tiene sentido para la mayoría de SaaS construidos por desarrolladores independientes.

  • Silo: 1 instancia de base de datos por tenant. Aislamiento total, cero riesgo de que el cliente A vea las filas del cliente B, y una curva de costos lineal que se vuelve fea alrededor del cliente número 50.
  • Bridge: 1 base de datos, 1 esquema por tenant. Más barato que Silo, pero ahora cada migración se ejecuta en todos los esquemas que tengas, y Postgres no fue exactamente diseñado para disfrutar alojando cientos de copias de la misma estructura de tablas. Supera unos pocos miles de tenants y el catálogo del sistema mismo empieza a quejarse bajo el peso.
  • Pool: 1 conjunto de tablas, compartido por todos, separado por una columna tenant_id.

Pool es la respuesta aburrida y es la correcta. La más barata de ejecutar, un esquema para migrar, un pool de conexiones para dimensionar. El problema es el mismo problema que tiene toda configuración de tablas compartidas: tu código tiene que recordar filtrar por tenant_id en cada consulta, cada vez, para siempre. Olvida ese filtro una vez, en un trabajo en segundo plano que no probaste cuidadosamente, y el cliente A ve las facturas del cliente B. Karen de Contabilidad se da cuenta antes que tú, y ahora estás escribiendo un reporte de incidente en lugar de lanzar funcionalidades.

La excepción es real, no teórica: salud, finanzas, cualquier cosa donde un auditor eventualmente te pedirá que pruebes el aislamiento a nivel de infraestructura, no solo a nivel de consulta. Si ese es tu contexto, Silo deja de ser paranoia y se convierte en el precio de entrada. Para todos los demás, Pool es la opción.

El Portero Que Postgres Ya Construyó

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

Eso es Row-Level Security (RLS), la respuesta integrada de Postgres desde 2016. 4 líneas, y ahora la base de datos misma se niega a devolver una fila que no coincida con el tenant actual, sin importar lo que tu código de aplicación haga u olvide hacer. Piénsalo como un portero parado en la capa de consultas: no importa qué tan encantador sea tu SELECT, si el tenant_id en tu pulsera no coincide, no vas a pasar la puerta.

La palabra clave FORCE importa más de lo que la gente espera. Sin ella, el propietario de la tabla y cualquier rol superusuario pasan directamente a través de la política, que casualmente es el rol con el que la mayoría de configuraciones de desarrollo local y scripts de administración se ejecutan. Probar como superusuario es básicamente jugar en modo dios: nada de lo que rompas aparece en la ejecución que cuenta. Me di cuenta de esto después de pasar unos buenos 20 minutos preguntándome por qué mi política supuestamente a prueba de balas dejaba que cada tenant viera los datos de todos, todo porque nunca salí del modo dios. Siempre prueba como el rol de aplicación real, no como admin, porque admin te miente exactamente de la forma que no quieres durante una demo.

No relacionado, pero ya que estamos con misterios de base de datos que no he resuelto: mi base de datos de staging se queda sin conexiones todos los domingos, como un reloj, y nunca he estado desplegando nada ese día. C'est la vie.

Un footgun más, y este vive a nivel del pooler de conexiones. Si estás ejecutando PgBouncer o algo similar en modo transacción, establecer el contexto del tenant tiene que pasar dentro de la transacción, con scope usando SET LOCAL, no un SET simple.

BEGIN;
SET LOCAL app.tenant_id = '3f29e1d2-91aa-4b3a-9d21-7e0dcb9a1234';
SELECT * FROM invoices;
COMMIT;

Un SET simple se pega a la conexión, y las conexiones son recicladas por el pooler y entregadas a la siguiente solicitud. Haz eso y la solicitud del tenant B puede heredar la variable de sesión del tenant A, la política RLS pasa limpia, y has construido exactamente la filtración que RLS se suponía que previniera. SET LOCAL muere en COMMIT o ROLLBACK, sin importar quién tome la conexión después.

RLS es una capa, no toda la defensa. Los fundamentos de DevOps que un agente de IA se saltó antes de borrar producción importan tanto como cualquier política dentro de Postgres: credenciales con scope, entornos de staging reales, backups que vivan en algún lugar que no sea la misma máquina, ya que RLS mantiene a los tenants separados pero nunca fue construido para respaldar nada.

1 Índice, 25 Veces Más Rápido

Sin un índice, cada consulta que toca una tabla bajo RLS fuerza a Postgres a verificar la política fila por fila, y si no hay nada que lo ayude a reducir eso, recurre a un escaneo secuencial. Eso es el equivalente en base de datos de pasar por cada encuentro aleatorio en lugar de tomar el punto de teletransporte directo al jefe. En una tabla con unos pocos miles de filas eso es invisible. En una tabla con unos pocos millones, el tiempo de carga de tu dashboard se convierte en un descanso para café.

CREATE INDEX idx_invoices_tenant_id ON invoices (tenant_id, created_at);

Agrega un índice compuesto con tenant_id como la columna principal y Postgres cambia de escanear toda la tabla a un bitmap index scan, yendo directo a las filas que importan. Los benchmarks en tablas de millones de filas ponen la diferencia en 25 veces más rápido, a veces más, y la latencia p95 overhead del RLS mismo baja a menos del 2%, lo suficientemente cerca de gratis que deja de ser un punto de conversación.

La verificación de política misma es barata: una sola comparación de igualdad que Postgres ya sabe cómo planificar. El costo real se esconde en otro lugar, en una decisión del planificador de consultas que solo notas una vez que la buscas. Así es como los equipos terminan lanzando RLS, viendo una consulta lenta aparecer en producción 3 semanas después, y pasando una tarde convencidos de que el modelo de seguridad mismo es el problema, cuando la solución real era una declaración CREATE INDEX que estuvo ahí todo el tiempo.

Un detalle más, pequeño pero caro si lo omites: si tu política llama una función personalizada en lugar de una comparación de columna simple, marca esa función como STABLE.

CREATE FUNCTION current_tenant() RETURNS uuid
  LANGUAGE sql STABLE
AS $$ SELECT current_setting('app.tenant_id')::uuid $$;

Déjala VOLATILE (el default) y Postgres la re-evalúa para cada fila individual en lugar de una vez por consulta, lo que silenciosamente derrota todo el punto de agregar el índice en primer lugar.

Creo que esto cubre la configuración RLS que funciona para la gran mayoría de SaaS modelo Pool por ahí, aunque admito que no estoy completamente seguro de que se mantenga una vez que estés ejecutando docenas de políticas apiladas por tabla. Esa es una escala de complejidad que personalmente no he alcanzado aún.

Prisma, Drizzle, o Saltarse SQL Completamente

TITLE "The Prisma vs Drizzle Race" + subtitle "bundle size and cold start, side by side". Metaphor: 2 racers on a track, 1 hauling a heavy trailer, 1 on a light bike. Style: retro arcade 8-bit pixel art. Palette: mustard #F4C430, hot pink #FF3E7F, sky blue #4FC3F7, cream #FFF8E7, black #111111. Content: left racer labeled PRISMA pulling a trailer marked with a weight icon, right racer labeled DRIZZLE riding a light bike with a lightning bolt sticker, finish line labeled EDGE RUNTIME. Highlight: DRIZZLE racer glowing with speed lines and small lightning bolt sparkles. Legend: sticky note bottom left, trailer icon means compiled query engine, lightning icon means zero dependency runtime. Footer: © rentierdigital.xyz bottom-right, small, handwritten. NOT flat corporate vector, NOT stock infographic style.
Infografía de Carrera Comparando Rendimiento Prisma vs Drizzle

Prisma y Drizzle son ambos ORMs, la capa entre tu TypeScript y el SQL subyacente, y Prisma se lee como inglés simple mientras el autocompletado te hace sentir peligroso 🤓. La versión 7 finalmente se deshizo del viejo binario del motor de consultas Rust por un compilador WebAssembly, bajando el bundle de 14MB a 1.6MB y arreglando el problema de cold start que lo hacía doloroso en serverless durante años.

Drizzle se salta la abstracción casi completamente. Los esquemas son TypeScript simple, las consultas se parecen al SQL que generan, y todo pesa entre 12KB y 57KB dependiendo de lo que importes. El cold start llega a 50ms. Si estás desplegando al Edge (Cloudflare Workers, ese tipo de runtime), Drizzle es la elección. Necesitarás saber SQL realmente para usarlo bien, lo cual es una característica o un filtro dependiendo de dónde estés empezando. Los joins generados de Prisma ocasionalmente se leen como una respuesta de Stack Overflow, copiada rápido y nunca releída.

Drizzle también tiene una respuesta real para el problema de cableado RLS: te permite declarar roles y políticas directamente en el esquema TypeScript, y AsyncLocalStorage de Node lleva el tenant_id desde tu middleware hasta la función de consulta, sin pasarlo a través de cada firma de función a mano. Las extensiones de Prisma pueden fingir el mismo truco con wrappers de cliente, pero está atornillado. Drizzle fue construido con eso en mente.

Elige Prisma para la experiencia de desarrollo local más suave si no estás desplegando al Edge, elige Drizzle si ya te sientes cómodo en SQL y el cold start genuinamente importa para tu stack.

Supabase Te Entrega RLS y la Factura de Ops

Supabase es Postgres por debajo, conectado a su propia auth, storage, y una capa API auto-generada (PostgREST) que permite que tu frontend hable con la base de datos casi directamente. Esa última parte es por qué RLS deja de ser opcional en Supabase: tu app cliente puede tocar la base de datos sin un backend sentado en el medio, así que la política es lo único que está entre el cliente A y las filas del cliente B. Supabase funciona exactamente con la configuración RLS de antes en este artículo, solo pre-cableada en su dashboard en lugar de un archivo de migración que escribes a mano.

Auto-hospedar Supabase es real y gratis, publican el stack completo de docker-compose. También son 12 contenedores: Postgres, GoTrue para auth, PostgREST, Realtime, Storage, Kong como el gateway API, Studio para el dashboard, y algunos más dependiendo de lo que habilites. Ejecutar el stack completo tú mismo significa convertirte en el sysadmin de una docena de servicios que solían ser problema de alguien más, lo cual es su propio jefe final. La RAM sube rápido, y cada uno de esos 12 contenedores quiere sus propias actualizaciones, su propio TLS, sus propios ojos cuando algo se cae.

Para una construcción individual, el tier gratuito hospedado elimina toda esa matemática. Auto-hospedar solo empieza a valer la pena una vez que hay una razón real detrás: reglas de residencia de datos, o una factura hospedada que ha crecido más allá de lo que la carga de ops te cuesta en tiempo. (Supabase es exactamente el stack que recorro paso a paso en Vibe Coding, For Real, si quieres la versión guiada de ir desde esa primera demo de Next.js a algo realmente lanzado.)

Convex Se Salta SQL y Se Auto-Hospeda en 1 Contenedor

Mi proyecto paralelo de Convex no tiene schema.sql, no tiene CREATE POLICY, no tiene carpeta de migraciones. Las consultas son funciones TypeScript, multi-tenancy es un filtro que escribes dentro de esa función, y las actualizaciones en tiempo real se envían por defecto en lugar de ser una característica que atornillas. El intercambio también es real: sin acceso SQL crudo significa no poder entrar a psql cuando algo se ve mal, estás completamente dentro del lenguaje de consultas de Convex o estás atascado.

Convex también envía un backend auto-hospedado de código abierto, y el contraste con el stack de Supabase es toda la propuesta: 1 contenedor ejecutando el motor de sincronización y la base de datos, no 12. Menos que actualizar, menos que monitorear, menos por lo que recibir páginas. El problema es madurez, no complejidad: Convex auto-hospedado es más joven, las herramientas de comunidad alrededor son más delgadas, y estás intercambiando la década de Supabase de "alguien ya tocó este bug antes que tú" por un proyecto con mucho menos recorrido detrás.

Me metí completamente en lanzar un backend SaaS sin tocar SQL para ese proyecto paralelo. Auto-hospeda Supabase y heredas una docena de contenedores para obtener lo que su tier gratuito ya te entrega, y auto-hospeda Convex y heredas 1 contenedor respaldando un proyecto que no ha pasado por tantos fuegos de producción aún. Dos facturas diferentes para el mismo instinto de poseer tu stack.

En realidad, déjame ponerlo diferente: ninguna de estas 4 decisiones es difícil por sí sola. Pool, Bridge, Silo (un desarrollador motivado llega a la respuesta correcta en 10 minutos con un boceto en servilleta). Prisma o Drizzle es una decisión de 10 minutos también. ¿Supabase, Convex, o Postgres crudo? Elige uno y sigue adelante. La parte difícil es el índice compuesto que agregas o no, la función que marcas STABLE o no, el SET LOCAL que pones dentro de la transacción o no. El tipo de detalle del que ninguna de las otras 3 decisiones te advierte.

Tu base de datos nunca esconde tus errores de arquitectura. Solo espera a que aparezca el volumen antes de enviarte la factura.

Agrega el índice antes de que lo haga.

Fuentes

Este post puede contener enlaces de afiliados. Si los clickeas, podría ganar una pequeña comisión (no te cuesta nada, y me ayuda a seguir enviando artículos de calidad todos los días para tu placer de lectura).