Files
Classeo/backend/src/Shared/Infrastructure/Tenant/TenantEntityManagerFactory.php
Mathias STRASSER 1fd256346a feat: Infrastructure multi-tenant avec isolation par sous-domaine
Une application SaaS éducative nécessite une séparation stricte des données
entre établissements scolaires. L'architecture multi-tenant par sous-domaine
(ecole-alpha.classeo.local) permet cette isolation tout en utilisant une
base de code unique.

Le choix d'une résolution basée sur les sous-domaines plutôt que sur des
headers ou tokens facilite le routage au niveau infrastructure (reverse proxy)
et offre une UX plus naturelle où chaque école accède à "son" URL dédiée.
2026-01-31 01:03:35 +01:00

160 lines
4.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Tenant;
use App\Shared\Domain\Clock;
use function count;
use DateTimeImmutable;
use Doctrine\DBAL\DriverManager;
use Doctrine\ORM\Configuration;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
final class TenantEntityManagerFactory
{
private const MAX_MANAGERS = 50;
private const IDLE_TIMEOUT_SECONDS = 300; // 5 minutes
/** @var array<string, array{manager: EntityManagerInterface, lastUsed: DateTimeImmutable}> */
private array $managers = [];
public function __construct(
private readonly TenantRegistry $registry,
private readonly Clock $clock,
private readonly Configuration $ormConfiguration,
) {
}
public function getForTenant(TenantId $tenantId): EntityManagerInterface
{
$key = (string) $tenantId;
// Evict idle connections first
$this->evictIdleConnections();
// Evict LRU if pool is full and we need a new manager
if (!isset($this->managers[$key]) && count($this->managers) >= self::MAX_MANAGERS) {
$this->evictLeastRecentlyUsed();
}
if (!isset($this->managers[$key])) {
$this->managers[$key] = [
'manager' => $this->createManagerForTenant($tenantId),
'lastUsed' => $this->clock->now(),
];
} else {
// Health check before returning cached manager (AC4 requirement)
$manager = $this->managers[$key]['manager'];
if (!$manager->isOpen() || !$manager->getConnection()->isConnected()) {
// Connection is dead, recreate the manager
$this->closeAndRemove($key);
$this->managers[$key] = [
'manager' => $this->createManagerForTenant($tenantId),
'lastUsed' => $this->clock->now(),
];
} else {
$this->managers[$key]['lastUsed'] = $this->clock->now();
}
}
return $this->managers[$key]['manager'];
}
public function getPoolSize(): int
{
return count($this->managers);
}
private function evictLeastRecentlyUsed(): void
{
if (empty($this->managers)) {
return;
}
$oldestKey = null;
$oldestTime = null;
foreach ($this->managers as $key => $data) {
if ($oldestTime === null || $data['lastUsed'] < $oldestTime) {
$oldestKey = $key;
$oldestTime = $data['lastUsed'];
}
}
if ($oldestKey !== null) {
$this->closeAndRemove($oldestKey);
}
}
private function evictIdleConnections(): void
{
$now = $this->clock->now();
$keysToRemove = [];
foreach ($this->managers as $key => $data) {
$idleSeconds = $now->getTimestamp() - $data['lastUsed']->getTimestamp();
if ($idleSeconds > self::IDLE_TIMEOUT_SECONDS) {
$keysToRemove[] = $key;
}
}
foreach ($keysToRemove as $key) {
$this->closeAndRemove($key);
}
}
private function closeAndRemove(string $key): void
{
if (isset($this->managers[$key])) {
$manager = $this->managers[$key]['manager'];
if ($manager->isOpen()) {
$manager->close();
}
unset($this->managers[$key]);
}
}
private function createManagerForTenant(TenantId $tenantId): EntityManagerInterface
{
$config = $this->registry->getConfig($tenantId);
// Parse database URL and create connection parameters
$connectionParams = $this->parseConnectionParams($config->databaseUrl);
// Health check before creation
/** @phpstan-ignore argument.type (Doctrine accepts both URL and explicit params) */
$connection = DriverManager::getConnection($connectionParams);
$this->healthCheck($connection);
return new EntityManager($connection, $this->ormConfiguration);
}
/**
* @return array<string, mixed>
*/
private function parseConnectionParams(string $databaseUrl): array
{
// Handle SQLite in-memory specially
if (str_starts_with($databaseUrl, 'sqlite:///:memory:')) {
return [
'driver' => 'pdo_sqlite',
'memory' => true,
];
}
// For other databases, use URL parameter
return ['url' => $databaseUrl];
}
private function healthCheck(\Doctrine\DBAL\Connection $connection): void
{
// Verify the database is accessible by executing a simple query
// This implicitly connects and validates the connection
$connection->executeQuery('SELECT 1');
}
}