Skip to main content

Design Patterns in Go: Idiomatic Implementations

·9 mins
Table of Contents
Go approaches design patterns differently from Java or C++. Because Go uses composition instead of inheritance, and because functions are first-class values, many patterns that require elaborate class hierarchies in OOP languages collapse into a few idiomatic Go constructs. This post shows the correct, production-ready implementations and, just as importantly, tells you when not to reach for a pattern at all.

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.

singleton.go
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
}
Note

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.

server.go
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:

main.go
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.

notify/factory.go
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)
    }
}
Tip

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.

observable.go
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.

sorter.go
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:

main.go
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.

middleware.go
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:

main.go
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.

SituationPattern you might reach forWhat to do in Go instead
Shared global stateSingletonPass dependencies as arguments; use sync.Once only when global init is truly required
Configuring a structBuilderFunctional options or a plain config struct with sensible zero values
Polymorphic dispatchStrategy interfaceFunction type or a plain interface with one method
Adding behavior to a typeDecoratorEmbed the type in a new struct and add/override methods
Observing state changesObserver with channelsUse context.Context for cancellation; use sync.Cond for simple synchronization
Warning

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.

**Over-engineering with the Builder pattern.** A struct with three fields does not need a Builder. Use a struct literal or functional options only when the number of optional parameters is large enough that a long argument list becomes unreadable (roughly five or more optional fields). **Closing channels before goroutines finish.** The original Observer example in many Go tutorials closes channels synchronously right after `Notify`. If the channel is unbuffered, `Notify` blocks until a receiver reads, so the close happens before the goroutine can loop again. Always use a `WaitGroup` to wait for all receivers to drain. **Using `interface{}` (or `any`) in return types.** A `Build() interface{}` method forces the caller to type-assert on every call, pushing errors to runtime rather than compile time. Return the concrete type or a specific interface. **Reproducing the AbstractFactory hierarchy.** A factory function that returns an interface is nearly always sufficient. Multiple factory structs with factory methods is indirection without benefit. **Singleton for everything injectable.** Singletons make tests order-dependent and hard to parallelize. Prefer dependency injection via function parameters or a lightweight container.

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.

Related