Le interfacce in Go sono il collante che permette di disaccoppiare i componenti di un’app web: handler, servizi, repository, client esterni. Grazie alle interfacce possiamo testare in isolamento, cambiare implementazioni (es. da memoria a database) e mantenere il codice pulito.
Quando ha senso usare un’interfaccia
- Vuoi cambiare l’implementazione senza toccare il resto del codice (es. storage locale vs Postgres).
- Vuoi testare un handler senza avviare un DB o un servizio esterno.
- Stai modellando un comportamento (contratto), non una gerarchia di tipi.
Un esempio minimal: handler + repository
Partiamo da un’interfaccia UserRepository e da un handler HTTP che dipende da questa interfaccia anziché da una struttura concreta.
package main
import (
"encoding/json"
"errors"
"log"
"net/http"
"strconv"
)
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
// Interfaccia: contratto del nostro storage utenti.
type UserRepository interface {
FindByID(id int64) (User, error)
Save(u User) (User, error)
}
// Implementazione in memoria (comoda per demo e test locali).
type memoryRepo struct {
data map[int64]User
next int64
}
func NewMemoryRepo() *memoryRepo {
return &memoryRepo{data: map[int64]User{}, next: 1}
}
func (m *memoryRepo) FindByID(id int64) (User, error) {
u, ok := m.data[id]
if !ok {
return User{}, errors.New("not found")
}
return u, nil
}
func (m *memoryRepo) Save(u User) (User, error) {
u.ID = m.next
m.next++
m.data[u.ID] = u
return u, nil
}
// Handler dipendente dall'interfaccia, non dall'implementazione.
type UserHandler struct {
repo UserRepository
}
func NewUserHandler(r UserRepository) *UserHandler {
return &UserHandler{repo: r}
}
func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
var payload struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil || payload.Name == "" {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
u, err := h.repo.Save(User{Name: payload.Name})
if err != nil {
http.Error(w, "cannot save", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(u)
}
func (h *UserHandler) GetByID(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Query().Get("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
u, err := h.repo.FindByID(id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(u)
}
func main() {
repo := NewMemoryRepo() // Concrete type
handler := NewUserHandler(repo) // Dipende dall'interfaccia
http.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
handler.Create(w, r)
return
}
if r.Method == http.MethodGet {
handler.GetByID(w, r)
return
}
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
})
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Sostituire l’implementazione (es. database SQL)
Possiamo creare un’altra implementazione che soddisfa UserRepository ma usa un database. Il resto del codice non cambia.
package dbrepo
import (
"database/sql"
"errors"
_ "github.com/lib/pq"
)
type User struct {
ID int64
Name string
}
type SQLRepo struct {
db *sql.DB
}
func NewSQLRepo(db *sql.DB) *SQLRepo { return &SQLRepo{db: db} }
func (r *SQLRepo) FindByID(id int64) (User, error) {
var u User
err := r.db.QueryRow(`SELECT id, name FROM users WHERE id = $1`, id).
Scan(&u.ID, &u.Name)
if errors.Is(err, sql.ErrNoRows) {
return User{}, errors.New("not found")
}
return u, err
}
func (r *SQLRepo) Save(u User) (User, error) {
err := r.db.QueryRow(`INSERT INTO users(name) VALUES($1) RETURNING id`, u.Name).
Scan(&u.ID)
return u, err
}
package main
import (
"database/sql"
"log"
"os"
_ "github.com/lib/pq"
"myapp/dbrepo"
)
func main() {
dsn := os.Getenv("DATABASE_URL")
db, err := sql.Open("postgres", dsn)
if err != nil {
log.Fatal(err)
}
repo := dbrepo.NewSQLRepo(db) // Sostituisce la memoria con Postgres
handler := NewUserHandler(repo)
// ... routing come prima
}
Esempio schema SQL
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL
);
Testare gli handler con un mock
Nei test definiamo un finto repository che implementa l’interfaccia. Così testiamo la logica HTTP senza dipendenze reali.
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
type mockRepo struct {
users map[int64]User
save func(User) (User, error)
find func(int64) (User, error)
}
func (m *mockRepo) Save(u User) (User, error) { return m.save(u) }
func (m *mockRepo) FindByID(id int64) (User, error) { return m.find(id) }
func TestCreateUser(t *testing.T) {
h := NewUserHandler(&mockRepo{
save: func(u User) (User, error) {
u.ID = 123
return u, nil
},
})
body, _ := json.Marshal(map[string]string{"name": "Ada"})
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body))
w := httptest.NewRecorder()
h.Create(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", w.Code)
}
}
Accoppiamento debole con client esterni
Le interfacce non servono solo per lo storage. Ecco un client email astratto, utile per cambiare fornitore o per disabilitare l’invio in test.
type Mailer interface {
Send(to, subject, body string) error
}
type noopMailer struct{}
func (noopMailer) Send(to, subject, body string) error { return nil }
// In produzione potresti avere un mailer SMTP o un provider esterno.
Linee guida pratiche
- Dipendi da interfacce, non da implementazioni: accetta l’interfaccia nel costruttore del tuo componente (handler, servizio).
- Interfacce piccole: preferisci contratti specifici (es.
FindByIDeSave) invece di “mega-interfacce”. - Definisci l’interfaccia dal lato del consumer: mettila nel package che la usa; le implementazioni vivono altrove.
- Evita interfacce premature: se non ci sono alternative o test che ne beneficiano, inizia con un tipo concreto.
- Usa error wrapping e contesto: distingui “not found” dagli errori di sistema.
Struttura dei package
Un layout semplice per separare contratti e implementazioni.
myapp/
├── cmd/api/main.go # wiring dell'app
├── internal/http # handler, middleware (consumano interfacce)
├── internal/domain # entità e contratti (interfacce minime)
├── internal/repository # implementazioni (memoria, sql, ecc.)
└── internal/external # client esterni (mailer, http client ...)
Middleware che usa un’interfaccia
Esempio di middleware che dipende da un’interfaccia per il logging, così puoi cambiarne la strategia (stdout, JSON, file, cloud).
type Logger interface {
Info(msg string, kv ...any)
Error(err error, msg string, kv ...any)
}
func Logging(l Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
l.Info("request", "method", r.Method, "path", r.URL.Path)
next.ServeHTTP(w, r)
})
}
}
Wiring finale
Nel main scegli l’implementazione delle interfacce e iniettile ai componenti.
func main() {
// repo := dbrepo.NewSQLRepo(db) // produzione
repo := NewMemoryRepo() // locale / test manuale
handler := NewUserHandler(repo)
mux := http.NewServeMux()
mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
handler.Create(w, r)
case http.MethodGet:
handler.GetByID(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
http.ListenAndServe(":8080", mux)
}
Checklist veloce
- Ho definito l’interfaccia dove viene consumata?
- È abbastanza piccola da essere facile da implementare e testare?
- Posso sostituire l’implementazione senza cambiare i consumer?
- Ho test con mock/fake che usano l’interfaccia?
Conclusione
Le interfacce in Go rendono le app web più modulabili, testabili e facili da evolvere. Parti concreto, poi estrai un’interfaccia quando ti serve flessibilità (nuove implementazioni o test). Mantieni i contratti piccoli e vicini a chi li usa: il tuo futuro te ti ringrazierà.