Skip to content
AgileLogics
8 min read

Idiomatic Design Patterns in Go

Prerequisites: Project layout & architecture

Many classic design patterns exist to work around missing language features. Go's first-class functions, implicit interfaces, and embedding make most of them unnecessary. A few patterns, though, show up in nearly every Go codebase.

Constructor functions

Go has no constructors. By convention a function named NewX returns a ready-to-use value, applying defaults and validating input:

func NewCache(capacity int) (*Cache, error) {
    if capacity <= 0 {
        return nil, fmt.Errorf("cache: capacity must be positive, got %d", capacity)
    }
    return &Cache{capacity: capacity, items: make(map[string]string)}, nil
}

Where possible, also make the zero value useful, as sync.Mutex, bytes.Buffer, and strings.Builder do — then callers don't need a constructor at all.

Functional options

When a constructor has several optional settings, a long parameter list is unreadable and a config struct forces callers to know every default. Functional options pass each setting as a function:

package main

import (
    "fmt"
    "time"
)

type Server struct {
    addr    string
    timeout time.Duration
    maxConn int
}

type Option func(*Server)

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

func WithMaxConn(n int) Option {
    return func(s *Server) { s.maxConn = n }
}

func NewServer(addr string, opts ...Option) *Server {
    s := &Server{addr: addr, timeout: 30 * time.Second, maxConn: 100}
    for _, opt := range opts {
        opt(s)
    }
    return s
}

func main() {
    a := NewServer(":8080")
    b := NewServer(":9090", WithTimeout(5*time.Second), WithMaxConn(10))
    fmt.Println(a.addr, a.timeout, a.maxConn)
    fmt.Println(b.addr, b.timeout, b.maxConn)
}

Expected output:

:8080 30s 100
:9090 5s 10

Required arguments stay positional; options are self-documenting and new ones can be added without breaking callers. For internal code with only a couple of settings, a plain Config struct is simpler and perfectly fine — don't reach for options by default.

Dependency injection via interfaces

"Dependency injection" in Go just means passing dependencies in, usually through the constructor, typed as small interfaces:

package main

import (
    "fmt"
    "time"
)

type Clock interface {
    Now() time.Time
}

type realClock struct{}

func (realClock) Now() time.Time { return time.Now() }

type fixedClock struct{ t time.Time }

func (c fixedClock) Now() time.Time { return c.t }

type Greeter struct {
    clock Clock
}

func NewGreeter(c Clock) *Greeter { return &Greeter{clock: c} }

func (g *Greeter) Greeting() string {
    if g.clock.Now().Hour() < 12 {
        return "Good morning"
    }
    return "Good afternoon"
}

func main() {
    morning := time.Date(2024, 1, 1, 9, 0, 0, 0, time.UTC)
    g := NewGreeter(fixedClock{morning})
    fmt.Println(g.Greeting())

    _ = NewGreeter(realClock{}) // production wiring
}

Expected output:

Good morning

Time, randomness, the filesystem, and network services are the usual candidates — anything that makes a test slow or non-deterministic.

Middleware chains

Middleware (from the REST API lesson) composes naturally. A small helper applies a list in a readable order:

type Middleware func(http.Handler) http.Handler

// Chain applies middleware so the first in the list is the outermost.
func Chain(h http.Handler, mws ...Middleware) http.Handler {
    for i := len(mws) - 1; i >= 0; i-- {
        h = mws[i](h)
    }
    return h
}

// handler := Chain(mux, recoverer, requestID, logging)

The same decorator shape works outside HTTP too — wrapping an interface to add logging, metrics, or retries:

type loggingStore struct {
    next   Store
    logger *slog.Logger
}

func (s loggingStore) Get(ctx context.Context, id string) (Item, error) {
    item, err := s.next.Get(ctx, id)
    s.logger.Info("store.get", "id", id, "err", err)
    return item, err
}

Other idioms worth knowing

  • Embedding for composition — embed a type to reuse its methods, rather than building inheritance hierarchies.
  • Small interfaces — io.Reader, io.Writer, and fmt.Stringer have one method each, which is why so much code can interoperate.
  • Errors as values — sentinel errors (var ErrNotFound = errors.New(...)) and custom error types, checked with errors.Is / errors.As.
  • Singletons are rarely needed; construct one value in main and pass it down.

Common mistakes

  • Porting Java-style factories, abstract base classes, and builders. A function and a struct literal usually suffice.
  • Functional options for a type with one setting.
  • Options that can fail silently. If validation is needed, have options return an error or validate once at the end of NewX.
  • Interfaces declared next to their only implementation "just in case".

Practice

Add a WithRetries(n int) option to the Server above (default 3). Make NewServer return an error if maxConn ends up less than 1 or retries is negative.

Show solution
package main

import (
    "errors"
    "fmt"
    "time"
)

type Server struct {
    addr    string
    timeout time.Duration
    maxConn int
    retries int
}

type Option func(*Server)

func WithMaxConn(n int) Option { return func(s *Server) { s.maxConn = n } }
func WithRetries(n int) Option { return func(s *Server) { s.retries = n } }
func WithTimeout(d time.Duration) Option {
    return func(s *Server) { s.timeout = d }
}

func NewServer(addr string, opts ...Option) (*Server, error) {
    s := &Server{addr: addr, timeout: 30 * time.Second, maxConn: 100, retries: 3}
    for _, opt := range opts {
        opt(s)
    }
    if s.maxConn < 1 {
        return nil, errors.New("maxConn must be at least 1")
    }
    if s.retries < 0 {
        return nil, errors.New("retries must not be negative")
    }
    return s, nil
}

func main() {
    s, err := NewServer(":8080", WithRetries(5))
    fmt.Println(s.retries, err)

    _, err = NewServer(":8080", WithMaxConn(0))
    fmt.Println(err)
}

Expected output:

5 <nil>
maxConn must be at least 1