Appearance
Multi-Tenant: Schema-Based Tenancy
QUÉ es Multi-Tenant Schema-Based
Definición: Arquitectura donde cada "tenant" (sucursal o caja) tiene su propio schema PostgreSQL dentro de la misma base de datos física. Un schema es un namespace que contiene tablas, vistas, funciones, etc.
Principio fundamental: PostgreSQL busca objetos (tablas) en el schema definido por search_path. Cambiando el search_path, aislamos completamente los datos sin modificar las queries.
Analogía: Imagina una empresa con 10 sucursales. En lugar de tener 10 bases de datos separadas, tenemos 1 base de datos con 10 "carpetas" (schemas). Cada sucursal solo ve su carpeta.
bautista (database)
├── suc0001 (schema) ← Sucursal Casa Central
│ ├── facturas
│ ├── clientes
│ └── productos
├── suc0002 (schema) ← Sucursal Filial Norte
│ ├── facturas
│ ├── clientes
│ └── productos
└── suc0001caja001 (schema) ← Caja 001 de Casa Central
├── recibos
├── movimientos_caja
└── cierre_cajaCUÁNDO usar: SIEMPRE. Multi-tenant es la arquitectura base de Sistema Bautista. Cada request HTTP trabaja en un schema específico determinado por X-Schema header o JWT payload.
Niveles de Schema (Jerarquía)
Sistema Bautista implementa 3 niveles de schemas, organizados jerárquicamente:
| Nivel | Constante | Schema | Alcance | Ejemplo |
|---|---|---|---|---|
| Empresa | LEVEL_EMPRESA = 1 | public | Datos maestros compartidos | plan_cuentas, conceptos_retencion |
| Sucursal | LEVEL_SUCURSAL = 2 | suc0001, suc0002, etc. | Datos de sucursal | facturas, clientes, productos |
| Caja | LEVEL_CAJA = 3 | suc0001caja001, etc. | Datos de caja individual | recibos, movimientos_caja |
Regla clave: Datos de nivel superior (empresa) son compartidos por niveles inferiores (sucursal/caja). Datos de nivel inferior NO son visibles para otros tenants del mismo nivel.
Diagrama de Jerarquía de Schemas
mermaid
graph TB
E[public - EMPRESA<br/>Level 1]
S1[suc0001 - SUCURSAL<br/>Level 2]
S2[suc0002 - SUCURSAL<br/>Level 2]
C1[suc0001caja001 - CAJA<br/>Level 3]
C2[suc0001caja002 - CAJA<br/>Level 3]
C3[suc0002caja001 - CAJA<br/>Level 3]
E -.->|Comparte maestros| S1
E -.->|Comparte maestros| S2
S1 -.->|Comparte datos sucursal| C1
S1 -.->|Comparte datos sucursal| C2
S2 -.->|Comparte datos sucursal| C3
E -->|Contiene| DM[Datos Maestros:<br/>plan_cuentas<br/>conceptos_retencion<br/>configuracion_global]
S1 -->|Contiene| DS1[Datos Sucursal:<br/>facturas<br/>clientes<br/>productos<br/>stock]
C1 -->|Contiene| DC1[Datos Caja:<br/>recibos<br/>movimientos_caja<br/>cierre_caja]
style E fill:#e1f5ff
style S1 fill:#fff4e1
style S2 fill:#fff4e1
style C1 fill:#f0ffe1
style C2 fill:#f0ffe1
style C3 fill:#f0ffe1Qué datos viven en cada nivel:
- LEVEL_EMPRESA (public): Configuración global, plan de cuentas, conceptos fiscales, usuarios
- LEVEL_SUCURSAL (sucXXXX): Facturas, clientes, proveedores, productos, stock
- LEVEL_CAJA (sucXXXXcajaXXX): Recibos, movimientos de caja, cierres de caja
ConnectionManager y Conexiones Nombradas
ConnectionManager es el componente central que gestiona conexiones a PostgreSQL. En lugar de trabajar con conexiones anónimas, usamos nombres lógicos (aliases):
| Conexión | Propósito | Database | Schema | Configurado por |
|---|---|---|---|---|
| oficial | Datos de producción | bautista | Según X-Schema/JWT | Siempre apunta a DB oficial |
| principal (alias) | Conexión de trabajo por request | Dinámica (oficial o prueba) | Según X-Schema/JWT | Resuelto según prueba parameter |
| prueba | Datos de testing | bautista_p (con sufijo _p) | Según X-Schema/JWT | Siempre apunta a DB prueba |
| ini | Conexión inicial sin schema | bautista | public | Usado en migrations, setup |
Pattern de uso:
php
// En servicios: SIEMPRE usar 'principal' para transacciones
$conn = ConnectionManager::getConnection('principal');
// Para maestros: SIEMPRE usar 'oficial' (incluso en modo prueba)
$connOficial = ConnectionManager::getConnection('oficial');
$planCuentas = $connOficial->fetchAllAssociative("SELECT * FROM plan_cuentas");
// Para testing directo: usar 'prueba' explícitamente
$connPrueba = ConnectionManager::getConnection('prueba');Beneficios:
- ✅ Transparencia: Servicios no saben si están en modo oficial o prueba
- ✅ Maestros consistentes: Siempre de 'oficial', sin duplicación
- ✅ Testing seguro: 'prueba' nunca afecta producción
- ✅ Transacciones simples: 1 conexión = 1 transacción atómica
X-Schema Header y JWT Payload
El schema se determina en orden de precedencia:
- X-Schema HTTP Header (máxima prioridad)
- JWT Payload (
schemafield) - GLOBALS (fallback en scripts legacy)
Precedencia y Casos de Uso
mermaid
graph TD
REQ[HTTP Request]
REQ --> CHECK1{¿Tiene X-Schema<br/>header?}
CHECK1 -->|Sí| USE_HEADER[Usar X-Schema]
CHECK1 -->|No| CHECK2{¿Tiene JWT con<br/>schema field?}
CHECK2 -->|Sí| USE_JWT[Usar JWT schema]
CHECK2 -->|No| CHECK3{¿GLOBALS tiene<br/>schema?}
CHECK3 -->|Sí| USE_GLOBALS[Usar GLOBALS<br/>Legacy fallback]
CHECK3 -->|No| ERROR[Error: Schema no determinado]
USE_HEADER --> CM[ConnectionManager]
USE_JWT --> CM
USE_GLOBALS --> CM
CM --> SET_PATH[SET search_path]
style USE_HEADER fill:#90EE90
style USE_JWT fill:#FFD700
style USE_GLOBALS fill:#FFA500
style ERROR fill:#FF6B6BFlujo de Autenticación y Schema
mermaid
sequenceDiagram
participant F as Frontend (Schema Selector)
participant AM as AuthMiddleware
participant CM as ConnectionMiddleware
participant ConnMgr as ConnectionManager
participant PG as PostgreSQL
F->>AM: POST /api/ventas/facturas<br/>Authorization: Bearer <JWT><br/>X-Schema: suc0001caja001
AM->>AM: Validar JWT (RSA signature)
AM->>AM: Extraer payload: {schema: "suc0001", ...}
AM->>CM: Request con JWT validado
CM->>CM: Determinar schema:<br/>1. X-Schema header → suc0001caja001<br/>2. JWT schema → suc0001<br/>Precedencia: X-Schema gana
CM->>ConnMgr: setupConnection('principal', schema='suc0001caja001')
ConnMgr->>ConnMgr: Construir search_path jerárquico:<br/>suc0001caja001, suc0001, public
ConnMgr->>PG: SET search_path = suc0001caja001, suc0001, public
PG-->>ConnMgr: Search path configurado
ConnMgr-->>CM: Connection ready
CM-->>F: Request procesado en schema correcto
Note over F,PG: Multi-Tenant activo:<br/>Schema = suc0001caja001<br/>Search path jerárquico establecidoCasos de Uso por Precedencia
| Escenario | X-Schema | JWT | GLOBALS | Schema Final | Uso |
|---|---|---|---|---|---|
| Schema selector en frontend | suc0001caja001 | suc0001 | - | suc0001caja001 | Usuario elige caja manualmente |
| Login normal | - | suc0001 | - | suc0001 | Schema del usuario logueado |
| Admin viendo reportes | suc0002 | suc0001 | - | suc0002 | Admin consulta otra sucursal |
| Testing automatizado | - | - | suc0001 | suc0001 | Scripts de testing legacy |
| Error: sin schema | - | - | - | ❌ Error | Request rechazado |
Search_path Jerárquico
Concepto: PostgreSQL permite definir múltiples schemas en search_path. Busca objetos (tablas) de izquierda a derecha hasta encontrar uno que coincida.
Construcción en Sistema Bautista:
search_path = caja, sucursal, empresa (public)Ejemplo concreto:
sql
-- Schema: suc0001caja001
SET search_path = suc0001caja001, suc0001, public;
-- Query: SELECT * FROM recibos;
-- PostgreSQL busca en este orden:
-- 1. suc0001caja001.recibos ✅ ENCONTRADO → usa esta tabla
-- 2. suc0001.recibos (no se busca, ya encontrado)
-- 3. public.recibos (no se busca, ya encontrado)
-- Query: SELECT * FROM facturas;
-- PostgreSQL busca en este orden:
-- 1. suc0001caja001.facturas ❌ NO EXISTE
-- 2. suc0001.facturas ✅ ENCONTRADO → usa esta tabla
-- 3. public.facturas (no se busca, ya encontrado)
-- Query: SELECT * FROM plan_cuentas;
-- PostgreSQL busca en este orden:
-- 1. suc0001caja001.plan_cuentas ❌ NO EXISTE
-- 2. suc0001.plan_cuentas ❌ NO EXISTE
-- 3. public.plan_cuentas ✅ ENCONTRADO → usa esta tablaBeneficios:
- ✅ Queries agnósticas:
SELECT * FROM facturasfunciona sin especificar schema - ✅ Herencia jerárquica: Caja hereda datos de sucursal, sucursal hereda maestros de empresa
- ✅ Aislamiento automático: Caja 001 nunca ve datos de caja 002
Construcción del search_path:
php
// ConnectionUtils::buildSearchPath()
public static function buildSearchPath(string $schema): string
{
$paths = [$schema]; // 1. Schema específico (ej: suc0001caja001)
// 2. Extraer schema de sucursal si es caja (ej: suc0001caja001 → suc0001)
if (preg_match('/^(suc\d+)caja\d+$/', $schema, $matches)) {
$paths[] = $matches[1]; // Agregar sucursal
}
$paths[] = 'public'; // 3. Siempre incluir public (empresa)
return implode(', ', $paths);
}
// Resultado para suc0001caja001: "suc0001caja001, suc0001, public"
// Resultado para suc0001: "suc0001, public"
// Resultado para public: "public"Configuración de Niveles de Tabla
Cada tabla tiene un nivel lógico configurado en configuracion_niveles_tablas (JSON en public.configuracion):
json
{
"facturas": 2, // LEVEL_SUCURSAL
"clientes": 2, // LEVEL_SUCURSAL
"recibos": 3, // LEVEL_CAJA
"plan_cuentas": 1, // LEVEL_EMPRESA
"movimientos_caja": 3 // LEVEL_CAJA
}Regla de uso:
- Si tabla NO está en configuración: Usar defaults hardcoded en código
- Si tabla SÍ está en configuración: Usar el nivel configurado (permite personalizar)
Ejemplo de lógica:
php
// En Service
private const DEFAULT_LEVELS = [
'facturas' => SchemaConstants::LEVEL_SUCURSAL,
'clientes' => SchemaConstants::LEVEL_SUCURSAL,
'recibos' => SchemaConstants::LEVEL_CAJA,
];
public function getTableLevel(string $table): int
{
// 1. Intentar obtener de configuración JSON
$config = $this->configService->get('configuracion_niveles_tablas');
if (isset($config[$table])) {
return (int) $config[$table];
}
// 2. Fallback a defaults
return self::DEFAULT_LEVELS[$table] ?? SchemaConstants::LEVEL_SUCURSAL;
}Beneficios:
- ✅ Flexibilidad: Cambiar nivel de tabla sin modificar código
- ✅ Defaults sensatos: Código funciona sin configuración explícita
- ✅ Migraciones simples: Mover tabla de nivel es cambio de configuración
Reglas Arquitecturales
RA-MT-001: Aislamiento por Schema
Descripción: Cada tenant (sucursal/caja) DEBE tener su propio schema PostgreSQL. Los datos transaccionales de un tenant NO DEBEN ser accesibles desde otro tenant sin multi-schema querying explícito.
Implicación:
- ✅ Factura de
suc0001NO visible desdesuc0002 - ✅ Recibo de
caja001NO visible desdecaja002 - ❌ NO usar WHERE conditions para filtrar por tenant (ej:
WHERE sucursal_id = 1)
RA-MT-002: Validación de Schema
Descripción: El schema DEBE ser validado y existir en information_schema.schemata antes de establecer search_path.
Implicación:
- ✅ ConnectionMiddleware valida schema antes de setup
- ✅ SchemaService::schemaExists() consulta PostgreSQL
- ❌ NO permitir schemas inventados o inyección SQL
Código de validación:
php
// SchemaService::schemaExists()
public function schemaExists(string $schemaName): bool
{
$sql = "SELECT schema_name
FROM information_schema.schemata
WHERE schema_name = :schema";
$result = $this->connection->fetchOne($sql, ['schema' => $schemaName]);
return $result !== false;
}RA-MT-003: Datos Maestros Compartidos
Descripción: Los datos maestros (LEVEL_EMPRESA) DEBEN residir en public schema y ser compartidos entre todos los tenants. NO duplicar maestros por schema.
Implicación:
- ✅
plan_cuentasenpublic, accesible desde todas las sucursales - ✅
conceptos_retencionenpublic, compartido - ❌ NO crear
suc0001.plan_cuentas(duplicación innecesaria)
Tablas maestras típicas:
plan_cuentasconceptos_retenciontipos_comprobantemonedascondiciones_iva
RA-MT-004: Cambio de Schema Durante Request
Descripción: NO se permite cambiar de schema durante el procesamiento de un request HTTP. El schema se establece en ConnectionMiddleware y permanece fijo hasta finalizar el request.
Implicación:
- ✅ 1 request = 1 schema (aislamiento transaccional)
- ❌ NO llamar
SET search_pathmanualmente en servicios - ⚠️ Excepción: Multi-schema querying usa múltiples conexiones (1 por schema)
Referencias a Código Clave
Componentes Principales
server/connection/ConnectionManager.php- Gestión de conexiones nombradas, aliases, transaccionesserver/connection/Database.php- Wrapper Doctrine DBAL, ejecución deSET search_pathserver/connection/ConnectionUtils.php-buildSearchPath(),setSchemaContext()server/Middleware/AuthMiddleware.php- Validación JWT, extracción deschemafieldserver/Middleware/ConnectionMiddleware.php- Setup de conexión por request, precedencia X-Schemaserver/service/Config/SchemaService.php- Consultainformation_schema, validación de schemas
Migraciones y Configuración
server/migrations/CLAUDE.md- Sistema de migraciones multi-tenant con Phinxdocs/backend/configuration-system.md- Sistema de configuración con niveles de tabla
Ejemplos de Uso
Ejemplo 1: Servicio con Multi-Tenant Básico
php
namespace App\service\Ventas;
use App\connection\ConnectionManager;
class FacturaService
{
public function createFactura(array $data): array
{
// ConnectionMiddleware ya configuró el schema según X-Schema/JWT
// NO necesitamos hacer nada, simplemente usamos 'principal'
$conn = ConnectionManager::getConnection('principal');
// Esta query se ejecuta en el schema correcto automáticamente
// Ej: Si X-Schema = suc0001, se inserta en suc0001.facturas
$sql = "INSERT INTO facturas (fecha, cliente_id, total)
VALUES (:fecha, :cliente_id, :total)";
$conn->executeStatement($sql, [
'fecha' => $data['fecha'],
'cliente_id' => $data['cliente_id'],
'total' => $data['total'],
]);
return ['id' => $conn->lastInsertId()];
}
public function getPlanCuentas(): array
{
// Maestros SIEMPRE de oficial (incluso en modo prueba)
$connOficial = ConnectionManager::getConnection('oficial');
// Esta query busca en public.plan_cuentas
return $connOficial->fetchAllAssociative("SELECT * FROM plan_cuentas");
}
}Ejemplo 2: Frontend con Schema Selector
typescript
// ts/config/SucursalSelector.tsx
import { useState } from 'react';
import { api } from '../api/api';
export function SucursalSelector() {
const [schema, setSchema] = useState('suc0001');
const handleSchemaChange = (newSchema: string) => {
setSchema(newSchema);
// Axios interceptor inyectará automáticamente X-Schema header
// Ver ts/api/api.ts
};
const handleCreateFactura = async (data: FacturaDTO) => {
// Esta llamada irá con X-Schema: suc0001 (o el schema seleccionado)
const response = await api.post('/ventas/facturas', data);
// Backend procesará en el schema correcto automáticamente
return response.data;
};
return (
<select value={schema} onChange={e => handleSchemaChange(e.target.value)}>
<option value="suc0001">Casa Central</option>
<option value="suc0002">Filial Norte</option>
<option value="suc0001caja001">Caja 001 - Casa Central</option>
</select>
);
}typescript
// ts/api/api.ts - Axios interceptor
import axios from 'axios';
import { getSelectedSchema } from '../config/schemaManager';
export const api = axios.create({
baseURL: '/api',
});
// Interceptor que inyecta X-Schema en TODOS los requests
api.interceptors.request.use(config => {
const schema = getSelectedSchema(); // Obtener del state global
if (schema) {
config.headers['X-Schema'] = schema;
}
return config;
});Relación con Multi-Schema y Multi-Modo
Multi-Tenant vs Multi-Schema
| Aspecto | Multi-Tenant | Multi-Schema |
|---|---|---|
| Propósito | Aislamiento de datos | Búsqueda cross-schema |
| Schemas por operación | 1 schema | N schemas simultáneos |
| Cuándo | SIEMPRE (base) | Solo cuando necesitamos buscar en múltiples schemas |
| Relación | Establece el schema inicial | Expande búsqueda a otros schemas |
Combinación: Multi-tenant establece el schema base (suc0001caja001). Si activamos multi-schema, buscamos en ese schema + otros schemas de la misma sucursal.
Multi-Tenant vs Multi-Modo
| Aspecto | Multi-Tenant | Multi-Modo |
|---|---|---|
| Propósito | Aislamiento por tenant | Separación oficial/prueba |
| Database | 1 database (bautista) | 2 databases (bautista + bautista_p) |
| Cuándo | SIEMPRE | Solo en modo prueba o consolidación |
| Relación | Trabaja en nivel de schema | Trabaja en nivel de database |
Combinación: Multi-tenant determina el schema. Multi-modo determina la database (bautista o bautista_p). Ambos son ortogonales.
Ejemplo: Request con prueba=true y X-Schema=suc0001caja001:
- Multi-modo → Database:
bautista_p - Multi-tenant → Schema:
suc0001caja001 - Resultado:
bautista_p.suc0001caja001.recibos
Diagrama Completo de Multi-Tenant
mermaid
graph TB
subgraph "Frontend"
SS[Schema Selector<br/>suc0001caja001]
JWT[JWT Token<br/>schema: suc0001]
end
subgraph "Middleware"
AM[AuthMiddleware<br/>Valida JWT]
CM[ConnectionMiddleware<br/>Setup conexión]
end
subgraph "ConnectionManager"
ALIAS[Resolver Alias<br/>principal → oficial o prueba]
SETUP[Setup Connection<br/>Determinar schema<br/>X-Schema > JWT > GLOBALS]
end
subgraph "PostgreSQL"
PG_OFICIAL[bautista<br/>oficial]
PG_PRUEBA[bautista_p<br/>prueba]
subgraph "Schemas (oficial)"
S1[suc0001<br/>facturas, clientes]
C1[suc0001caja001<br/>recibos, movimientos]
PUB[public<br/>plan_cuentas, maestros]
end
end
SS -->|X-Schema: suc0001caja001| AM
JWT -->|schema: suc0001| AM
AM -->|Request validado| CM
CM -->|getConnection('principal')| ALIAS
CM -->|Determinar schema| SETUP
ALIAS -->|prueba=false| PG_OFICIAL
ALIAS -->|prueba=true| PG_PRUEBA
SETUP -->|SET search_path| S_PATH[suc0001caja001, suc0001, public]
S_PATH -.->|Busca en orden| C1
S_PATH -.->|Si no encuentra| S1
S_PATH -.->|Si no encuentra| PUB
style SS fill:#90EE90
style JWT fill:#FFD700
style AM fill:#87CEEB
style CM fill:#87CEEB
style S_PATH fill:#FF69B4Siguiente paso: Leer Multi-Schema (Cross-Schema Querying) para entender cómo buscar en múltiples schemas simultáneamente.
Referencias: