Skip to main content
Go advanced Lesson 22 of 25

Design Patterns in Go

Implement functional options, middleware chains, repository pattern, and wire-based dependency injection in idiomatic Go.

Functional Options Pattern

The classic way to configure a struct is a long constructor parameter list — but that becomes unreadable past three or four parameters, and adding a new option is a breaking change for all callers. The functional options pattern solves this elegantly: each option is a small function that applies one setting to a config struct. Callers pass only the options they care about. Adding new options is non-breaking, defaults are applied centrally, and the call site is self-documenting.

type Server struct {
    host         string
    port         int
    timeout      time.Duration
    maxConns     int
    tlsCert      string
    tlsKey       string
    readTimeout  time.Duration
    writeTimeout time.Duration
}

// Option is a function that modifies a Server — the key type for this pattern
type Option func(*Server)

// Each WithXxx function returns an Option that sets one field
func WithHost(host string) Option {
    return func(s *Server) { s.host = host }
}

func WithPort(port int) Option {
    return func(s *Server) { s.port = port }
}

// One option can set multiple related fields atomically
func WithTimeout(d time.Duration) Option {
    return func(s *Server) {
        s.timeout = d
        s.readTimeout = d
        s.writeTimeout = d
    }
}

func WithMaxConns(n int) Option {
    return func(s *Server) { s.maxConns = n }
}

func WithTLS(cert, key string) Option {
    return func(s *Server) {
        s.tlsCert = cert
        s.tlsKey = key
    }
}

// NewServer applies sensible defaults, then applies each caller-provided option in order
func NewServer(opts ...Option) *Server {
    s := &Server{
        host:         "0.0.0.0",
        port:         8080,
        timeout:      30 * time.Second,
        maxConns:     1000,
        readTimeout:  15 * time.Second,
        writeTimeout: 15 * time.Second,
    }
    for _, opt := range opts {
        opt(s) // each option overwrites only the fields it cares about
    }
    return s
}

// Usage — reads clearly, order doesn't matter, unused options take their defaults
srv := NewServer(
    WithPort(9090),
    WithTimeout(60*time.Second),
    WithTLS("/etc/tls/cert.pem", "/etc/tls/key.pem"),
)

Middleware Pattern

HTTP middleware solves a cross-cutting concerns problem: logging, authentication, rate limiting, and CORS all need to apply to many handlers without duplicating code in each one. In Go, middleware is just a function that wraps an http.Handler — it runs code before and/or after the inner handler, and can short-circuit the chain by not calling next.ServeHTTP. Chaining middlewares builds a pipeline that processes every request in a consistent, predictable order.

type Middleware func(http.Handler) http.Handler

// loggingMiddleware records the method, path, status, and duration of every request
func WithLogging(logger *slog.Logger) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()
            rw := &responseWriter{ResponseWriter: w, status: 200}
            next.ServeHTTP(rw, r) // call the actual handler
            logger.Info("request",
                "method", r.Method,
                "path", r.URL.Path,
                "status", rw.status,
                "duration", time.Since(start),
            )
        })
    }
}

// WithAuth rejects requests without a valid token before they reach the handler
func WithAuth(secret string) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            token := r.Header.Get("Authorization")
            if !validateToken(token, secret) {
                http.Error(w, "unauthorized", http.StatusUnauthorized)
                return // short-circuit — next.ServeHTTP is never called
            }
            next.ServeHTTP(w, r)
        })
    }
}

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

// Usage — request flows: logging → auth → rate limit → handler
mux := http.NewServeMux()
mux.HandleFunc("/api/users", handleUsers)

handler := Chain(mux,
    WithLogging(slog.Default()),
    WithAuth(os.Getenv("JWT_SECRET")),
    WithRateLimit(100),
)

http.ListenAndServe(":8080", handler)

Repository Pattern

The repository pattern separates data access from business logic by hiding the storage implementation behind an interface. Business logic depends only on the interface — it doesn’t know or care whether the backing store is PostgreSQL, SQLite, Redis, or an in-memory map. This separation has two major benefits: business logic is testable without a real database, and you can swap storage backends without touching business logic.

// Domain model — a plain struct with no database-specific fields
type User struct {
    ID        int
    Name      string
    Email     string
    CreatedAt time.Time
}

// UserRepository defines the data access contract — business logic depends on this interface
type UserRepository interface {
    FindByID(ctx context.Context, id int) (*User, error)
    FindByEmail(ctx context.Context, email string) (*User, error)
    Create(ctx context.Context, u *User) error
    Update(ctx context.Context, u *User) error
    Delete(ctx context.Context, id int) error
}

// pgUserRepo is the PostgreSQL implementation — unexported, accessed only via the interface
type pgUserRepo struct {
    db *sqlx.DB
}

func NewUserRepository(db *sqlx.DB) UserRepository {
    return &pgUserRepo{db: db} // return the interface, not the concrete type
}

func (r *pgUserRepo) FindByID(ctx context.Context, id int) (*User, error) {
    var u User
    err := r.db.GetContext(ctx, &u, "SELECT * FROM users WHERE id = $1", id)
    if errors.Is(err, sql.ErrNoRows) {
        return nil, ErrNotFound // translate DB-specific error to domain error
    }
    return &u, err
}

func (r *pgUserRepo) Create(ctx context.Context, u *User) error {
    return r.db.QueryRowContext(ctx,
        "INSERT INTO users(name, email) VALUES($1, $2) RETURNING id, created_at",
        u.Name, u.Email,
    ).Scan(&u.ID, &u.CreatedAt)
}

// inMemoryUserRepo is used in tests — no database needed
type inMemoryUserRepo struct {
    mu    sync.RWMutex
    users map[int]*User
    seq   int
}

func NewInMemoryUserRepo() UserRepository {
    return &inMemoryUserRepo{users: make(map[int]*User)}
}

func (r *inMemoryUserRepo) FindByID(ctx context.Context, id int) (*User, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    u, ok := r.users[id]
    if !ok {
        return nil, ErrNotFound
    }
    copy := *u // return a copy — caller can't accidentally mutate the stored value
    return &copy, nil
}

func (r *inMemoryUserRepo) Create(ctx context.Context, u *User) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.seq++
    u.ID = r.seq
    u.CreatedAt = time.Now()
    cp := *u
    r.users[u.ID] = &cp
    return nil
}

Service Layer

The service layer holds business logic — validation, orchestration, side effects. It sits between HTTP handlers (which deal with HTTP) and repositories (which deal with storage). Each service takes its dependencies as constructor arguments, which makes the dependency graph explicit and the service testable in isolation: swap the real repository for the in-memory one and swap the real emailer for a mock.

type UserService struct {
    repo    UserRepository // depends on the interface, not the concrete type
    emailer Emailer
    logger  *slog.Logger
}

func NewUserService(repo UserRepository, emailer Emailer, logger *slog.Logger) *UserService {
    return &UserService{repo: repo, emailer: emailer, logger: logger}
}

func (s *UserService) Register(ctx context.Context, name, email string) (*User, error) {
    // Business rule: email must be unique
    existing, err := s.repo.FindByEmail(ctx, email)
    if err != nil && !errors.Is(err, ErrNotFound) {
        return nil, fmt.Errorf("checking email: %w", err)
    }
    if existing != nil {
        return nil, fmt.Errorf("email already registered: %w", ErrConflict)
    }

    u := &User{Name: name, Email: email}
    if err := s.repo.Create(ctx, u); err != nil {
        return nil, fmt.Errorf("creating user: %w", err)
    }

    s.logger.Info("user registered", "id", u.ID, "email", u.Email)

    // Send welcome email in the background — don't block the registration response
    go func() {
        if err := s.emailer.Send(u.Email, "Welcome!", "Thanks for signing up!"); err != nil {
            s.logger.Error("welcome email failed", "error", err)
        }
    }()

    return u, nil
}

Constructor Injection (Simple DI)

Dependency injection doesn’t require a framework — in most Go services, manually wiring dependencies in main.go is the right approach. Each constructor takes its dependencies as arguments. main.go creates real implementations and passes them down the chain. Tests create fake implementations and pass those instead. The entire dependency graph is visible in one place with no magic.

// main.go — wire up the entire application here, nowhere else
func main() {
    db, err := sqlx.Connect("postgres", os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Fatal(err)
    }

    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    emailer := email.NewSMTPEmailer(os.Getenv("SMTP_HOST"))

    // Dependencies flow from bottom to top: DB → repo → service → handler
    userRepo    := repository.NewUserRepository(db)
    userSvc     := service.NewUserService(userRepo, emailer, logger)
    userHandler := handler.NewUserHandler(userSvc, logger)

    mux := http.NewServeMux()
    userHandler.Register(mux) // handler registers its own routes

    srv := NewServer(
        WithPort(8080),
        WithHandler(Chain(mux, WithLogging(logger))),
    )
    log.Fatal(srv.ListenAndServe())
}

sync.Once — Singleton

The singleton pattern in Go is almost always implemented with sync.Once. It guarantees that the initialisation function runs exactly once regardless of how many goroutines call GetDB simultaneously — the first caller runs the setup, all others block until it completes, then all receive the same instance. This is safe, simple, and avoids the double-checked locking bugs that plague singleton implementations in other languages.

type dbPool struct {
    db *sql.DB
}

var (
    pool     *dbPool
    poolOnce sync.Once
)

// GetDB returns the singleton database pool, initialising it on the first call
func GetDB() *sql.DB {
    poolOnce.Do(func() {
        // This block runs exactly once — even with concurrent callers
        db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
        if err != nil {
            panic(err) // unrecoverable — can't serve without a DB
        }
        db.SetMaxOpenConns(25)
        db.SetMaxIdleConns(5)
        pool = &dbPool{db: db}
    })
    return pool.db
}

Observer / Event Bus

The observer pattern decouples event producers from consumers — the component that registers a user doesn’t need to know about the welcome email, the CRM sync, or any future integrations. Subscribers register for named events; the bus delivers events asynchronously. This is the right tool when a single domain event needs to trigger multiple independent side effects, and when you want to add new side effects without modifying existing code.

type EventBus struct {
    mu          sync.RWMutex
    subscribers map[string][]func(any)
}

func NewEventBus() *EventBus {
    return &EventBus{subscribers: make(map[string][]func(any))}
}

// Subscribe registers a handler for a named event — multiple handlers per event are allowed
func (b *EventBus) Subscribe(event string, fn func(any)) {
    b.mu.Lock()
    defer b.mu.Unlock()
    b.subscribers[event] = append(b.subscribers[event], fn)
}

// Publish delivers an event to all subscribers asynchronously
func (b *EventBus) Publish(event string, data any) {
    b.mu.RLock()
    handlers := b.subscribers[event] // copy the slice while holding the lock
    b.mu.RUnlock()

    for _, h := range handlers {
        go h(data) // each handler runs in its own goroutine — non-blocking
    }
}

// Usage — producer and consumers are completely decoupled
bus := NewEventBus()

// These subscribers don't know about each other or about who calls Publish
bus.Subscribe("user.created", func(data any) {
    u := data.(*User)
    fmt.Printf("sending welcome email to %s\n", u.Email)
})
bus.Subscribe("user.created", func(data any) {
    u := data.(*User)
    fmt.Printf("adding %s to CRM\n", u.Email)
})

bus.Publish("user.created", &User{ID: 1, Email: "alice@example.com"})

Frequently Asked Questions

What is the functional options pattern?
Functional options let you build flexible constructors without long parameter lists or config structs that require all fields to be set. Each option is a function that modifies a config struct, and callers only pass the options they need.
Does Go have a built-in dependency injection framework?
No built-in one, but Google's Wire is the standard compile-time DI tool for Go. For simpler cases, constructor injection (passing dependencies as function arguments) is idiomatic and testable without any framework.
Is the Singleton pattern used in Go?
Rarely. Go's package-level variables and sync.Once cover most singleton needs. In tests, singletons are hard to reset between test cases. Constructor injection with interfaces is preferred for testability.