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à:
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:
- Lo schema del database cambia (nomi delle colonne, struttura della tabella)
- Il provider email cambia (da SMTP a SendGrid, o il template dell’email cambia)
- 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.
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
}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
}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.
// 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.
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.
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.
// 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:
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
Un package per struct
Package utils condivisi
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 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.