Design Patterns
Patterns are shared vocabulary for proven solutions — "it's a Strategy" says more than three sentences. But a pattern is a means, not a goal: reach for one when it resolves a real tension (change, coupling, duplication). Adding patterns speculatively is its own smell — indirection you pay for and may never use. Most of these are just composition over inheritance and SOLID applied to a recurring problem. In Go especially, the idiomatic form is often lighter than the classic OOP version (a function value, an interface, middleware).
Strategy — swap interchangeable algorithms at runtime
What: define a family of algorithms behind one interface and pick the implementation at runtime. When: several ways to do one thing (pricing, sorting, payment method, retry policy) and you want to avoid a growing if/else/switch. Trade-off: more types; only worth it when the variants actually change independently. The purest expression of OCP + composition.
type Pricer interface{ Price(o Order) int }
type Standard struct{}
func (Standard) Price(o Order) int { return o.Base }
type Member struct{}
func (Member) Price(o Order) int { return o.Base * 9 / 10 }
func Checkout(o Order, p Pricer) int { return p.Price(o) } // inject the strategyIn Go a single-method strategy is often just a func value — no interface needed.
Factory — centralize creation, return an interface
What: a function/type that builds objects and hands back an interface, hiding the concrete type. When: construction depends on config/input, or you want callers to depend on an abstraction (DIP) rather than a concrete constructor. Trade-off: indirection — if a plain struct literal or New… constructor is enough, don't add a factory.
func NewStore(kind string) (Store, error) {
switch kind {
case "postgres": return newPostgres()
case "memory": return newMemory(), nil
default: return nil, fmt.Errorf("unknown store %q", kind)
}
}Singleton — one shared instance (use with care)
What: guarantee a single instance with global access. When: a genuinely shared, usually stateless resource — config, logger, a connection pool. Trade-off / caution: often an anti-pattern — a global hides dependencies and makes code hard to test in isolation. Prefer creating one instance and injecting it. If you must, scope it and make init safe:
var (
once sync.Once
pool *Pool
)
func DB() *Pool { once.Do(func() { pool = newPool() }); return pool }Senior take: "pass the dependency" beats "reach for the global" almost every time.
Observer — notify subscribers of events (pub/sub)
What: a subject maintains a list of observers and notifies them when something changes — one-to-many, decoupled. When: producers shouldn't know their consumers (domain events, UI updates, cache invalidation). Trade-off: flow becomes harder to follow ("event soup"), ordering isn't guaranteed, and forgotten unsubscribes leak. In Go, a channel often is the observer. At system scale this becomes a message queue — see Queues vs Streams.
Decorator — wrap to add behavior, same interface
What: wrap an object in another that implements the same interface, adding behavior before/after delegating. When: layer cross-cutting concerns — logging, caching, retries, auth, metrics — without touching the core type; stack them in any order. Trade-off: many small wrappers, and order matters. This is the cleanest OCP win and Go's middleware is exactly this:
func WithLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Println(r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}Adapter — make an incompatible interface fit
What: wrap a type so it satisfies the interface your code expects. When: integrating a third-party SDK or legacy code — keep the foreign type at the edge behind your interface (DIP), so the rest of the code (and your tests) never depend on it directly. Trade-off: an extra thin layer, but it's what keeps a vendor swap from rippling through the codebase.
type Mailer interface{ Send(to, body string) error }
type sendgridAdapter struct{ c *sendgrid.Client } // wrap the vendor
func (a sendgridAdapter) Send(to, body string) error { /* translate to SDK call */ return nil }Builder — construct complex objects step by step
What: assemble an object incrementally instead of a constructor with many parameters. When: lots of optional fields and you want readable, safe construction (no telescoping constructors or ambiguous positional args). Trade-off: boilerplate. In Go the idiomatic form is functional options, not a classic builder:
func NewServer(opts ...Option) *Server {
s := &Server{port: 8080} // defaults
for _, o := range opts { o(s) }
return s
}
// NewServer(WithPort(9090), WithTLS(cert))Common follow-ups
- When is reaching for a pattern over-engineering — what's the signal you've added one too early?
- How do Go idioms (func values, interfaces, middleware, functional options) replace the classic OOP forms?
- Which patterns are really the same idea (Strategy / State / Command all swap behavior behind an interface)?
- How does the Decorator pattern relate to middleware chains and the Open/Closed Principle?