Salta al contenuto principale

Il pattern Factory in Go: dependency inversion e servizi testabili

·6 minuti
Indice dei contenuti
Il pattern Factory in Go riguarda principalmente la costruzione basata su interfacce che abilita la dependency inversion e il testing. L’esempio degli animali va bene per imparare la sintassi. Questo articolo mostra la versione che conta in produzione: backend di storage, sender di notifiche e servizi testabili.

La versione originale di questo articolo aveva due paragrafi e un esempio con Dog.Speak(). Questo copre la sintassi ma manca completamente il punto. Il pattern Factory in Go non riguarda la creazione di animali. Riguarda la scrittura di codice in cui il layer chiamante non ha bisogno di sapere o importarsi di quale implementazione concreta riceve.

Il Problema Senza una Factory
#

Considera un UserService che istanzia direttamente uno storage PostgreSQL:

bad_example.go
package user

type UserService struct {
    db *PostgresDB // tipo concreto, non un'interfaccia
}

func NewUserService() *UserService {
    db := &PostgresDB{
        Host:     "localhost",
        Port:     5432,
        Database: "users",
    }
    db.Connect()
    return &UserService{db: db}
}

Questo e problematico per tre motivi:

  1. Non puoi testare UserService senza un’istanza Postgres in esecuzione
  2. Non puoi scambiare Postgres con SQLite in sviluppo senza toccare UserService
  3. Il servizio possiede il ciclo di vita della connessione al database – una responsabilita che non dovrebbe avere

Il Pattern Factory: Costruzione Basata su Interfacce
#

Definisci un’interfaccia che esprime cosa puo fare lo storage, poi scrivi una factory che restituisce l’implementazione giusta in base alla configurazione:

storage.go
package storage

import "context"

// Storage e l'interfaccia da cui dipende il resto del codebase.
// Non menziona mai Postgres, SQLite o qualsiasi backend concreto.
type Storage interface {
    GetUser(ctx context.Context, id int64) (*User, error)
    CreateUser(ctx context.Context, u *User) error
    DeleteUser(ctx context.Context, id int64) error
}

type Config struct {
    Type string // "postgres", "sqlite", "memory"
    DSN  string // connection string per postgres/sqlite
}

// NewStorage e la funzione factory.
// Il chiamante ottiene un'interfaccia Storage e non vede mai il tipo concreto.
func NewStorage(cfg Config) (Storage, error) {
    switch cfg.Type {
    case "postgres":
        return newPostgresStorage(cfg.DSN)
    case "sqlite":
        return newSQLiteStorage(cfg.DSN)
    case "memory":
        return newMemoryStorage(), nil
    default:
        return nil, fmt.Errorf("tipo di storage sconosciuto: %q", cfg.Type)
    }
}

Usarla per il Testing
#

L’implementazione in-memory non e un giocattolo. E la chiave che rende i test unitari veloci e autonomi:

memory_storage.go
package storage

import (
    "context"
    "fmt"
    "sync"
)

type memoryStorage struct {
    mu    sync.RWMutex
    users map[int64]*User
    seq   int64
}

func newMemoryStorage() *memoryStorage {
    return &memoryStorage{users: make(map[int64]*User)}
}

func (m *memoryStorage) GetUser(ctx context.Context, id int64) (*User, error) {
    m.mu.RLock()
    defer m.mu.RUnlock()
    u, ok := m.users[id]
    if !ok {
        return nil, fmt.Errorf("utente %d non trovato", id)
    }
    return u, nil
}

func (m *memoryStorage) CreateUser(ctx context.Context, u *User) error {
    m.mu.Lock()
    defer m.mu.Unlock()
    m.seq++
    u.ID = m.seq
    m.users[m.seq] = u
    return nil
}

func (m *memoryStorage) DeleteUser(ctx context.Context, id int64) error {
    m.mu.Lock()
    defer m.mu.Unlock()
    delete(m.users, id)
    return nil
}

Ora i test unitari non toccano mai un database:

user_service_test.go
package user_test

import (
    "context"
    "testing"

    "github.com/example/app/storage"
    "github.com/example/app/user"
)

func TestCreateUser(t *testing.T) {
    // Storage in-memory: nessun database richiesto, nessuna pulizia necessaria
    store, err := storage.NewStorage(storage.Config{Type: "memory"})
    if err != nil {
        t.Fatal(err)
    }

    svc := user.NewUserService(store)
    created, err := svc.CreateUser(context.Background(), "alice", "alice@example.com")
    if err != nil {
        t.Fatalf("errore inaspettato: %v", err)
    }
    if created.ID == 0 {
        t.Error("atteso ID non zero")
    }
}

Factory con Dependency Injection
#

Il servizio stesso accetta l’interfaccia nel suo costruttore – non chiama la factory:

user_service.go
package user

import (
    "context"
    "fmt"

    "github.com/example/app/storage"
)

type UserService struct {
    store storage.Storage // interfaccia, non tipo concreto
}

// NewUserService accetta qualsiasi implementazione di Storage.
// La factory decide quale usare; il servizio non se ne preoccupa.
func NewUserService(store storage.Storage) *UserService {
    return &UserService{store: store}
}

func (s *UserService) CreateUser(ctx context.Context, name, email string) (*storage.User, error) {
    u := &storage.User{Name: name, Email: email}
    if err := s.store.CreateUser(ctx, u); err != nil {
        return nil, fmt.Errorf("create user: %w", err)
    }
    return u, nil
}

Nel punto di ingresso dell’applicazione, colleghi tutto:

main.go
func main() {
    storageType := os.Getenv("STORAGE_TYPE")
    if storageType == "" {
        storageType = "postgres"
    }

    store, err := storage.NewStorage(storage.Config{
        Type: storageType,
        DSN:  os.Getenv("DATABASE_URL"),
    })
    if err != nil {
        log.Fatal(err)
    }

    userSvc := user.NewUserService(store)
    // ... configura gli handler HTTP, ecc.
}

Imposta STORAGE_TYPE=memory in CI. Imposta STORAGE_TYPE=postgres in produzione. Il codice del servizio e identico in entrambi gli ambienti.

Notification Sender: Una Factory nel Mondo Reale
#

Lo stesso pattern si applica a qualsiasi backend che varia per ambiente:

notifier.go
package notify

import "context"

type Sender interface {
    Send(ctx context.Context, to, subject, body string) error
}

type Config struct {
    Type     string // "smtp", "log", "noop"
    SMTPHost string
    SMTPPort int
    From     string
}

func NewSender(cfg Config) (Sender, error) {
    switch cfg.Type {
    case "smtp":
        return newSMTPSender(cfg)
    case "log":
        // Scrive su stdout -- utile in sviluppo e CI
        return &logSender{}, nil
    case "noop":
        // Scarta silenziosamente tutti i messaggi -- utile nei test
        return &noopSender{}, nil
    default:
        return nil, fmt.Errorf("tipo di sender sconosciuto: %q", cfg.Type)
    }
}

La produzione usa smtp. I test di integrazione usano log per poter vedere cosa verrebbe inviato. I test unitari usano noop per saltare completamente le email. Nessuna ginnastica con le variabili d’ambiente, nessun framework di mocking.

Il Tool wire per Codebase Grandi
#

Quando il grafo delle dipendenze cresce oltre una manciata di servizi, Google Wire genera il codice di wiring a tempo di compilazione. Definisci le funzioni provider (le tue factory) e Wire genera un wire_gen.go che costruisce il grafo completo degli oggetti senza riflessione a runtime.

Per la maggior parte delle applicazioni, il wiring manuale in main.go e piu pulito e leggibile. Ricorri a Wire quando il codice di wiring stesso diventa un onere di manutenzione.

Errori comuni e come evitarli

Restituire tipi concreti invece di interfacce Se la tua factory restituisce *PostgresStorage invece di Storage, ogni chiamante e accoppiato a Postgres. L’intero scopo della factory e restituire l’interfaccia. Il chiamante non dovrebbe mai importare il pacchetto concreto.

Factory che fa troppo lavoro Una funzione factory dovrebbe costruire e configurare. Non dovrebbe fare chiamate di rete, eseguire migration o fare il seeding dei dati. Mantieni la costruzione veloce e sposta il lavoro di inizializzazione in metodi espliciti Init() o Start() che i chiamanti controllano.

Nessuna implementazione “noop” o in-memory per il testing Se le uniche implementazioni sono “backend reali” (Postgres, SMTP, S3), i tuoi test devono fare mock a livello HTTP o avviare container per ogni esecuzione. Scrivi sempre un’implementazione minima in-memory o noop. Costa 30 righe e ripaga in velocita di test.

Restituire nil nel caso default Restituire nil, nil nel caso default dello switch in una factory significa che i chiamanti ottengono un’interfaccia nil e un errore nil. Il call site andra in panic quando prova a usare il valore restituito. Restituisci sempre un errore esplicito quando il tipo richiesto e sconosciuto.


Se vuoi approfondire questi argomenti, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architettura cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.