Go · lesson 22 of 25
Also part of: Low-Level Design
Design Patterns in Go
Idiomatic Go patterns run for real: functional options, HTTP middleware chains, a repository interface, constructor injection with a fake, and events.
By SkillByExample · Updated
Go’s take on design patterns is mostly about functions and small interfaces rather than class hierarchies. The patterns below appear in nearly every production Go codebase, each as a complete program.
Functional options
A constructor with sensible defaults, configured by passing only the options you need.
package main
import (
"fmt"
"time"
)
type Server struct {
host string
port int
timeout time.Duration
}
type Option func(*Server)
func WithPort(p int) Option { return func(s *Server) { s.port = p } }
func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } }
func NewServer(host string, opts ...Option) *Server {
s := &Server{host: host, port: 8080, timeout: 30 * time.Second} // defaults
for _, opt := range opts {
opt(s)
}
return s
}
func main() {
for _, s := range []*Server{
NewServer("localhost"),
NewServer("0.0.0.0", WithPort(9090), WithTimeout(5*time.Second)),
} {
fmt.Printf("%s:%d timeout=%v\n", s.host, s.port, s.timeout)
}
}
$ go run options.go
localhost:8080 timeout=30s
0.0.0.0:9090 timeout=5s
The defaults are set once, and each option overrides one field. Adding WithTLS later doesn’t change NewServer’s signature, so no caller breaks — the main advantage over a long parameter list. For many related settings, a plain Config struct is the simpler alternative; options shine when most callers want the defaults.
Middleware
A middleware wraps an http.Handler in another http.Handler, so several can be stacked around the real handler.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
type Middleware func(http.Handler) http.Handler
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println("-> log:", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
fmt.Println("<- log: done")
})
}
func RequireKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-API-Key") != "secret" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return // the inner handler never runs
}
next.ServeHTTP(w, r)
})
}
func Chain(h http.Handler, ms ...Middleware) http.Handler {
for i := len(ms) - 1; i >= 0; i-- {
h = ms[i](h) // wrap in reverse so ms[0] is outermost
}
return h
}
func main() {
hello := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println(" handler runs")
fmt.Fprint(w, "hello")
})
app := Chain(hello, Logging, RequireKey)
for _, key := range []string{"secret", "wrong"} {
req := httptest.NewRequest("GET", "/hello", nil)
req.Header.Set("X-API-Key", key)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
fmt.Printf(" status %d body %q\n", rec.Code, rec.Body.String())
}
}
$ go run middleware.go
-> log: GET /hello
handler runs
<- log: done
status 200 body "hello"
-> log: GET /hello
<- log: done
status 401 body "unauthorized\n"
Chain(hello, Logging, RequireKey) makes Logging the outermost layer, so it runs first on the way in and last on the way out — even when RequireKey rejects the request and the handler never runs. Order matters: put recovery and logging outside, authentication inside. The program drives the stack with httptest instead of opening a port, the same way you would test it.
Repository behind an interface
Business logic depends on a small interface, not on a database package. The interface is declared where it’s used.
package main
import (
"errors"
"fmt"
"sync"
)
type User struct {
ID int
Name string
}
var ErrNotFound = errors.New("not found")
type UserStore interface {
Get(id int) (User, error)
Save(u User) error
}
type memStore struct {
mu sync.RWMutex
users map[int]User
}
func newMemStore() *memStore { return &memStore{users: map[int]User{}} }
func (m *memStore) Get(id int) (User, error) {
m.mu.RLock()
defer m.mu.RUnlock()
u, ok := m.users[id]
if !ok {
return User{}, fmt.Errorf("user %d: %w", id, ErrNotFound)
}
return u, nil
}
func (m *memStore) Save(u User) error {
m.mu.Lock()
defer m.mu.Unlock()
m.users[u.ID] = u
return nil
}
func rename(s UserStore, id int, name string) error {
u, err := s.Get(id)
if err != nil {
return fmt.Errorf("rename: %w", err)
}
u.Name = name
return s.Save(u)
}
func main() {
var store UserStore = newMemStore()
store.Save(User{1, "ann"})
fmt.Println(rename(store, 1, "Ann"))
fmt.Println(store.Get(1))
err := rename(store, 7, "Bo")
fmt.Println(err, errors.Is(err, ErrNotFound))
}
$ go run repo.go
<nil>
{1 Ann} <nil>
rename: user 7: not found true
rename only knows UserStore; swapping the in-memory store for a PostgreSQL one changes main and nothing else. Wrapping a sentinel (ErrNotFound) lets callers react to “missing” without depending on the storage package’s own error types — sql.ErrNoRows would be translated at the repository boundary.
Constructor injection and a test fake
A service receives its dependencies through its constructor. In tests, you pass a fake.
package main
import (
"fmt"
"strings"
)
type Mailer interface {
Send(to, body string) error
}
type Signup struct {
mail Mailer
}
func NewSignup(m Mailer) *Signup { return &Signup{mail: m} }
func (s *Signup) Register(email string) error {
if !strings.Contains(email, "@") {
return fmt.Errorf("invalid email %q", email)
}
return s.mail.Send(email, "welcome!")
}
// fakeMailer records messages instead of sending them.
type fakeMailer struct{ sent []string }
func (f *fakeMailer) Send(to, body string) error {
f.sent = append(f.sent, to+": "+body)
return nil
}
func main() {
fake := &fakeMailer{}
svc := NewSignup(fake)
fmt.Println(svc.Register("ann@example.com"))
fmt.Println(svc.Register("not-an-email"))
fmt.Println(fake.sent)
}
$ go run inject.go
<nil>
invalid email "not-an-email"
[ann@example.com: welcome!]
The fake is a dozen lines and needs no mocking library: implicit interface satisfaction means any type with a Send method will do. The service never constructs its own dependencies, so main is the one place where real implementations are wired together — at that size, no DI framework is needed.
A lazily created shared value
When something must exist once per process, sync.OnceValue creates it on first use, safely across goroutines.
package main
import (
"fmt"
"sync"
)
type Registry struct{ names []string }
var created int
var defaultRegistry = sync.OnceValue(func() *Registry {
created++
return &Registry{}
})
func main() {
var wg sync.WaitGroup
for range 10 {
wg.Add(1)
go func() { defer wg.Done(); _ = defaultRegistry() }()
}
wg.Wait()
r1, r2 := defaultRegistry(), defaultRegistry()
fmt.Println(created, r1 == r2)
}
$ go run -race once.go
1 true
Ten goroutines and two more calls, one construction, and the same pointer every time. Prefer passing such a value explicitly where you can: a global is convenient, but every test then shares it.
Observer: a small event bus
Subscribers register handlers by topic; publishing calls each handler.
package main
import (
"fmt"
"sync"
)
type Bus struct {
mu sync.RWMutex
subs map[string][]func(string)
}
func NewBus() *Bus { return &Bus{subs: map[string][]func(string){}} }
func (b *Bus) Subscribe(topic string, fn func(string)) {
b.mu.Lock()
defer b.mu.Unlock()
b.subs[topic] = append(b.subs[topic], fn)
}
func (b *Bus) Publish(topic, msg string) int {
b.mu.RLock()
handlers := b.subs[topic]
b.mu.RUnlock()
for _, fn := range handlers {
fn(msg)
}
return len(handlers)
}
func main() {
bus := NewBus()
bus.Subscribe("order.created", func(m string) { fmt.Println("email:", m) })
bus.Subscribe("order.created", func(m string) { fmt.Println("audit:", m) })
fmt.Println(bus.Publish("order.created", "order #42"), "handlers")
fmt.Println(bus.Publish("order.cancelled", "order #7"), "handlers")
}
$ go run bus.go
email: order #42
audit: order #42
2 handlers
0 handlers
Handlers run synchronously, in subscription order, so the output is predictable and a slow handler slows Publish. Releasing the lock before calling handlers lets a handler subscribe or publish without deadlocking. Running handlers in goroutines would decouple them, at the price of ordering and error reporting.
Practice
1. Add a WithHost(h string) Option to options.go and make NewServer take no positional arguments at all.
Solution
package main
import "fmt"
type Server struct {
host string
port int
}
type Option func(*Server)
func WithHost(h string) Option { return func(s *Server) { s.host = h } }
func WithPort(p int) Option { return func(s *Server) { s.port = p } }
func NewServer(opts ...Option) *Server {
s := &Server{host: "localhost", port: 8080}
for _, o := range opts {
o(s)
}
return s
}
func main() {
fmt.Printf("%+v %+v\n", *NewServer(), *NewServer(WithHost("0.0.0.0"), WithPort(80)))
}
$ go run p1.go
{host:localhost port:8080} {host:0.0.0.0 port:80}
Options are applied in order, so if two set the same field, the last one wins.
2. Write a Recover middleware that turns a panic in the handler into a 500 response, and test it with httptest.
Solution
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
fmt.Println("recovered:", v)
}
}()
next.ServeHTTP(w, r)
})
}
func main() {
boom := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var m map[string]int
m["x"] = 1
})
rec := httptest.NewRecorder()
Recover(boom).ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
fmt.Println(rec.Code, rec.Body.String())
}
$ go run p2.go
recovered: assignment to entry in nil map
500 internal error
net/http’s server already recovers handler panics to keep running, but it just drops the connection; this middleware sends a proper 500 and lets you log it.
3. Make fakeMailer from inject.go return an error for addresses ending in @blocked.test, and show Register passing it through.
Solution
package main
import (
"errors"
"fmt"
"strings"
)
type Mailer interface{ Send(to, body string) error }
type fakeMailer struct{}
func (fakeMailer) Send(to, body string) error {
if strings.HasSuffix(to, "@blocked.test") {
return errors.New("mailbox rejected")
}
return nil
}
func register(m Mailer, email string) error {
if err := m.Send(email, "welcome!"); err != nil {
return fmt.Errorf("register %s: %w", email, err)
}
return nil
}
func main() {
fmt.Println(register(fakeMailer{}, "ann@example.com"))
fmt.Println(register(fakeMailer{}, "bo@blocked.test"))
}
$ go run p3.go
<nil>
register bo@blocked.test: mailbox rejected
Fakes make error paths easy to test — getting a real mail server to reject a message on demand is much harder.
4. Add an Unsubscribe-free alternative to the bus: have Subscribe return a function that removes that handler.
Solution
package main
import (
"fmt"
"sync"
)
type Bus struct {
mu sync.Mutex
next int
subs map[int]func(string)
}
func (b *Bus) Subscribe(fn func(string)) (cancel func()) {
b.mu.Lock()
defer b.mu.Unlock()
if b.subs == nil {
b.subs = map[int]func(string){}
}
id := b.next
b.next++
b.subs[id] = fn
return func() { b.mu.Lock(); delete(b.subs, id); b.mu.Unlock() }
}
func (b *Bus) Publish(msg string) int {
b.mu.Lock()
defer b.mu.Unlock()
for _, fn := range b.subs {
fn(msg)
}
return len(b.subs)
}
func main() {
var b Bus
cancel := b.Subscribe(func(m string) { fmt.Println("A got", m) })
fmt.Println(b.Publish("first"))
cancel()
fmt.Println(b.Publish("second"))
}
$ go run p4.go
A got first
1
0
Returning a cancel function avoids comparing function values (which Go doesn’t allow) to find the handler to remove. With more than one subscriber, ranging over the map calls handlers in random order — keep a slice if order matters.