Skip to main content
Go · lesson 22 of 25
Go advanced 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.

Frequently Asked Questions

What is the functional options pattern in Go?
A constructor accepts a variadic list of option functions, such as NewServer(WithPort(9090)), each of which modifies the value being built. Callers pass only the options they care about, defaults live in one place, and new options can be added later without breaking existing calls.
Does Go need a dependency injection framework?
Usually not. Constructor injection, where each component receives its dependencies as interface-typed arguments to its New function and main wires them together, is idiomatic and easy to test. Code generators such as Google's Wire exist for very large dependency graphs.
Is the Singleton pattern used in Go?
Rarely in its classic form. A package-level value initialised with sync.Once or sync.OnceValue covers the need for lazy shared state, but global singletons make tests hard to isolate, so most Go code passes shared dependencies explicitly instead.
Why should Go interfaces be defined by the consumer?
A small interface declared next to the code that uses it, listing only the methods it calls, lets any type with those methods be passed in, including test fakes. Because satisfaction is implicit, the concrete type's package never needs to know the interface exists.
How does middleware work in Go's net/http?
Middleware is a function that takes an http.Handler and returns a new http.Handler that does some work before or after calling the original. Because the input and output types match, middleware can be stacked, and the outermost one runs first on the way in.