Domain-Driven Design con Multi-Tenancy in Laravel

Progettare un SaaS multi-tenant è una di quelle sfide che mettono alla prova la disciplina architetturale: devi isolare i dati e i processi di ciascun cliente (tenant), ma senza trasformare l’applicazione in un groviglio di if e regole sparse. Domain-Driven Design (DDD) offre una bussola: separare chiaramente dominio, applicazione e infrastruttura, e modellare il software attorno ai bounded context. In questo articolo vediamo come combinare DDD e multi-tenancy in Laravel, con esempi concreti e una struttura di progetto sostenibile.

Obiettivi e principi

  • Isolamento: ogni tenant deve vedere e modificare solo i propri dati.
  • Coerenza del dominio: le regole di business non devono dipendere dal tipo di tenancy.
  • Testabilità: dominio e casi d’uso devono essere testabili senza database e senza HTTP.
  • Evolvibilità: aggiungere un bounded context o cambiare strategia di tenancy non deve riscrivere tutto.

Panoramica DDD in Laravel

Laravel è un framework pragmatico e “MVC-friendly”, ma non impedisce un’architettura DDD. L’idea è usare Laravel come delivery mechanism (HTTP, console, queue) e come infrastruttura, mentre il dominio resta indipendente e centrato su:

  • Entità e Value Object per il modello.
  • Aggregati con invarianti forti e confini di consistenza.
  • Repository (interfacce nel dominio, implementazioni nell’infrastruttura).
  • Domain Service solo quando una regola non appartiene naturalmente a un’entità/VO.
  • Application Service / Use Case per orchestrare il flusso: input, transazione, output.

Una struttura di cartelle consigliata

Una convenzione semplice è organizzare per bounded context, ciascuno con i propri layer.

app/
  Shared/
    Domain/
      ValueObject/
      Contracts/
  Tenancy/
    Domain/
    Application/
    Infrastructure/
  Billing/
    Domain/
      Model/
      Repository/
      Service/
    Application/
      UseCases/
      DTO/
    Infrastructure/
      Eloquent/
      Providers/
  Http/
    Controllers/
    Middleware/

Il punto chiave: il dominio non dipende da Laravel, Eloquent o HTTP. Laravel “entra” nel layer Infrastructure (ad es. implementazioni dei repository) e nel layer delivery (Controller, Middleware).

Multi-Tenancy: strategie e implicazioni sul dominio

Prima di modellare, devi scegliere (o almeno comprendere) la strategia di multi-tenancy. Le più comuni sono:

1) Database per tenant

  • Pro: isolamento forte, backup/migrazioni per tenant, performance prevedibile.
  • Contro: più complessità operativa (connessioni, migrazioni multiple), costi.

2) Schema per tenant

  • Pro: buon isolamento in DB che supportano schemi (es. PostgreSQL), meno DB da gestire.
  • Contro: supporto variabile, migrazioni e tooling più complessi.

3) Database condiviso con tenant_id

  • Pro: semplice operativamente, ottimo per SaaS con tanti tenant piccoli.
  • Contro: isolamento “logico” (richiede disciplina), rischio di query senza filtro.

DDD ti aiuta a mantenere il dominio stabile: molte decisioni di tenancy dovrebbero vivere in infrastruttura e nei meccanismi di data access. Il dominio, però, deve conoscere il concetto di Tenant quando fa parte del linguaggio ubiquo (ad esempio per regole di licenza, limiti, piani, o vincoli per tenant).

Modellare il Tenant: Value Object e Contesto

Il primo passo è rappresentare il tenant in modo esplicito e sicuro. Evita di passare stringhe “a caso”.

<?php
// app/Tenancy/Domain/ValueObject/TenantId.php

namespace App\Tenancy\Domain\ValueObject;

final class TenantId
{
    private string $value;

    private function __construct(string $value)
    {
        $value = trim($value);
        if ($value === '') {
            throw new \InvalidArgumentException('TenantId non valido.');
        }
        $this->value = $value;
    }

    public static function fromString(string $value): self
    {
        return new self($value);
    }

    public function equals(self $other): bool
    {
        return $this->value === $other->value;
    }

    public function toString(): string
    {
        return $this->value;
    }
}

Poi serve un modo per ottenere il tenant “corrente” durante una richiesta HTTP o un job in coda. Nel dominio conviene esprimere questa dipendenza come contratto (interfaccia) e fornirne un’implementazione nell’infrastruttura.

<?php
// app/Tenancy/Domain/Contracts/TenancyContext.php

namespace App\Tenancy\Domain\Contracts;

use App\Tenancy\Domain\ValueObject\TenantId;

interface TenancyContext
{
    public function tenantId(): TenantId;

    public function isInitialized(): bool;
}

Nota: l’interfaccia sta nel dominio o nello shared kernel, ma l’implementazione userà i meccanismi Laravel (container, middleware, request).

Risoluzione del tenant: subdomain, header o path

Una prassi comune per SaaS è il subdomain: acme.tuosaas.com. In alternativa, puoi usare un header (X-Tenant) o un path (/t/{tenant}/...). Qualunque scelta tu faccia, assicurati che:

  • il tenant venga risolto una sola volta all’inizio della request;
  • la risoluzione sia validata (tenant esistente, attivo, piano valido);
  • il contesto venga propagato a tutto lo stack (DB, cache, queue, logging).

Middleware di risoluzione tenant (subdomain)

<?php
// app/Http/Middleware/InitializeTenancyFromSubdomain.php

namespace App\Http\Middleware;

use App\Tenancy\Infrastructure\Tenancy\TenantResolver;
use Closure;
use Illuminate\Http\Request;

final class InitializeTenancyFromSubdomain
{
    public function __construct(private TenantResolver $resolver) {}

    public function handle(Request $request, Closure $next)
    {
        $host = $request->getHost(); // es. acme.tuosaas.com
        $subdomain = explode('.', $host)[0] ?? null;

        $this->resolver->initializeFromIdentifier($subdomain);

        return $next($request);
    }
}

Qui TenantResolver è infrastruttura: carica il tenant dal repository, valida lo stato e inizializza il contesto (ad esempio settando la connessione DB).

Tenancy e database: connessioni dinamiche senza contaminare il dominio

Se usi database-per-tenant, tipicamente cambi connessione per request. Se usi tenant_id, invece, devi applicare automaticamente il filtro in ogni query. In entrambi i casi l’obiettivo è lo stesso: impedire query “fuori tenant” per errore.

Esempio: database-per-tenant con connessione runtime

<?php
// app/Tenancy/Infrastructure/Tenancy/DatabaseTenancy.php

namespace App\Tenancy\Infrastructure\Tenancy;

use App\Tenancy\Domain\ValueObject\TenantId;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;

final class DatabaseTenancy
{
    public function switchToTenantDatabase(TenantId $tenantId, string $databaseName): void
    {
        // Copia una connessione base e sostituisci il database
        $base = Config::get('database.connections.tenant');
        $base['database'] = $databaseName;

        Config::set('database.connections.tenant_runtime', $base);

        // Forza Laravel a riconnettersi
        DB::purge('tenant_runtime');
        DB::reconnect('tenant_runtime');
    }
}

In questo scenario, i repository Eloquent del bounded context useranno la connessione tenant_runtime. Il dominio non sa nulla di connessioni: riceve semplicemente repository che, per contratto, operano nel tenant corrente.

Esempio: database condiviso con Global Scope

Se scegli la colonna tenant_id, una protezione comune è un global scope su tutti i model tenant-aware. Attenzione: un global scope è comodo, ma va applicato con coerenza e testato.

<?php
// app/Tenancy/Infrastructure/Eloquent/Scopes/TenantScope.php

namespace App\Tenancy\Infrastructure\Eloquent\Scopes;

use App\Tenancy\Domain\Contracts\TenancyContext;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

final class TenantScope implements Scope
{
    public function __construct(private TenancyContext $context) {}

    public function apply(Builder $builder, Model $model): void
    {
        if (!$this->context->isInitialized()) {
            return;
        }

        $builder->where($model->getTable() . '.tenant_id', $this->context->tenantId()->toString());
    }
}

Per usarlo, puoi definire un trait BelongsToTenant e applicare lo scope in booted().

<?php
// app/Tenancy/Infrastructure/Eloquent/Concerns/BelongsToTenant.php

namespace App\Tenancy\Infrastructure\Eloquent\Concerns;

use App\Tenancy\Infrastructure\Eloquent\Scopes\TenantScope;

trait BelongsToTenant
{
    protected static function booted(): void
    {
        static::addGlobalScope(app(TenantScope::class));

        static::creating(function ($model) {
            $context = app(\App\Tenancy\Domain\Contracts\TenancyContext::class);

            if ($context->isInitialized() && empty($model->tenant_id)) {
                $model->tenant_id = $context->tenantId()->toString();
            }
        });
    }
}

Se vuoi mantenere il dominio “pulito”, questo trait resta in infrastruttura, e i Model Eloquent (anch’essi infrastruttura) lo usano. Nel dominio continuerai ad avere entità e repository astratti.

Bounded context e multi-tenancy: dove mettere cosa

Un antipattern comune è avere un “Tenant” onnipresente ovunque, con logica sparsa. In DDD, il Tenant è:

  • un concetto del bounded context Tenancy (gestione tenant, domini, piani, stato);
  • uno shared concept (TenantId) usato dagli altri contesti come identità esterna;
  • un vincolo infrastrutturale per l’accesso dati (connessione/scoping).

Suggerimento pratico: crea un bounded context Tenancy responsabile di risoluzione tenant, provisioning (creazione DB/schema), domini e piani. Gli altri contesti consumano solo TenantId e TenancyContext.

Esempio end-to-end: bounded context Billing

Supponiamo un contesto Billing con una regola: “un tenant non può avere più di N fatture in stato Draft se il piano non lo consente”. Questa è logica di dominio, non di infrastruttura.

Modello di dominio

<?php
// app/Billing/Domain/Model/Invoice.php

namespace App\Billing\Domain\Model;

use App\Tenancy\Domain\ValueObject\TenantId;

final class Invoice
{
    public function __construct(
        private string $invoiceId,
        private TenantId $tenantId,
        private string $status // Draft, Issued, Paid...
    ) {}

    public function tenantId(): TenantId
    {
        return $this->tenantId;
    }

    public function status(): string
    {
        return $this->status;
    }

    public function issue(): void
    {
        if ($this->status !== 'Draft') {
            throw new \DomainException('Solo una fattura Draft può essere emessa.');
        }
        $this->status = 'Issued';
    }
}

Repository di dominio (interfaccia)

<?php
// app/Billing/Domain/Repository/InvoiceRepository.php

namespace App\Billing\Domain\Repository;

use App\Tenancy\Domain\ValueObject\TenantId;
use App\Billing\Domain\Model\Invoice;

interface InvoiceRepository
{
    public function countDraftByTenant(TenantId $tenantId): int;

    public function save(Invoice $invoice): void;
}

Use case applicativo

Il caso d’uso orchestri: recupero tenant, validazioni, creazione entità, persistenza.

<?php
// app/Billing/Application/UseCases/CreateDraftInvoice.php

namespace App\Billing\Application\UseCases;

use App\Billing\Domain\Model\Invoice;
use App\Billing\Domain\Repository\InvoiceRepository;
use App\Tenancy\Domain\Contracts\TenancyContext;

final class CreateDraftInvoice
{
    public function __construct(
        private InvoiceRepository $invoices,
        private TenancyContext $tenancy,
        private int $draftLimit
    ) {}

    public function handle(string $invoiceId): void
    {
        $tenantId = $this->tenancy->tenantId();

        $drafts = $this->invoices->countDraftByTenant($tenantId);
        if ($drafts >= $this->draftLimit) {
            throw new \DomainException('Limite di fatture Draft raggiunto per questo tenant.');
        }

        $invoice = new Invoice($invoiceId, $tenantId, 'Draft');
        $this->invoices->save($invoice);
    }
}

Nota importante: l’use case dipende da TenancyContext (contratto) e da un repository di dominio. Non dipende da HTTP, Request o Eloquent.

Implementazione Eloquent del repository

I Model Eloquent sono parte dell’infrastruttura. Se usi tenant_id con scope, l’implementazione diventa più sicura.

<?php
// app/Billing/Infrastructure/Eloquent/Models/InvoiceModel.php

namespace App\Billing\Infrastructure\Eloquent\Models;

use Illuminate\Database\Eloquent\Model;
use App\Tenancy\Infrastructure\Eloquent\Concerns\BelongsToTenant;

final class InvoiceModel extends Model
{
    use BelongsToTenant;

    protected $table = 'invoices';
    protected $fillable = ['invoice_id', 'tenant_id', 'status'];
    public $timestamps = false;
}
<?php
// app/Billing/Infrastructure/Eloquent/EloquentInvoiceRepository.php

namespace App\Billing\Infrastructure\Eloquent;

use App\Billing\Domain\Model\Invoice;
use App\Billing\Domain\Repository\InvoiceRepository;
use App\Billing\Infrastructure\Eloquent\Models\InvoiceModel;
use App\Tenancy\Domain\ValueObject\TenantId;

final class EloquentInvoiceRepository implements InvoiceRepository
{
    public function countDraftByTenant(TenantId $tenantId): int
    {
        return InvoiceModel::query()
            ->where('tenant_id', $tenantId->toString())
            ->where('status', 'Draft')
            ->count();
    }

    public function save(Invoice $invoice): void
    {
        InvoiceModel::query()->updateOrCreate(
            ['invoice_id' => $invoiceId = $this->readPrivate($invoice, 'invoiceId')],
            [
                'tenant_id' => $invoice->tenantId()->toString(),
                'status' => $invoice->status(),
            ]
        );
    }

    private function readPrivate(object $object, string $property): mixed
    {
        $ref = new \ReflectionClass($object);
        $prop = $ref->getProperty($property);
        $prop->setAccessible(true);
        return $prop->getValue($object);
    }
}

In un progetto reale è preferibile evitare reflection in favore di metodi espliciti (ad es. invoiceId()), ma l’esempio serve a mostrare la mappatura tra dominio e persistenza.

Service Provider e binding nel container

Un passo cruciale è legare le interfacce di dominio alle implementazioni infrastrutturali in un provider dedicato al bounded context.

<?php
// app/Billing/Infrastructure/Providers/BillingServiceProvider.php

namespace App\Billing\Infrastructure\Providers;

use Illuminate\Support\ServiceProvider;
use App\Billing\Domain\Repository\InvoiceRepository;
use App\Billing\Infrastructure\Eloquent\EloquentInvoiceRepository;

final class BillingServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(InvoiceRepository::class, EloquentInvoiceRepository::class);

        $this->app->when(\App\Billing\Application\UseCases\CreateDraftInvoice::class)
            ->needs('$draftLimit')
            ->giveConfig('billing.draft_limit');
    }
}

Questo ti permette di sostituire implementazioni (ad es. in-memory repository per test) senza toccare il dominio.

Transazioni e consistenza per tenant

In multi-tenancy, le transazioni dovrebbero essere sempre confinante al tenant. Con database-per-tenant è naturale. Con tenant_id, è comunque essenziale che i casi d’uso applichino transazioni sul database corretto.

<?php
// Esempio di transazione in un controller o in un Application Service

use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($useCase, $command) {
    $useCase->handle($command->invoiceId);
});

Un’evoluzione più “DDD” è delegare la gestione transazionale a un Application Service o a un decorator, mantenendo i singoli use case focalizzati sulla logica.

Tenant-aware cache, queue e storage

Isolare solo il database non basta. In un SaaS è comune introdurre bug sottili su:

  • Cache: chiavi senza prefisso tenant, dati serviti al tenant sbagliato.
  • Queue/Job: job che non ripristinano il contesto tenant quando vengono eseguiti.
  • Storage: filesystem condiviso senza path tenant-specific.
  • Logging/Tracing: impossibile correlare errori a un tenant senza metadata.

Prefisso tenant per chiavi cache

<?php
// app/Tenancy/Infrastructure/Cache/TenantCacheKey.php

namespace App\Tenancy\Infrastructure\Cache;

use App\Tenancy\Domain\Contracts\TenancyContext;

final class TenantCacheKey
{
    public function __construct(private TenancyContext $tenancy) {}

    public function for(string $key): string
    {
        if (!$this->tenancy->isInitialized()) {
            return $key;
        }
        return 'tenant:' . $this->tenancy->tenantId()->toString() . ':' . $key;
    }
}

Job tenant-aware

Un pattern robusto è includere tenant_id nel payload del job e re-inizializzare il contesto all’inizio dell’handle.

<?php
// app/Shared/Infrastructure/Queue/TenantAwareJob.php

namespace App\Shared\Infrastructure\Queue;

use App\Tenancy\Domain\ValueObject\TenantId;
use App\Tenancy\Infrastructure\Tenancy\TenantResolver;

trait TenantAwareJob
{
    public string $tenant_id;

    public function initializeTenant(string $tenantId): void
    {
        $this->tenant_id = $tenantId;
    }

    public function bootTenant(TenantResolver $resolver): void
    {
        $resolver->initializeFromIdentifier($this->tenant_id);
    }
}
<?php
// Uso in un Job

namespace App\Billing\Infrastructure\Jobs;

use App\Shared\Infrastructure\Queue\TenantAwareJob;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;

final class RecalculateBilling implements ShouldQueue
{
    use Dispatchable, Queueable, TenantAwareJob;

    public function __construct(string $tenantId)
    {
        $this->initializeTenant($tenantId);
    }

    public function handle(\App\Tenancy\Infrastructure\Tenancy\TenantResolver $resolver): void
    {
        $this->bootTenant($resolver);

        // ... logica
    }
}

Boundary tra contesti: integrare Tenancy con gli altri bounded context

Un bounded context non dovrebbe “leggere” direttamente le tabelle del contesto Tenancy. Se Billing ha bisogno del piano del tenant, hai varie opzioni:

  • ACL (Anti-Corruption Layer): Billing usa un servizio (interfaccia) che traduce dati da Tenancy nel linguaggio di Billing.
  • Eventi: Tenancy emette eventi di dominio (es. PlanChanged) e Billing aggiorna una proiezione locale.
  • API interna: se i contesti sono separati (microservizi), Billing chiama Tenancy via HTTP/gRPC.

In una monolitica modulare Laravel, l’ACL è spesso la soluzione più semplice: un adapter in infrastruttura che legge dal contesto Tenancy e restituisce un oggetto “Billing-friendly”.

Testing: cosa testare e come evitare false sicurezze

Per ottenere valore da DDD, devi testare dove il valore è massimo:

  • Unit test su entità, value object e domain service (veloci, senza Laravel).
  • Test dei use case con repository finti (in-memory) e tenancy context finto.
  • Integration test dei repository Eloquent (con tenant_id o connessioni runtime).
  • Feature test HTTP per assicurarti che il middleware inizializzi il tenant e blocchi accessi impropri.

TenancyContext finto per test

<?php
namespace Tests\Doubles;

use App\Tenancy\Domain\Contracts\TenancyContext;
use App\Tenancy\Domain\ValueObject\TenantId;

final class FakeTenancyContext implements TenancyContext
{
    public function __construct(private ?TenantId $tenantId) {}

    public function tenantId(): TenantId
    {
        if ($this->tenantId === null) {
            throw new \RuntimeException('Tenancy non inizializzata.');
        }
        return $this->tenantId;
    }

    public function isInitialized(): bool
    {
        return $this->tenantId !== null;
    }
}

Checklist anti-bug in produzione

  1. Ogni request inizializza il tenant prima di qualsiasi accesso a repository.
  2. Le query tenant-aware hanno un meccanismo centralizzato di scoping (connessione o scope).
  3. Cache e storage includono un prefisso/path tenant-specific.
  4. I job ripristinano il contesto tenant in handle().
  5. Log e tracing includono sempre il tenant_id (es. via middleware o processor).
  6. Esistono test che falliscono se una query “dimentica” il tenant (soprattutto con tenant_id).
  7. Provisioning tenant (DB, migrazioni, seed) è automatizzato e idempotente.

Conclusione

DDD e multi-tenancy in Laravel convivono bene se separi responsabilità e mantieni il dominio indipendente. Il tenant deve essere un concetto del linguaggio ubiquo solo dove serve (identità, piani, limiti), mentre la parte “tecnica” (scoping, connessioni, prefissi, provisioning) vive nell’infrastruttura. Con bounded context chiari, repository astratti e un TenancyContext ben definito, puoi costruire un SaaS che cresce senza perdere controllo su isolamento, testabilità e manutenibilità.

Torna su