Go Design Patterns: Pattern essenziali e domande da colloquio per sviluppatori Go

I sei design pattern Go piu importanti con codice pronto per la produzione: Functional Options, Strategy, Factory, Observer, Middleware e Struct Embedding. Con domande frequenti nei colloqui tecnici.

Go Design Patterns: Functional Options, Strategy, Factory, Observer, Middleware e Struct Embedding

I programmi Go, grazie alla semplicita del linguaggio e alla sua filosofia orientata alla composizione, richiedono un approccio ai design pattern diverso rispetto ai linguaggi orientati agli oggetti tradizionali. Go non dispone di classi, ereditarieta o generics nel senso classico: i pattern si costruiscono attorno a interfacce, funzioni di primo ordine, goroutine e canali. Comprendere questi pattern idiomatici risulta fondamentale sia per scrivere codice di produzione robusto, sia per affrontare con sicurezza i colloqui tecnici. Questa guida analizza i design pattern piu rilevanti in Go, con esempi pratici e domande frequenti nei colloqui per sviluppatori Go.

Principio idiomatico di Go

Go adotta un approccio "composition over inheritance" come principio fondamentale del linguaggio. A differenza di Java o C++, i pattern in Go si basano su interfacce implicite, embedding di struct e funzioni come valori di prima classe. Pensare in termini di composizione, anziche di gerarchie, rappresenta la chiave per scrivere codice Go idiomatico.

Functional Options: configurazione flessibile e leggibile

Il pattern Functional Options risolve un problema comune nella progettazione di API Go: come configurare una struct complessa senza ricorrere a costruttori con decine di parametri o a struct di configurazione separate. Questo approccio, reso popolare da Dave Cheney e Rob Pike, sfrutta le closure per applicare opzioni in modo incrementale a una struct con valori di default sensati.

Il vantaggio principale risiede nella retrocompatibilita: aggiungere nuove opzioni non rompe il codice esistente, poiche i chiamanti specificano solo cio che desiderano modificare. Nei colloqui tecnici sui golang design patterns, questo pattern viene citato frequentemente come esempio di API design idiomatico.

server.gogo
package server

import (
	"time"
	"log/slog"
)

// Server holds the HTTP server configuration.
type Server struct {
	host         string
	port         int
	timeout      time.Duration
	maxConns     int
	logger       *slog.Logger
}

// Option defines a functional option for Server.
type Option func(*Server)

// WithPort sets the server port.
func WithPort(port int) Option {
	return func(s *Server) {
		s.port = port
	}
}

// WithTimeout sets the request timeout.
func WithTimeout(d time.Duration) Option {
	return func(s *Server) {
		s.timeout = d
	}
}

// WithMaxConns sets the maximum concurrent connections.
func WithMaxConns(n int) Option {
	return func(s *Server) {
		s.maxConns = n
	}
}

// New creates a Server with sensible defaults and applies options.
func New(host string, opts ...Option) *Server {
	srv := &Server{
		host:     host,
		port:     8080,           // default port
		timeout:  30 * time.Second, // default timeout
		maxConns: 100,            // default max connections
		logger:   slog.Default(),
	}
	for _, opt := range opts {
		opt(srv)
	}
	return srv
}

La firma New(host string, opts ...Option) mantiene obbligatorio solo il parametro essenziale (host), delegando tutto il resto a opzioni variadic. Ogni Option e una closure che modifica un singolo campo, rendendo il codice auto-documentante: server.New("localhost", server.WithPort(9090), server.WithTimeout(10*time.Second)).

Strategy Pattern: algoritmi intercambiabili tramite interfacce

Lo Strategy Pattern permette di definire una famiglia di algoritmi, incapsularli e renderli intercambiabili a runtime. In Go, questo pattern si implementa in modo naturale attraverso le interfacce: si definisce un contratto (l'interfaccia) e si forniscono implementazioni concrete che lo soddisfano.

L'aspetto distintivo di Go risiede nelle interfacce implicite: un tipo soddisfa un'interfaccia semplicemente implementandone i metodi, senza dichiarazioni esplicite. Questo riduce l'accoppiamento e facilita il testing, poiche creare mock diventa banale.

payment.gogo
package payment

import "fmt"

// Processor defines the strategy interface.
type Processor interface {
	Pay(amount float64) (string, error)
}

// CreditCard implements Processor for card payments.
type CreditCard struct {
	CardNumber string
	Expiry     string
}

func (c *CreditCard) Pay(amount float64) (string, error) {
	// Charge the card via payment gateway
	return fmt.Sprintf("charged %.2f to card ending %s", amount, c.CardNumber[len(c.CardNumber)-4:]), nil
}

// BankTransfer implements Processor for wire transfers.
type BankTransfer struct {
	IBAN string
}

func (b *BankTransfer) Pay(amount float64) (string, error) {
	return fmt.Sprintf("initiated transfer of %.2f to %s", amount, b.IBAN), nil
}

// Checkout processes a payment using the given strategy.
func Checkout(p Processor, amount float64) error {
	receipt, err := p.Pay(amount)
	if err != nil {
		return fmt.Errorf("payment failed: %w", err)
	}
	fmt.Println(receipt)
	return nil
}

La funzione Checkout accetta qualsiasi valore che soddisfi Processor, senza conoscere i dettagli implementativi. Aggiungere un nuovo metodo di pagamento (ad esempio criptovalute) richiede solo una nuova struct con il metodo Pay, senza modificare alcun codice esistente. Questo rispetta il principio Open/Closed e rappresenta una delle go patterns best practices piu consolidate.

Factory Functions: costruzione sicura con validazione

A differenza dei linguaggi con costruttori integrati, Go utilizza funzioni factory convenzionalmente denominate New o NewXxx. Queste funzioni restituiscono un puntatore alla struct e un errore, consentendo la validazione dei parametri al momento della creazione. Questo pattern garantisce che ogni istanza si trovi in uno stato valido fin dalla nascita.

connpool.gogo
package pool

import (
	"errors"
	"sync"
)

// ConnPool manages a pool of reusable connections.
type ConnPool struct {
	mu       sync.Mutex
	conns    []Conn
	maxSize  int
}

// Conn represents a database connection.
type Conn struct {
	ID     int
	Active bool
}

// NewConnPool validates parameters and returns an initialized pool.
func NewConnPool(maxSize int) (*ConnPool, error) {
	if maxSize <= 0 {
		return nil, errors.New("pool: maxSize must be positive")
	}
	return &ConnPool{
		conns:   make([]Conn, 0, maxSize),
		maxSize: maxSize,
	}, nil
}

// Acquire returns a connection from the pool.
func (p *ConnPool) Acquire() (*Conn, error) {
	p.mu.Lock()
	defer p.mu.Unlock()
	for i := range p.conns {
		if !p.conns[i].Active {
			p.conns[i].Active = true
			return &p.conns[i], nil
		}
	}
	if len(p.conns) >= p.maxSize {
		return nil, errors.New("pool: no available connections")
	}
	c := Conn{ID: len(p.conns) + 1, Active: true}
	p.conns = append(p.conns, c)
	return &p.conns[len(p.conns)-1], nil
}

Il pattern (*T, error) come tipo di ritorno costituisce una convenzione idiomatica di Go che forza il chiamante a gestire esplicitamente l'errore. Nel caso del connection pool, NewConnPool rifiuta valori non validi (maxSize <= 0) prima ancora di allocare risorse. L'uso di sync.Mutex protegge l'accesso concorrente, un requisito critico nel codice di produzione Go.

Pronto a superare i tuoi colloqui su Go?

Pratica con i nostri simulatori interattivi, flashcards e test tecnici.

Observer Pattern con i canali Go

Il pattern Observer, tradizionalmente implementato con callback e liste di listener, trova in Go un'espressione naturale attraverso i canali (channels). I canali forniscono comunicazione type-safe tra goroutine, rendendo la pubblicazione e la sottoscrizione di eventi un'operazione concorrente e sicura senza bisogno di lock espliciti lato consumatore.

eventbus.gogo
package events

import "sync"

// Event carries a topic and payload.
type Event struct {
	Topic   string
	Payload any
}

// Bus manages subscriptions and event dispatch.
type Bus struct {
	mu          sync.RWMutex
	subscribers map[string][]chan Event
}

// NewBus creates an event bus.
func NewBus() *Bus {
	return &Bus{
		subscribers: make(map[string][]chan Event),
	}
}

// Subscribe returns a channel that receives events for a topic.
func (b *Bus) Subscribe(topic string) <-chan Event {
	ch := make(chan Event, 16) // buffered to avoid blocking publisher
	b.mu.Lock()
	b.subscribers[topic] = append(b.subscribers[topic], ch)
	b.mu.Unlock()
	return ch
}

// Publish sends an event to all subscribers of the topic.
func (b *Bus) Publish(topic string, payload any) {
	b.mu.RLock()
	defer b.mu.RUnlock()
	for _, ch := range b.subscribers[topic] {
		select {
		case ch <- Event{Topic: topic, Payload: payload}:
		default:
			// subscriber too slow, drop event
		}
	}
}

L'implementazione utilizza canali bufferizzati (capacita 16) per evitare che un publisher lento blocchi l'intero bus. Il costrutto select con caso default in Publish implementa una politica di drop: se un sottoscrittore non consuma abbastanza velocemente, l'evento viene scartato anziche bloccare il publisher. In un sistema di produzione, sarebbe opportuno aggiungere metriche per monitorare gli eventi persi e un meccanismo di Unsubscribe che chiuda il canale.

Middleware Pattern: composizione di handler HTTP

Il pattern Middleware rappresenta una delle go design patterns piu utilizzate nello sviluppo web con Go. La libreria standard net/http definisce l'interfaccia Handler con un singolo metodo, rendendo la composizione di middleware estremamente naturale: ogni middleware accetta un http.Handler, lo avvolge con logica aggiuntiva e restituisce un nuovo http.Handler.

middleware.gogo
package middleware

import (
	"log/slog"
	"net/http"
	"time"
)

// Logging records request duration and status.
func Logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		wrapped := &statusWriter{ResponseWriter: w, status: 200}
		next.ServeHTTP(wrapped, r)
		slog.Info("request",
			"method", r.Method,
			"path", r.URL.Path,
			"status", wrapped.status,
			"duration", time.Since(start),
		)
	})
}

// statusWriter captures the HTTP status code.
type statusWriter struct {
	http.ResponseWriter
	status int
}

func (w *statusWriter) WriteHeader(code int) {
	w.status = code
	w.ResponseWriter.WriteHeader(code)
}

// Chain applies middleware in order: first listed = outermost.
func Chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
	for i := len(mw) - 1; i >= 0; i-- {
		h = mw[i](h)
	}
	return h
}

La tecnica del statusWriter merita attenzione: poiche http.ResponseWriter non espone lo status code dopo la scrittura, si utilizza un wrapper che intercetta la chiamata WriteHeader. La funzione Chain applica i middleware in ordine inverso affinche il primo della lista sia il piu esterno nella catena di esecuzione. Questo pattern si compone elegantemente: Chain(handler, Logging, Auth, RateLimit) crea una pipeline chiara e manutenibile.

Composizione tramite Struct Embedding

Go non supporta l'ereditarieta, ma offre l'embedding di struct come meccanismo di composizione. Quando una struct viene embedded in un'altra, tutti i suoi campi e metodi vengono "promossi" e diventano accessibili direttamente sulla struct contenitore. Questo approccio favorisce il riutilizzo del codice senza creare gerarchie di tipi fragili.

models.gogo
package models

import "time"

// Timestamps provides common audit fields.
type Timestamps struct {
	CreatedAt time.Time
	UpdatedAt time.Time
}

// User embeds Timestamps to gain CreatedAt/UpdatedAt.
type User struct {
	Timestamps
	ID    int
	Email string
}

// Order also embeds Timestamps.
type Order struct {
	Timestamps
	ID     int
	UserID int
	Total  float64
}

Con l'embedding, User e Order dispongono automaticamente dei campi CreatedAt e UpdatedAt. Se Timestamps avesse metodi (ad esempio MarkUpdated()), anche questi sarebbero disponibili direttamente su User e Order. A differenza dell'ereditarieta, l'embedding non crea una relazione "is-a" ma "has-a": un User non e un Timestamps, ma contiene un Timestamps. Questa distinzione risulta fondamentale nei colloqui tecnici su golang design patterns interview.

Domande frequenti nei colloqui tecnici su Go Design Patterns

Questa sezione raccoglie le domande piu comuni poste durante i colloqui tecnici per posizioni Go, con risposte concise e orientate alla pratica.

Perche Go preferisce la composizione all'ereditarieta?

Go e stato progettato intenzionalmente senza ereditarieta di classe. La composizione tramite embedding e interfacce implicite produce codice piu flessibile e manutenibile. L'ereditarieta crea accoppiamento stretto tra classi base e derivate (il "fragile base class problem"), mentre la composizione permette di assemblare comportamenti in modo indipendente. Le interfacce implicite di Go eliminano la necessita di dichiarare le dipendenze in anticipo, facilitando il refactoring e il testing.

Suggerimento per il colloquio

Nei colloqui tecnici, spiegare la differenza tra embedding (composizione, "has-a") e ereditarieta ("is-a") dimostra una comprensione profonda della filosofia Go. Accompagnare la spiegazione con un esempio concreto, come il pattern Timestamps mostrato sopra, rende la risposta immediatamente credibile.

Come si implementa il Singleton in Go in modo concurrency-safe?

Il package sync fornisce sync.Once, che garantisce l'esecuzione di una funzione una sola volta, indipendentemente dal numero di goroutine che la invocano. L'approccio idiomatico prevede una variabile package-level e una funzione GetInstance() che utilizza once.Do(). Tuttavia, nella community Go il Singleton viene spesso considerato un anti-pattern: rende il testing difficile e introduce stato globale. L'alternativa consigliata consiste nell'iniettare le dipendenze esplicitamente attraverso i costruttori.

Quando utilizzare il pattern Functional Options rispetto a una struct di configurazione?

Il pattern Functional Options risulta preferibile quando l'API deve restare stabile nel tempo e i parametri opzionali sono numerosi. Ogni opzione e auto-documentante e i valori di default sono centralizzati nel costruttore. Una struct di configurazione (Config struct) funziona meglio quando i parametri sono pochi, strettamente correlati e vengono spesso serializzati/deserializzati (ad esempio da file YAML). In pratica, molte librerie Go di successo (gRPC, Zap logger) utilizzano Functional Options per le API pubbliche.

Come si gestisce la propagazione degli errori nei pattern Go?

Go utilizza valori di errore espliciti anziche eccezioni. Nei pattern come Factory e Strategy, ogni operazione restituisce (risultato, error) e il chiamante decide come reagire. Il wrapping degli errori con fmt.Errorf("contesto: %w", err) permette di costruire una catena di contesto senza perdere l'errore originale. I tipi di errore personalizzati (sentinel errors o error types) consentono ai chiamanti di distinguere tra diverse condizioni di errore tramite errors.Is() e errors.As().

Attenzione

Durante un colloquio su go patterns best practices, mostrare familiarita con errors.Is(), errors.As() e il wrapping con %w dimostra padronanza della gestione errori idiomatica in Go. Evitare di menzionare panic/recover come meccanismo principale di gestione errori: in Go, panic e riservato a situazioni irrecuperabili.

Pronto a superare i tuoi colloqui su Go?

Pratica con i nostri simulatori interattivi, flashcards e test tecnici.

Conclusione

I design pattern in Go riflettono la filosofia pragmatica del linguaggio: semplicita, composizione e concorrenza come primitive di prima classe. Ecco un riepilogo dei pattern trattati e del loro ambito di applicazione:

  • Functional Options: configurazione flessibile e retrocompatibile per struct complesse, ideale per librerie pubbliche
  • Strategy Pattern: algoritmi intercambiabili tramite interfacce implicite, perfetto per logiche di business variabili (pagamenti, notifiche, storage)
  • Factory Functions: costruzione sicura con validazione integrata, convenzione idiomatica NewXxx con restituzione (*T, error)
  • Observer con Channels: pubblicazione/sottoscrizione di eventi sfruttando le primitive di concorrenza native di Go
  • Middleware Pattern: composizione di handler HTTP tramite funzioni wrapper, fondamento dello sviluppo web in Go
  • Struct Embedding: riutilizzo del codice tramite composizione anziche ereditarieta, promuovendo relazioni "has-a" pulite

Padroneggiare questi golang interview patterns non significa solo superare un colloquio tecnico: significa scrivere codice Go che risulta naturale, manutenibile e performante in ambienti di produzione. La chiave risiede nel comprendere non solo il "come", ma il "perche" dietro ogni pattern, e nel saper riconoscere quando un pattern semplifica genuinamente il codice e quando invece aggiunge complessita non necessaria.

Inizia a praticare!

Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.

Tag

#go
#design-patterns
#interview
#best-practices

Condividi

Articoli correlati