Quan vam començar a construir Lueira el març de 2023, la primera decisió d'esquema que vam prendre no va ser sobre reserves, pagaments o calendaris. Va ser sobre inquilins (tenants). Lueira existeix per portar el dia a dia d'escoles d'esport independents —escoles d'esquí, centres de caiac i busseig, campaments de surf— i des del principi sabíem que n'allotjaria desenes a la mateixa plataforma, no una escola per desplegament. Una escola d'esquí al Pirineu i un centre de busseig a les Illes Canàries acabarien compartint la mateixa base de dades, els mateixos servidors d'aplicació i el mateix codi, sense poder veure mai ni una sola reserva que no fos seva.
Aquesta restricció sona òbvia quan la dius en veu alta, però canvia gairebé totes les decisions primerenques d'una manera fàcil de subestimar. El multi-tenant és una d'aquelles coses que és barat incorporar des del primer dia i brutalment car afegir després, perquè quan t'adones que el necessites, cada consulta, cada clau de memòria cau i cada tasca en segon pla ja donen per fet un món d'un sol inquilí.
1. Per què no pots afegir l'aïllament més tard
El parany del multi-tenant és que una aplicació d'un sol inquilí "funciona gairebé bé" fins i tot després de començar a afegir inquilins, fins que deixa de fer-ho en silenci. Si no dissenyes l'aïllament des del principi, acabes afegint una columna school_id a les teves taules molt després dels fets, i després perseguint cada consulta del codi per assegurar-te que hi filtra. En un codi amb centenars de consultes, auditar-les totes a mà buscant un filtre que falta no és una estratègia, és una esperança.
Ja vam explicar en una entrada anterior, quan vam llançar el motor de reserves de Lueira el juny de 2023, quant de valor va sortir de mantenir la lògica de reserves modular en lloc de donar per fetes les particularitats d'una sola escola. El multi-tenant és la continuació natural d'aquesta mateixa lliçó: la modularitat que permet que una escola configuri els seus propis esports, preus i horaris és la mateixa modularitat que permet que la plataforma raoni sobre "per a quin inquilí és aquesta petició" com un concepte de primera classe, en lloc d'un afegit tardà sobre un munt creixent de lògica de negoci.
2. Fer passar la identitat de l'inquilí per cada petició
Un cop acceptes que l'aïllament ha d'estar dissenyat des de dins, la següent decisió és com sap una petició a quin inquilí pertany. Hi ha unes quantes opcions estàndard, i cadascuna té un mode de fallada diferent:
- Subdomini (
aransport.lueira.com): intuïtiu per al client final, i fa que la identificació de l'inquilí passi a la capa d'encaminament abans que corri cap codi de l'aplicació. El desavantatge és que acobla la teva infraestructura al DNS i als certificats wildcard, i resulta incòmode per a escoles que volen un domini propi. - Capçalera o segment de ruta: flexible i agnòstic d'infraestructura, però trasllada la responsabilitat de fixar aquesta capçalera correctament a cada client, incloent-hi el teu propi frontend i qualsevol script intern que algú escrigui amb pressa.
- Claim del JWT: l'inquilí viatja dins la sessió autenticada, cosa que significa que cap client no el pot oblidar, però també significa que un bug d'autenticació esdevé un bug d'aïllament entre inquilins: t'equivoques un cop amb el claim i has donat a algú un token que suplanta una altra escola.
Cap d'aquestes opcions no és estrictament "correcta" en abstracte; la tria correcta depèn d'on vulguis que visqui la superfície de fallada. El que importa més que quina triïs és que triïs exactament un mecanisme canònic, el resolguis una sola vegada a la vora de la petició, i passis l'identificador d'inquilí resultant cap avall com un valor explícit, en lloc d'alguna cosa que el codi de consulta hagi de reconstruir.
3. El bug que és especialment perillós en sistemes multi-tenant
Hi ha una classe específica de bug que amb prou feines importa en una app d'un sol inquilí i és genuïnament perillós en una multi-tenant: una consulta que s'oblida de filtrar per inquilí. En un sistema d'un sol inquilí, oblidar una clàusula WHERE retorna massa de les teves pròpies dades. En un sistema multi-tenant, retorna —o pitjor, actualitza— les d'algú altre.
// Perillós: compila sense problemes, passa la revisió si ningú no hi
// para atenció, i retorna en silenci les reserves confirmades de
// totes les escoles, no només d'una.
bookings, err := client.Booking.Query().
Where(booking.StatusEQ("confirmed")).
All(ctx)
Raonant això de manera general, la solució no és una sola salvaguarda, és superposar capes:
// Millor: l'abast d'inquilí està incorporat en com construeixes la
// consulta, no alguna cosa que hagis de recordar afegir a sobre.
bookings, err := tenantClient.Booking.Query().
Where(booking.StatusEQ("confirmed")).
All(ctx) // tenantClient ja porta incorporat l'abast de l'escola
-- I una barrera a nivell de base de dades, perquè fins i tot una
-- consulta que es salti completament la capa d'aplicació no pugui
-- travessar els límits d'inquilí.
CREATE POLICY tenant_isolation ON bookings
USING (school_id = current_setting('app.current_tenant')::uuid);
Un àmbit per defecte a nivell d'ORM atrapa l'error abans que es desplegui. Una restricció de base de dades o una política de row-level security l'atrapa si la primera capa se salta alguna vegada. Cap de les dues per si sola no n'hi ha prou; vols totes dues, perquè les dues capes fallen de manera independent.
4. Infraestructura compartida sense bifurcar el codi
L'altra meitat del multi-tenant és el problema contrari: donar a cada escola prou marge per ser diferent sense donar-li un codi diferent. Cada escola a Lueira té la seva pròpia marca, la seva pròpia combinació d'esports, els seus propis preus i els seus propis horaris, però totes corren sobre el mateix desplegament i la mateixa base de dades. En el moment en què bifurques el codi "només aquest cop" per a un client amb requisits poc habituals, has creat una branca de manteniment que cal mantenir sincronitzada a mà per sempre.
La sortida és tractar el comportament específic de cada inquilí com a configuració i dades, no com a codi: els esports disponibles d'una escola, les seves regles de preus i el seu horari viuen en taules que la plataforma llegeix i interpreta, no en condicionals escampats per l'aplicació.
Res d'això no va quedar del tot resolt el primer dia; part ho vam encertar per instint i part vam haver de corregir-ho quan escoles reals van començar a fer servir Lueira una al costat de l'altra. Però la decisió de fons —que l'aïllament i la identitat havien de ser decisions arquitectòniques i no detalls d'implementació— és la que estic més content de no haver ajornat.
