Domain-Driven Design con Multi-Tenancy in Go

Progettare un sistema multi-tenant significa consentire a più clienti (tenant) di condividere la stessa applicazione, mantenendo però un isolamento rigoroso dei dati, delle configurazioni e, spesso, delle policy di sicurezza. Applicare Domain-Driven Design (DDD) in questo contesto aiuta a gestire la complessità: invece di “spargere” controlli di tenant ovunque, si modellano confini, invarianti e flussi in modo coerente e testabile.

Questo articolo mostra come combinare DDD e multi-tenancy in Go con un approccio pragmatico: definire un modello di dominio chiaro, scegliere una strategia d’isolamento, propagare il contesto del tenant, e assicurare che persistenza e query non possano mai “uscire” dai confini del tenant.

Obiettivi e vincoli di un sistema multi-tenant

  • Isolamento dei dati: un tenant non deve mai leggere o modificare dati di un altro tenant.
  • Isolamento operativo: limiti di risorse, rate limiting, queue e job separati o controllati.
  • Configurazione per-tenant: feature flag, piani, impostazioni e branding.
  • Audit e compliance: tracciabilità delle azioni e dei cambiamenti per tenant.
  • Scalabilità: crescita lineare con numero di tenant e carico, con costi controllati.

DDD entra in gioco soprattutto su tre fronti: la definizione di Bounded Context, la scelta dei punti in cui rappresentare il tenant nel dominio, e l’imposizione degli invarianti (incluso l’isolamento) in modo sistematico.

DDD in breve: dove “vive” il tenant

In DDD, le decisioni più importanti riguardano i confini del modello: ogni Bounded Context ha linguaggio ubiquo, aggregati, repository e servizi applicativi propri. Il tenant, dal punto di vista modellistico, può essere:

  1. Parte del dominio (Tenant come concetto esplicito): utile se l’applicazione gestisce tenant come entità amministrabili, piani, billing, ecc.
  2. Concetto infrastrutturale (Tenant come “partizionamento”): utile se nel dominio non si parla mai di tenant, ma l’app deve comunque isolare dati e configurazioni.

Nella pratica spesso convivono entrambe: un contesto “Platform/Identity/Billing” tratta il tenant come entità di dominio, mentre altri contesti (es. “Orders”, “Catalog”) lo trattano come vincolo di isolamento.

Strategie di isolamento dei dati

Prima di scrivere codice, serve scegliere come isolare i dati. Le strategie più comuni sono:

1) Database per tenant

  • Pro: isolamento forte, facilità di backup/restore per tenant, tuning per tenant.
  • Contro: gestione complessa (migrazioni N volte), connessioni, provisioning, costi.
  • Quando: enterprise, compliance, “big tenant” con requisiti specifici.

2) Schema per tenant

  • Pro: buon isolamento, costi inferiori rispetto a database separati.
  • Contro: complessità nel routing dello schema, migrazioni multiple, strumenti non sempre friendly.
  • Quando: Postgres con schemi per cliente, numero tenant medio.

3) Tabelle condivise con colonna tenant_id

  • Pro: gestione semplice, migrazioni uniche, scalabilità orizzontale possibile.
  • Contro: rischio di bug di isolamento se non si impone disciplina (o RLS), indici più grandi.
  • Quando: SaaS con molti tenant piccoli e schema uniforme.

In questo articolo useremo la strategia (3) per mostrare tecniche generalizzabili anche alle altre due. Il punto chiave è: qualsiasi query o comando deve essere vincolato dal tenant, idealmente in modo non aggirabile.

Architettura consigliata: Clean + DDD per strati

Una struttura tipica in Go (per un singolo bounded context) può essere:

/internal/orders
  /domain
    aggregate.go
    events.go
    errors.go
    repository.go
    tenant.go
  /application
    commands.go
    handlers.go
    services.go
  /infrastructure
    postgres_repository.go
    migrations/
  /interfaces
    http_handlers.go
    middleware.go

L’idea è tenere il dominio puro (niente SQL/HTTP), l’applicazione come coordinazione, l’infrastruttura come implementazione tecnica e le interfacce come adapter (HTTP, gRPC, messaggistica).

Modellare l’identità del tenant

Anche quando il tenant è un concetto infrastrutturale, conviene rappresentarlo almeno come Value Object, perché rende espliciti i confini ed evita di passare stringhe generiche.

package domain

import (
  "errors"
  "strings"
)

type TenantID string

func NewTenantID(raw string) (TenantID, error) {
  s := strings.TrimSpace(raw)
  if s == "" {
    return "", errors.New("tenant id vuoto")
  }
  // opzionale: validazioni (UUID, prefissi, charset, lunghezza)
  return TenantID(s), nil
}

func (t TenantID) String() string { return string(t) }

A questo punto, tutte le operazioni sensibili dovrebbero richiedere esplicitamente un TenantID o ricavarlo da un context.Context controllato.

Propagare il tenant con context e middleware

In Go, context.Context è il modo idiomatico per propagare informazioni della richiesta (deadline, trace, cancellation). Il tenant rientra bene in questa categoria.

package interfaces

import (
  "context"
  "net/http"

  "example.com/app/internal/orders/domain"
)

type ctxKey int

const tenantKey ctxKey = iota

func WithTenant(ctx context.Context, tenant domain.TenantID) context.Context {
  return context.WithValue(ctx, tenantKey, tenant)
}

func TenantFromContext(ctx context.Context) (domain.TenantID, bool) {
  t, ok := ctx.Value(tenantKey).(domain.TenantID)
  return t, ok
}

func TenantMiddleware(next http.Handler) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    raw := r.Header.Get("X-Tenant-ID")
    tenant, err := domain.NewTenantID(raw)
    if err != nil {
      http.Error(w, "tenant non valido", http.StatusBadRequest)
      return
    }
    next.ServeHTTP(w, r.WithContext(WithTenant(r.Context(), tenant)))
  })
}

Nota: il modo in cui ricavi il tenant dipende dall’autenticazione. Potrebbe arrivare da un JWT (claim), da un subdomain, da un mTLS client cert o da un gateway. L’importante è che sia validato una volta e poi propagato in modo affidabile.

Il repository come “guardiano” dell’isolamento

Uno degli errori più frequenti è “fidarsi” dei layer superiori per filtrare per tenant. In DDD, il repository è un punto naturale per imporre il vincolo. Un pattern utile: repository “scoped” (istanza legata a un tenant) oppure repository che richiede sempre il tenant.

package domain

import "context"

type OrderID string

type Order struct {
  id       OrderID
  tenant   TenantID
  customer string
  status   string
}

func (o Order) ID() OrderID       { return o.id }
func (o Order) Tenant() TenantID  { return o.tenant }
func (o Order) Status() string    { return o.status }

// Repository esplicito: ogni metodo richiede tenant.
type OrderRepository interface {
  GetByID(ctx context.Context, tenant TenantID, id OrderID) (Order, error)
  Save(ctx context.Context, tenant TenantID, order Order) error
  ListByCustomer(ctx context.Context, tenant TenantID, customer string, limit int) ([]Order, error)
}

Questo rende impossibile (a livello di compilazione) chiamare il repository senza tenant. Alternative valide includono:

  • Repository scoped: repo := repos.ForTenant(tenant) e poi metodi senza parametro tenant.
  • Repository che legge dal context: comodo, ma va fatto con attenzione per evitare uso improprio.

Persistenza in Postgres: vincolo tenant_id in tutte le query

Di seguito un esempio di implementazione con database/sql. In produzione potresti usare pgx, sqlc o un layer più strutturato; il concetto resta lo stesso.

package infrastructure

import (
  "context"
  "database/sql"
  "errors"

  "example.com/app/internal/orders/domain"
)

type PostgresOrderRepository struct {
  db *sql.DB
}

func NewPostgresOrderRepository(db *sql.DB) *PostgresOrderRepository {
  return &PostgresOrderRepository{db: db}
}

func (r *PostgresOrderRepository) GetByID(
  ctx context.Context,
  tenant domain.TenantID,
  id domain.OrderID,
) (domain.Order, error) {

  const q = `
    SELECT id, tenant_id, customer, status
    FROM orders
    WHERE tenant_id = $1 AND id = $2
  `
  row := r.db.QueryRowContext(ctx, q, tenant.String(), string(id))

  var (
    oid, tid, customer, status string
  )
  if err := row.Scan(&oid, &tid, &customer, &status); err != nil {
    if errors.Is(err, sql.ErrNoRows) {
      return domain.Order{}, errors.New("order non trovato")
    }
    return domain.Order{}, err
  }

  t, _ := domain.NewTenantID(tid)
  return domain.Order{
    id:       domain.OrderID(oid),
    tenant:   t,
    customer: customer,
    status:   status,
  }, nil
}

func (r *PostgresOrderRepository) Save(
  ctx context.Context,
  tenant domain.TenantID,
  order domain.Order,
) error {

  // Invariante: non puoi salvare un ordine con tenant diverso da quello dello scope.
  if order.Tenant() != tenant {
    return errors.New("tenant mismatch in save")
  }

  const q = `
    INSERT INTO orders (id, tenant_id, customer, status)
    VALUES ($1, $2, $3, $4)
    ON CONFLICT (tenant_id, id) DO UPDATE
      SET customer = EXCLUDED.customer,
          status = EXCLUDED.status
  `
  _, err := r.db.ExecContext(
    ctx, q,
    string(order.ID()),
    tenant.String(),
    order.customer,
    order.status,
  )
  return err
}

Due dettagli importanti:

  • La chiave naturale è spesso (tenant_id, id). Anche se id è globalmente unico, l’indice composto riduce il rischio di bug e migliora i piani di query quando filtri per tenant.
  • tenant mismatch in Save è un check difensivo che impedisce di “spostare” un aggregate tra tenant accidentalmente.

Schema e indici: progettare per query e isolamento

Esempio di tabella con vincoli utili:

CREATE TABLE orders (
  tenant_id   TEXT NOT NULL,
  id          TEXT NOT NULL,
  customer    TEXT NOT NULL,
  status      TEXT NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),

  PRIMARY KEY (tenant_id, id)
);

CREATE INDEX orders_tenant_customer_idx
  ON orders (tenant_id, customer);

CREATE INDEX orders_tenant_created_idx
  ON orders (tenant_id, created_at DESC);

Filtrare sempre per tenant_id rende gli indici “tenant-first” molto efficaci. Evita indici che non includono il tenant se tutte le query devono essere isolate.

Row-Level Security: rendere l’isolamento non aggirabile

Se usi Postgres, un ulteriore livello di protezione è la Row-Level Security (RLS). Anche se un bug applicativo dimentica il filtro, il database lo imporrà.

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY orders_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id'));

-- In connessione/transaction, imposta la variabile:
-- SET LOCAL app.tenant_id = 'tenant-123';

A questo punto, l’app deve impostare app.tenant_id per ogni richiesta. È comodo farlo a livello di transazione, vicino all’unità di lavoro.

package infrastructure

import (
  "context"
  "database/sql"
)

type UnitOfWork struct {
  db *sql.DB
}

func (u UnitOfWork) WithinTx(ctx context.Context, fn func(tx *sql.Tx) error) error {
  tx, err := u.db.BeginTx(ctx, nil)
  if err != nil { return err }
  defer tx.Rollback()

  if err := fn(tx); err != nil { return err }
  return tx.Commit()
}
package infrastructure

import (
  "context"
  "database/sql"

  "example.com/app/internal/orders/domain"
)

func setTenantLocal(ctx context.Context, tx *sql.Tx, tenant domain.TenantID) error {
  _, err := tx.ExecContext(ctx, "SET LOCAL app.tenant_id = $1", tenant.String())
  return err
}

Con RLS, anche la query SELECT * FROM orders WHERE id = ... non restituirà righe di altri tenant, purché app.tenant_id sia impostata correttamente.

Servizi applicativi: comandi, query e invarianti

Nel layer applicativo coordini casi d’uso. Il tenant arriva dal contesto e diventa parametro esplicito verso il dominio/infrastruttura. Un esempio minimale con un handler di comando:

package application

import (
  "context"
  "errors"

  "example.com/app/internal/orders/domain"
  "example.com/app/internal/orders/interfaces"
)

type CreateOrder struct {
  OrderID  string
  Customer string
}

type CreateOrderHandler struct {
  repo domain.OrderRepository
}

func NewCreateOrderHandler(repo domain.OrderRepository) *CreateOrderHandler {
  return &CreateOrderHandler{repo: repo}
}

func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrder) error {
  tenant, ok := interfaces.TenantFromContext(ctx)
  if !ok {
    return errors.New("tenant mancante nel context")
  }

  // Creazione dell’aggregate (qui semplificata)
  order := domain.Order{
    id:       domain.OrderID(cmd.OrderID),
    tenant:   tenant,
    customer: cmd.Customer,
    status:   "created",
  }

  return h.repo.Save(ctx, tenant, order)
}

Due buone pratiche:

  • Non fidarti dell’input: il tenant dell’aggregate viene preso dal contesto validato, non dal body della richiesta.
  • Tenant come precondizione: se manca, è un errore strutturale (middleware o auth).

Domain Events e multi-tenancy

Se usi eventi di dominio (per integrazioni o proiezioni), includere il tenant è fondamentale per routing e isolamento.

package domain

import "time"

type DomainEvent interface {
  EventName() string
  OccurredAt() time.Time
  Tenant() TenantID
}

type OrderCreated struct {
  tenant TenantID
  at     time.Time
  id     OrderID
}

func (e OrderCreated) EventName() string   { return "orders.order_created" }
func (e OrderCreated) OccurredAt() time.Time { return e.at }
func (e OrderCreated) Tenant() TenantID    { return e.tenant }

In una pipeline event-driven (Kafka, NATS, RabbitMQ), il tenant può essere inserito anche come header o key, così da permettere partizionamento per tenant e consumer isolation.

Multi-tenancy e Bounded Context

Un errore comune è pensare che il “tenant” sia un singolo concetto condiviso ovunque. In realtà, ogni bounded context può avere esigenze diverse:

  • Identity/Platform: gestisce tenant, utenti, ruoli, piani, lifecycle (onboarding/offboarding).
  • Orders: tratta tenant come vincolo di query e coerenza.
  • Billing: potrebbe avere regole specifiche, valute, imposte per tenant.

Per evitare accoppiamento, usa integrazioni esplicite tra contesti: API, eventi, o Anti-Corruption Layer. Non “importare” direttamente modelli interni tra contesti.

Testing: evitare regressioni di isolamento

L’isolamento multi-tenant merita test dedicati. Alcune idee concrete:

  • Test di repository: dati di tenant A non devono comparire in query di tenant B.
  • Test di handler: se il tenant nel context cambia, cambia anche la visibilità.
  • Property-based testing: generare tenant e id casuali per scovare corner case.
  • Test con RLS: verificare che query “senza filtro” restino isolate grazie alle policy.
func TestIsolation_GetByID(t *testing.T) {
  ctx := context.Background()

  tenantA, _ := domain.NewTenantID("A")
  tenantB, _ := domain.NewTenantID("B")

  // salva ordine per tenant A
  order := domain.Order{ id: "o1", tenant: tenantA, customer: "c1", status: "created" }
  require.NoError(t, repo.Save(ctx, tenantA, order))

  // prova a leggere con tenant B
  _, err := repo.GetByID(ctx, tenantB, "o1")
  require.Error(t, err)
}

Osservabilità: tenant come dimensione di logging e metrics

Aggiungere il tenant come campo strutturato nei log e come label nelle metriche è utile per:

  • diagnostica rapida (errori concentrati su un tenant)
  • capacity planning (tenant rumorosi)
  • billing (se hai modelli a consumo)

Attenzione però alla cardinalità: se hai migliaia di tenant, etichettare tutte le metriche con tenant può esplodere la memoria di Prometheus. Una soluzione è:

  • mettere il tenant nei log (OK ad alta cardinalità)
  • metriche per cluster/servizio e, al massimo, per “tier” o “top N tenant”

Migrazioni e versioning

Con colonna tenant_id (tabelle condivise) le migrazioni sono semplici: una sola volta. Con schema/database per tenant devi orchestrare migrazioni ripetute e spesso parallele.

Esempio (semplificato) di applicazione migrazioni all’avvio usando un tool esterno:

# Esempio con migrate (golang-migrate)
migrate -path ./migrations -database "$DATABASE_URL" up

Configurazione per tenant

Spesso un tenant ha feature diverse (piani), limiti, template email, regole fiscali. Evita di far dipendere il dominio direttamente dal “config store”. Un approccio: definire un’interfaccia di dominio o applicativa e fornirne un’implementazione infrastrutturale.

package application

import (
  "context"

  "example.com/app/internal/orders/domain"
)

type TenantConfig struct {
  MaxOpenOrders int
  EnableDiscounts bool
}

type TenantConfigProvider interface {
  Get(ctx context.Context, tenant domain.TenantID) (TenantConfig, error)
}

In un handler, puoi caricare config e applicare regole senza “contaminare” il dominio con dipendenze tecniche. Se alcune regole sono invarianti dure, puoi trasformarle in policy o servizi di dominio, mantenendo le dipendenze invertite.

Checklist: errori comuni e come evitarli

  • Tenant preso dal body: usa auth/middleware, non input utente.
  • Query senza tenant_id: imponi firma repository o RLS, idealmente entrambi.
  • Indici non tenant-first: ottimizza per filtri costanti su tenant.
  • Metriche con label tenant ovunque: attenzione alla cardinalità.
  • Condivisione modelli tra bounded context: usa ACL, eventi o API.

Conclusione

Domain-Driven Design e multi-tenancy non sono in conflitto: DDD aiuta a evitare che il tenant diventi una “variabile globale” non controllata. Definendo confini (bounded context), value object per il tenant, repository che impongono lo scope e, dove possibile, RLS nel database, ottieni un sistema più sicuro, coerente e facile da evolvere.

Il risultato desiderato è semplice da enunciare e difficile da garantire senza disciplina: ogni operazione su dati è sempre eseguita nel contesto di un tenant, e non può uscire da quel contesto. Con Go e DDD puoi renderlo una proprietà strutturale del design, non una convenzione fragile.

Torna su