Cuando empezamos a construir Lueira en marzo de 2023, la primera decisión de esquema que tomamos no fue sobre reservas, pagos o calendarios. Fue sobre inquilinos (tenants). Lueira existe para llevar el día a día de escuelas de deporte independientes —escuelas de esquí, centros de kayak y buceo, campamentos de surf— y desde el principio sabíamos que alojaría a decenas de ellas en la misma plataforma, no una escuela por despliegue. Una escuela de esquí en el Pirineo y un centro de buceo en las Islas Canarias acabarían compartiendo la misma base de datos, los mismos servidores de aplicación y el mismo código, sin poder ver jamás ni una sola reserva que no fuera la suya.
Esa restricción suena obvia en cuanto la dices en voz alta, pero cambia casi todas las decisiones tempranas de una manera fácil de subestimar. El multi-tenant es una de esas cosas que es barato incorporar desde el primer día y brutalmente caro añadir después, porque para cuando te das cuenta de que lo necesitas, cada consulta, cada clave de caché y cada tarea en segundo plano ya dan por hecho un mundo de un solo inquilino.
1. Por qué no puedes añadir el aislamiento más tarde
La trampa del multi-tenant es que una aplicación de un solo inquilino "funciona casi bien" incluso después de empezar a añadir inquilinos, hasta que deja de hacerlo en silencio. Si no diseñas el aislamiento desde el principio, acabas añadiendo una columna school_id a tus tablas mucho después de los hechos, y luego persiguiendo cada consulta del código para asegurarte de que filtra por ella. En un código con cientos de consultas, auditar cada una a mano buscando un filtro que falta no es una estrategia, es una esperanza.
Ya contamos en una entrada anterior, cuando lanzamos el motor de reservas de Lueira en junio de 2023, cuánto valor salió de mantener la lógica de reservas modular en lugar de dar por sentadas las particularidades de una sola escuela. El multi-tenant es la continuación natural de esa misma lección: la modularidad que permite que una escuela configure sus propios deportes, precios y horarios es la misma modularidad que permite que la plataforma razone sobre "para qué inquilino es esta petición" como un concepto de primera clase, en lugar de un añadido tardío sobre un montón creciente de lógica de negocio.
2. Hacer pasar la identidad del inquilino por cada petición
Una vez aceptas que el aislamiento tiene que estar diseñado desde dentro, la siguiente decisión es cómo sabe una petición a qué inquilino pertenece. Hay unas cuantas opciones estándar, y cada una tiene un modo de fallo distinto:
- Subdominio (
aransport.lueira.com): intuitivo para el cliente final, y hace que la identificación del inquilino ocurra en la capa de enrutamiento antes de que corra ningún código de la aplicación. La desventaja es que acopla tu infraestructura al DNS y a los certificados wildcard, y resulta incómodo para escuelas que quieren un dominio propio. - Cabecera o segmento de ruta: flexible y agnóstico de infraestructura, pero traslada la responsabilidad de fijar esa cabecera correctamente a cada cliente, incluyendo tu propio frontend y cualquier script interno que alguien escriba con prisa.
- Claim del JWT: el inquilino viaja dentro de la sesión autenticada, lo que significa que ningún cliente puede olvidarlo, pero también significa que un bug de autenticación se convierte en un bug de aislamiento entre inquilinos: te equivocas una vez con el claim y le has entregado a alguien un token que suplanta a otra escuela.
Ninguna de estas opciones es estrictamente "correcta" en abstracto; la elección correcta depende de dónde quieras que viva la superficie de fallo. Lo que importa más que cuál elijas es que elijas exactamente un mecanismo canónico, lo resuelvas una sola vez en el borde de la petición, y pases el identificador de inquilino resultante hacia abajo como un valor explícito, en lugar de algo que el código de consulta tenga que reconstruir.
3. El bug que es especialmente peligroso en sistemas multi-tenant
Hay una clase específica de bug que apenas importa en una app de un solo inquilino y es genuinamente peligroso en una multi-tenant: una consulta que se olvida de filtrar por inquilino. En un sistema de un solo inquilino, olvidar una cláusula WHERE devuelve demasiados de tus propios datos. En un sistema multi-tenant, devuelve —o peor, actualiza— los de otro.
// Peligroso: compila sin problemas, pasa la revisión si nadie está
// prestando atención, y devuelve en silencio las reservas confirmadas
// de todas las escuelas, no solo de una.
bookings, err := client.Booking.Query().
Where(booking.StatusEQ("confirmed")).
All(ctx)
Razonando esto de forma general, la solución no es una sola salvaguarda, es superponer capas:
// Mejor: el scope de inquilino está incorporado en cómo construyes
// la consulta, no algo que tengas que recordar añadir encima.
bookings, err := tenantClient.Booking.Query().
Where(booking.StatusEQ("confirmed")).
All(ctx) // tenantClient ya lleva incorporado el scope de la escuela
-- Y una barrera a nivel de base de datos, para que incluso una consulta
-- que se salte por completo la capa de aplicación no pueda cruzar
-- los límites de inquilino.
CREATE POLICY tenant_isolation ON bookings
USING (school_id = current_setting('app.current_tenant')::uuid);
Un scope por defecto a nivel de ORM atrapa el error antes de que se despliegue. Una restricción de base de datos o una política de row-level security lo atrapa si la primera capa se salta alguna vez. Ninguna de las dos por sí sola basta; quieres las dos, porque las dos capas fallan de forma independiente.
4. Infraestructura compartida sin bifurcar el código
La otra mitad del multi-tenant es el problema contrario: dar a cada escuela suficiente margen para ser diferente sin darle un código distinto. Cada escuela en Lueira tiene su propia marca, su propia combinación de deportes, sus propios precios y sus propios horarios, pero todas corren sobre el mismo despliegue y la misma base de datos. En el momento en que bifurcas el código "solo esta vez" para un cliente con requisitos poco habituales, has creado una rama de mantenimiento que hay que mantener sincronizada a mano para siempre.
La salida es tratar el comportamiento específico de cada inquilino como configuración y datos, no como código: los deportes disponibles de una escuela, sus reglas de precios y su horario viven en tablas que la plataforma lee e interpreta, no en condicionales repartidos por la aplicación.
Nada de esto quedó completamente resuelto el primer día; parte lo acertamos por instinto y parte tuvimos que corregirlo cuando escuelas reales empezaron a usar Lueira una junto a otra. Pero la decisión de fondo —que el aislamiento y la identidad tenían que ser decisiones arquitectónicas y no detalles de implementación— es la que más contento estoy de no haber pospuesto.
