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, andfmt.Stringerhave 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 witherrors.Is/errors.As. - Singletons are rarely needed; construct one value in
mainand 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