Usare le interfacce in un’applicazione web in Go

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. FindByID e Save) 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à.

Torna su