Design patterns are not a checklist. In Go, applying Java-style patterns wholesale is the fastest path to code that looks foreign to every Go developer who reads it. The patterns below are presented in their idiomatic Go form, which is often significantly simpler than the textbook version.
Singleton with sync.Once#
The canonical Go singleton uses sync.Once, which guarantees the initializer runs exactly once regardless of how many goroutines call GetInstance concurrently. There is no lock-check-lock dance, no volatile keyword, no double-checked locking: the runtime handles all of that internally.
package config
import "sync"
type AppConfig struct {
DatabaseURL string
Port int
Debug bool
}
var (
instance *AppConfig
once sync.Once
)
func GetConfig() *AppConfig {
once.Do(func() {
instance = &AppConfig{
DatabaseURL: "postgres://localhost/mydb",
Port: 8080,
Debug: false,
}
})
return instance
}sync.Once is safe for concurrent use. Once Do has run, subsequent calls return immediately without acquiring any lock. This makes it essentially free on the hot path after initialization.
The pattern is appropriate for truly global state: configuration loaded once at startup, a shared database connection pool, or a metrics registry. Avoid it for anything that needs to be reset between tests. A singleton that cannot be replaced makes unit testing painful.
Functional Options: the Go-Idiomatic Builder#
The classical Builder pattern (interface with SetX methods returning the builder) exists in Go but the idiomatic approach is the functional options pattern, introduced by Rob Pike. Instead of a builder object, you pass variadic option functions that mutate a configuration struct before the real object is constructed.
package server
import (
"net/http"
"time"
)
type Server struct {
addr string
readTimeout time.Duration
writeTimeout time.Duration
maxHeaderBytes int
handler http.Handler
}
// ServerOption is a function that configures a Server.
type ServerOption func(*Server)
func WithAddr(addr string) ServerOption {
return func(s *Server) {
s.addr = addr
}
}
func WithReadTimeout(d time.Duration) ServerOption {
return func(s *Server) {
s.readTimeout = d
}
}
func WithWriteTimeout(d time.Duration) ServerOption {
return func(s *Server) {
s.writeTimeout = d
}
}
func WithMaxHeaderBytes(n int) ServerOption {
return func(s *Server) {
s.maxHeaderBytes = n
}
}
func NewServer(handler http.Handler, opts ...ServerOption) *Server {
s := &Server{
addr: ":8080",
readTimeout: 5 * time.Second,
writeTimeout: 10 * time.Second,
maxHeaderBytes: 1 << 20,
handler: handler,
}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *Server) ListenAndServe() error {
srv := &http.Server{
Addr: s.addr,
Handler: s.handler,
ReadTimeout: s.readTimeout,
WriteTimeout: s.writeTimeout,
MaxHeaderBytes: s.maxHeaderBytes,
}
return srv.ListenAndServe()
}Call site:
srv := server.NewServer(
myHandler,
server.WithAddr(":9090"),
server.WithReadTimeout(30 * time.Second),
)This is better than a classical Builder for Go because the defaults live in one place (NewServer), option functions are individually testable and composable, and callers only specify what they want to override. The standard library itself uses this pattern extensively (grpc.Dial, sql.Open wrappers, etc.).
Factory for Interface-Based Construction#
Use a factory when you need to produce different concrete types behind a shared interface and the caller should not need to import the concrete packages. A realistic production example: a notification sender with Email, SMS, and Slack backends.
package notify
import "fmt"
// Sender is the interface every notification backend must implement.
type Sender interface {
Send(to, subject, body string) error
}
// EmailSender sends notifications via SMTP.
type EmailSender struct{ SMTPHost string }
func (e *EmailSender) Send(to, subject, body string) error {
fmt.Printf("[EMAIL] to=%s subject=%s\n", to, subject)
return nil
}
// SMSSender sends notifications via an SMS gateway.
type SMSSender struct{ APIKey string }
func (s *SMSSender) Send(to, subject, body string) error {
fmt.Printf("[SMS] to=%s body=%s\n", to, body)
return nil
}
// SlackSender posts to a Slack channel.
type SlackSender struct{ WebhookURL string }
func (sl *SlackSender) Send(to, subject, body string) error {
fmt.Printf("[SLACK] channel=%s body=%s\n", to, body)
return nil
}
// NewSender is the factory function. config is a map of provider-specific values.
func NewSender(provider string, config map[string]string) (Sender, error) {
switch provider {
case "email":
return &EmailSender{SMTPHost: config["smtp_host"]}, nil
case "sms":
return &SMSSender{APIKey: config["api_key"]}, nil
case "slack":
return &SlackSender{WebhookURL: config["webhook_url"]}, nil
default:
return nil, fmt.Errorf("unknown notification provider: %s", provider)
}
}Return (Sender, error) from the factory, never just Sender. Configuration problems (missing API keys, invalid addresses) should surface at construction time, not when the first message is sent at 3 AM.
Observer with Channels: Correct Concurrent Implementation#
The naive Observer implementation using unbuffered channels will deadlock or panic. Calling Notify from the same goroutine as the channel receivers blocks forever on an unbuffered channel. Using a WaitGroup and buffered channels solves both problems: the sender never blocks, and cleanup waits for all goroutines to finish processing.
package events
import (
"fmt"
"sync"
)
type Observable struct {
observers []chan int
}
func (o *Observable) AddObserver(c chan int) {
o.observers = append(o.observers, c)
}
// Notify sends n to all observers without blocking.
// Each observer channel must be buffered to at least the expected burst size.
func (o *Observable) Notify(n int) {
for _, c := range o.observers {
c <- n
}
}
// CloseAll signals all observer goroutines to stop by closing their channels.
func (o *Observable) CloseAll() {
for _, c := range o.observers {
close(c)
}
}
func Example() {
observable := &Observable{}
// Buffered channels prevent Notify from blocking.
observer1 := make(chan int, 10)
observer2 := make(chan int, 10)
observable.AddObserver(observer1)
observable.AddObserver(observer2)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for n := range observer1 {
fmt.Println("Observer 1 received:", n)
}
}()
go func() {
defer wg.Done()
for n := range observer2 {
fmt.Println("Observer 2 received:", n)
}
}()
observable.Notify(1)
observable.Notify(2)
observable.Notify(3)
// Close channels to signal goroutines to exit, then wait for them.
observable.CloseAll()
wg.Wait()
}The WaitGroup guarantees that all events are processed before the program exits. Without it, goroutines may be killed mid-processing by main returning.
Strategy Pattern with First-Class Functions#
In Java, Strategy requires an interface with a single method plus a family of implementing classes. In Go, a function type is already a strategy. No interface, no struct, no method set.
package sorter
// SortStrategy is a function type. Any function with this signature is a strategy.
type SortStrategy func(data []int)
// Sorter delegates sorting to whichever strategy is configured.
type Sorter struct {
strategy SortStrategy
}
func NewSorter(s SortStrategy) *Sorter {
return &Sorter{strategy: s}
}
func (s *Sorter) Sort(data []int) {
s.strategy(data)
}
// Concrete strategies are plain functions.
func BubbleSort(data []int) {
n := len(data)
for i := 0; i < n-1; i++ {
for j := 0; j < n-1-i; j++ {
if data[j] > data[j+1] {
data[j], data[j+1] = data[j+1], data[j]
}
}
}
}
func QuickSort(data []int) {
if len(data) <= 1 {
return
}
pivot := data[len(data)/2]
var left, right, equal []int
for _, v := range data {
switch {
case v < pivot:
left = append(left, v)
case v > pivot:
right = append(right, v)
default:
equal = append(equal, v)
}
}
QuickSort(left)
QuickSort(right)
copy(data, append(append(left, equal...), right...))
}Swapping strategies at runtime:
s := sorter.NewSorter(sorter.QuickSort)
s.Sort(data)
// Switch strategy based on input size.
if len(data) < 20 {
s = sorter.NewSorter(sorter.BubbleSort)
}Decorator Pattern with HTTP Middleware#
In Go, HTTP middleware is the canonical Decorator implementation. An http.Handler is wrapped by a function that adds cross-cutting concerns (logging, authentication, rate limiting) without modifying the inner handler.
package middleware
import (
"log"
"net/http"
"time"
)
// Logger wraps an http.Handler to log request duration.
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}
// RequireAuth wraps an http.Handler to enforce bearer token authentication.
func RequireAuth(token string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer "+token {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// RateLimit wraps an http.Handler with a token bucket (simplified example).
func RateLimit(rps int, next http.Handler) http.Handler {
ticker := time.NewTicker(time.Second / time.Duration(rps))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-ticker.C:
next.ServeHTTP(w, r)
default:
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
}
})
}Chaining decorators at the call site:
var handler http.Handler = myAPIHandler
handler = middleware.Logger(handler)
handler = middleware.RequireAuth(os.Getenv("API_TOKEN"), handler)
handler = middleware.RateLimit(100, handler)
http.ListenAndServe(":8080", handler)Each middleware layer is independently testable by passing a simple http.HandlerFunc as next.
When NOT to Use These Patterns#
Go’s composition model often makes patterns unnecessary or even counterproductive.
| Situation | Pattern you might reach for | What to do in Go instead |
|---|---|---|
| Shared global state | Singleton | Pass dependencies as arguments; use sync.Once only when global init is truly required |
| Configuring a struct | Builder | Functional options or a plain config struct with sensible zero values |
| Polymorphic dispatch | Strategy interface | Function type or a plain interface with one method |
| Adding behavior to a type | Decorator | Embed the type in a new struct and add/override methods |
| Observing state changes | Observer with channels | Use context.Context for cancellation; use sync.Cond for simple synchronization |
Simulating Java-style inheritance in Go (embedding structs to “extend” them, then overriding via interface methods) produces code that is difficult to follow and breaks the Go convention that composition is explicit. If you find yourself asking “how do I make a struct inherit from another struct,” stop and reconsider the data model.
If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.