Salta al contenuto principale

Design di package pulito in Go: Singola responsabilità e coesione

Indice dei contenuti
L’SRP in Go riguarda i confini dei package e le superfici esportate, non solo dividere i metodi in file diversi. Quando un package ha un solo motivo per cambiare, lo si può testare in isolamento, sostituire le implementazioni dietro un’interfaccia e ragionare sul suo comportamento senza leggere il resto del codebase.

Il Single Responsibility Principle (SRP) viene spesso enunciato come “una classe dovrebbe avere un solo motivo per cambiare.” In Go non esistono classi, ma il principio si applica con uguale forza ai package e ai tipi esportati al loro interno. Sbagliarlo produce un God service: un unico package che conosce il database, l’email, HTTP e la business logic tutti insieme. Applicarlo correttamente produce un codebase dove cambiare il provider email non richiede di toccare il layer del database.

La violazione dell’SRP: il God Service
#

Ecco un esempio realistico di come appare una violazione dell’SRP in Go. Questo UserService ha accumulato troppe responsabilità:

user/service.go (PRIMA -- violazione)
package user

import (
    "database/sql"
    "encoding/json"
    "fmt"
    "net/http"
    "net/smtp"
)

type UserService struct {
    db *sql.DB
}

// CreateUser inserisce un utente E invia un'email di benvenuto E logga via HTTP.
// Tre motivi per cambiare: schema DB, provider email, endpoint di logging.
func (s *UserService) CreateUser(username, email string) error {
    _, err := s.db.Exec(
        "INSERT INTO users (username, email) VALUES ($1, $2)",
        username, email,
    )
    if err != nil {
        return fmt.Errorf("insert user: %w", err)
    }

    // Responsabilita 2: email
    auth := smtp.PlainAuth("", "robot@example.com", "secret", "smtp.example.com")
    msg := []byte("Subject: Welcome\r\n\r\nWelcome to the platform, " + username)
    if err := smtp.SendMail("smtp.example.com:587", auth, "robot@example.com",
        []string{email}, msg); err != nil {
        return fmt.Errorf("send welcome email: %w", err)
    }

    // Responsabilita 3: audit log via HTTP
    payload, _ := json.Marshal(map[string]string{"event": "user.created", "email": email})
    _, _ = http.Post("https://audit.internal/events", "application/json",
        bytes.NewReader(payload))

    return nil
}

Questa funzione ha tre distinti motivi per cambiare:

  1. Lo schema del database cambia (nomi delle colonne, struttura della tabella)
  2. Il provider email cambia (da SMTP a SendGrid, o il template dell’email cambia)
  3. La destinazione o il formato del log di audit cambia

Testare CreateUser richiede un database reale, un server SMTP reale e un endpoint HTTP reale. Cambiare l’URL del log di audit richiede di toccare il codice di creazione utente.

La divisione corretta: Repository, Notification, Orchestrator
#

La soluzione è estrarre ogni responsabilita nel proprio tipo, ciascuno nel proprio package, e collegarli in un layer di orchestrazione sottile.

storage/user_repository.go
package storage

import (
    "context"
    "database/sql"
    "fmt"
)

type User struct {
    ID       int64
    Username string
    Email    string
}

type UserRepository struct {
    db *sql.DB
}

func NewUserRepository(db *sql.DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) Create(ctx context.Context, username, email string) (*User, error) {
    var id int64
    err := r.db.QueryRowContext(ctx,
        "INSERT INTO users (username, email) VALUES ($1, $2) RETURNING id",
        username, email,
    ).Scan(&id)
    if err != nil {
        return nil, fmt.Errorf("create user: %w", err)
    }
    return &User{ID: id, Username: username, Email: email}, nil
}
notification/service.go
package notification

import (
    "fmt"
    "net/smtp"
)

type SMTPConfig struct {
    Host     string
    Port     int
    Username string
    Password string
    From     string
}

type Service struct {
    cfg SMTPConfig
}

func NewService(cfg SMTPConfig) *Service {
    return &Service{cfg: cfg}
}

func (s *Service) SendWelcome(toEmail, username string) error {
    addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
    auth := smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)
    msg := []byte(fmt.Sprintf("Subject: Welcome\r\n\r\nWelcome, %s!", username))
    if err := smtp.SendMail(addr, auth, s.cfg.From, []string{toEmail}, msg); err != nil {
        return fmt.Errorf("send welcome email to %s: %w", toEmail, err)
    }
    return nil
}
user/service.go (DOPO -- solo orchestrazione)
package user

import (
    "context"
    "fmt"

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

// Notifier è l'interfaccia richiesta da questo package. Non conosce SMTP.
type Notifier interface {
    SendWelcome(email, username string) error
}

// Storer è l'interfaccia richiesta da questo package. Non conosce SQL.
type Storer interface {
    Create(ctx context.Context, username, email string) (*storage.User, error)
}

type Service struct {
    store    Storer
    notifier Notifier
}

func NewService(store Storer, notifier Notifier) *Service {
    return &Service{store: store, notifier: notifier}
}

func (s *Service) Register(ctx context.Context, username, email string) error {
    user, err := s.store.Create(ctx, username, email)
    if err != nil {
        return fmt.Errorf("register: %w", err)
    }
    if err := s.notifier.SendWelcome(user.Email, user.Username); err != nil {
        // Non fatale: l'utente è creato, la notifica è best-effort.
        return fmt.Errorf("register: send welcome: %w", err)
    }
    return nil
}

Ora UserService ha un solo motivo per cambiare: la business logic della registrazione. Cambiare SMTP è un problema di notification. Cambiare lo schema del database è un problema di storage.

Coesione dei package: cosa va dove
#

flowchart LR
    subgraph Before ["Prima (God Service)"]
        GS[user.UserService\nDB + Email + HTTP + Logic]
    end

    subgraph After ["Dopo (Responsabilita decomposte)"]
        US[user.Service\nsolo orchestrazione]
        UR[storage.UserRepository\nsolo SQL]
        NS[notification.Service\nsolo SMTP]
        US -->|interfaccia Storer| UR
        US -->|interfaccia Notifier| NS
    end

La struttura decompostta offre:

  • storage: tutto ciò che parla con il database. Un motivo per cambiare: il meccanismo di persistenza.
  • notification: tutto ciò che invia messaggi agli utenti. Un motivo per cambiare: il canale di notifica o il template.
  • user: orchestrazione delle operazioni di dominio. Un motivo per cambiare: le regole di business del ciclo di vita dell’utente.

La regola della superficie esportata
#

Esporta solo ciò di cui i consumatori hanno bisogno. Se un tipo è un dettaglio implementativo, tienilo non esportato.

storage/user_repository.go (superficie esportata)
// Esportato: i consumatori devono chiamare NewUserRepository e usare UserRepository.
type UserRepository struct { /* ... */ }
func NewUserRepository(db *sql.DB) *UserRepository { /* ... */ }
func (r *UserRepository) Create(ctx context.Context, username, email string) (*User, error) { /* ... */ }

// Non esportato: i consumatori non devono sapere come viene costruita la query.
func (r *UserRepository) buildInsertQuery() string { /* ... */ }

Se un consumatore importa storage e ha bisogno di mockarlo, implementa l’interfaccia Storer dal package user. Non ha mai bisogno di dipendere dagli interni non esportati di storage.

Quando l’SRP diventa over-engineering
#

Non ogni struct da 20 righe ha bisogno del proprio package. L’SRP è uno strumento per gestire il cambiamento, non un mandato per massimizzare il numero di file.

Nota

Se una funzione fa legittimamente due cose che cambiano sempre insieme, dividerle è cerimonia, non ingegneria. L’euristica è: “un cambiamento a questa responsabilita mi richiederebbe di aprire file in un altro package?” Se sì, dividi. Altrimenti, tienili insieme.

Un package config che legge variabili d’ambiente e costruisce stringhe di connessione al database va bene. Il fatto che tocchi due concern (env vars e DB config) è accettabile perché cambiano insieme.

L’euristica reale dell’SRP in Go: un solo motivo per cambiare
#

I package della libreria standard Go sono un buon riferimento. net/http gestisce HTTP. database/sql gestisce SQL. encoding/json gestisce la codifica JSON. Ognuno ha una superficie chiaramente delimitata e un singolo asse di cambiamento.

Applica la stessa lente ai tuoi package: se puoi descrivere un package in una frase senza la parola “e”, l’SRP è probabilmente soddisfatto.

Suggerimento

Il package utils o helpers è quasi sempre una violazione dell’SRP in attesa di manifestarsi. Quando ti ritrovi a crearne uno, chiedi quale sia il filo comune. Se non c’è, le funzioni appartengono ai package che le usano, non in un catch-all condiviso.

Dependency injection basata su interfacce come meccanismo abilitante
#

Il meccanismo che rende l’SRP praticabile in Go è la dependency injection basata su interfacce. Definisci l’interfaccia nel package che la consuma, non in quello che la implementa.

user/service.go (interfacce nel consumatore)
// Queste interfacce vivono nel package user, non in storage o notification.
// Il package user definisce il comportamento di cui ha bisogno; storage e notification
// lo implementano senza conoscere il package user.
type Storer interface {
    Create(ctx context.Context, username, email string) (*storage.User, error)
}

type Notifier interface {
    SendWelcome(email, username string) error
}

Questa inversione significa che storage non importa user, quindi non ci sono import circolari. E nei test, si sostituiscono entrambi con semplici stub:

user/service_test.go
package user_test

import (
    "context"
    "testing"

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

type stubStorer struct{ user *storage.User }
func (s *stubStorer) Create(_ context.Context, username, email string) (*storage.User, error) {
    return &storage.User{ID: 1, Username: username, Email: email}, nil
}

type stubNotifier struct{ called bool }
func (n *stubNotifier) SendWelcome(_, _ string) error { n.called = true; return nil }

func TestRegister_SendsWelcome(t *testing.T) {
    notifier := &stubNotifier{}
    svc := user.NewService(&stubStorer{}, notifier)

    if err := svc.Register(context.Background(), "alice", "alice@example.com"); err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if !notifier.called {
        t.Fatal("expected welcome notification to be sent")
    }
}

Nessun database. Nessun server SMTP. Nessuna chiamata HTTP. Unit test puro della business logic.

Errori comuni
#

Anti-pattern del God service
Una struct di servizio con campi per una connessione DB, un client HTTP, un client SMTP e un client Redis sta facendo troppo. Ogni dipendenza segnala una responsabilita. Quando se ne hanno quattro, probabilmente ci sono quattro responsabilita che dovrebbero essere divise tra i package.
Un package per struct
Andare troppo lontano nell’altra direzione: una struct per package. Questo crea catene di import, costringe a esportare tutto ed elimina i benefici di coesione del tenere insieme tipi correlati. L’unita giusta è un insieme di tipi correlati che cambiano insieme per la stessa ragione.
Package utils condivisi
I package utils, helpers, common e shared sono magneti per codice non correlato. Quando una funzione finisce in utils, di solito significa che appartiene al package che la usa, oppure è genuinamente cross-cutting (come un helper di retry) e merita il proprio package focalizzato come retry o backoff.
Import circolari da scarsa applicazione dell'SRP
Se il package A importa B e B importa A, Go rifiuterà di compilare. Questo è quasi sempre causato da una scarsa decomposizione delle responsabilita: due package che sono in realta una sola responsabilita, o un terzo package che dovrebbe possedere il tipo condiviso. Le interfacce definite nel consumatore (come mostrato sopra) sono la soluzione standard.

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.

Articoli correlati