Go Design Patterns: Essential Patterns and Interview Questions for Go Developers

Master Go design patterns including Functional Options, Strategy, Factory, and Observer. Practical code examples, idiomatic best practices, and common interview questions for Go developers.

Go design patterns illustration with abstract geometric shapes representing software architecture

Go design patterns differ fundamentally from their counterparts in object-oriented languages. Without classes or inheritance, Go relies on composition, interfaces, and first-class functions to achieve the same structural flexibility—often with less ceremony and more clarity.

Idiomatic Go Principle

Go favors composition over inheritance. Every classic design pattern must be adapted to work with interfaces, struct embedding, and functions as first-class citizens rather than class hierarchies.

The Functional Options Pattern for Flexible Configuration

The Functional Options pattern solves a common problem in Go: constructing objects with many optional parameters. Unlike languages with method overloading or default arguments, Go has neither. Builder patterns work but feel verbose. Functional Options provide a clean, extensible API that remains backward-compatible as requirements grow.

This pattern, popularized by Dave Cheney and now standard across the Go ecosystem, uses variadic function arguments to configure a struct.

server.gogo
package server

import (
	"time"
	"log/slog"
)

// Server holds the HTTP server configuration.
type Server struct {
	host         string
	port         int
	timeout      time.Duration
	maxConns     int
	logger       *slog.Logger
}

// Option defines a functional option for Server.
type Option func(*Server)

// WithPort sets the server port.
func WithPort(port int) Option {
	return func(s *Server) {
		s.port = port
	}
}

// WithTimeout sets the request timeout.
func WithTimeout(d time.Duration) Option {
	return func(s *Server) {
		s.timeout = d
	}
}

// WithMaxConns sets the maximum concurrent connections.
func WithMaxConns(n int) Option {
	return func(s *Server) {
		s.maxConns = n
	}
}

// New creates a Server with sensible defaults and applies options.
func New(host string, opts ...Option) *Server {
	srv := &Server{
		host:     host,
		port:     8080,           // default port
		timeout:  30 * time.Second, // default timeout
		maxConns: 100,            // default max connections
		logger:   slog.Default(),
	}
	for _, opt := range opts {
		opt(srv)
	}
	return srv
}

The caller specifies only what differs from defaults. Adding a new option never breaks existing call sites. This pattern appears in production libraries like google.golang.org/grpc and go.uber.org/zap.

Strategy Pattern Through Interface Satisfaction

The Strategy pattern encapsulates interchangeable algorithms behind a common interface. In Go, this maps directly to interface-based polymorphism—no abstract classes or inheritance chains required.

A payment processing system demonstrates the pattern. Each payment method implements the same interface, and the checkout service selects the strategy at runtime.

payment.gogo
package payment

import "fmt"

// Processor defines the strategy interface.
type Processor interface {
	Pay(amount float64) (string, error)
}

// CreditCard implements Processor for card payments.
type CreditCard struct {
	CardNumber string
	Expiry     string
}

func (c *CreditCard) Pay(amount float64) (string, error) {
	// Charge the card via payment gateway
	return fmt.Sprintf("charged %.2f to card ending %s", amount, c.CardNumber[len(c.CardNumber)-4:]), nil
}

// BankTransfer implements Processor for wire transfers.
type BankTransfer struct {
	IBAN string
}

func (b *BankTransfer) Pay(amount float64) (string, error) {
	return fmt.Sprintf("initiated transfer of %.2f to %s", amount, b.IBAN), nil
}

// Checkout processes a payment using the given strategy.
func Checkout(p Processor, amount float64) error {
	receipt, err := p.Pay(amount)
	if err != nil {
		return fmt.Errorf("payment failed: %w", err)
	}
	fmt.Println(receipt)
	return nil
}

Go interfaces are satisfied implicitly—no implements keyword. Any type with a Pay(float64) (string, error) method qualifies as a Processor. This keeps coupling low and makes testing straightforward: pass a mock Processor that returns predictable results.

Factory Functions and Constructor Patterns in Go

Go has no constructors. The idiomatic replacement is a factory function, typically named New or NewXxx, that returns an initialized struct. Factory functions enforce invariants that zero-value initialization cannot guarantee.

The distinction matters for interview preparation: Go developers should recognize when a factory function is necessary versus when the zero value is useful by default.

connpool.gogo
package pool

import (
	"errors"
	"sync"
)

// ConnPool manages a pool of reusable connections.
type ConnPool struct {
	mu       sync.Mutex
	conns    []Conn
	maxSize  int
}

// Conn represents a database connection.
type Conn struct {
	ID     int
	Active bool
}

// NewConnPool validates parameters and returns an initialized pool.
func NewConnPool(maxSize int) (*ConnPool, error) {
	if maxSize <= 0 {
		return nil, errors.New("pool: maxSize must be positive")
	}
	return &ConnPool{
		conns:   make([]Conn, 0, maxSize),
		maxSize: maxSize,
	}, nil
}

// Acquire returns a connection from the pool.
func (p *ConnPool) Acquire() (*Conn, error) {
	p.mu.Lock()
	defer p.mu.Unlock()
	for i := range p.conns {
		if !p.conns[i].Active {
			p.conns[i].Active = true
			return &p.conns[i], nil
		}
	}
	if len(p.conns) >= p.maxSize {
		return nil, errors.New("pool: no available connections")
	}
	c := Conn{ID: len(p.conns) + 1, Active: true}
	p.conns = append(p.conns, c)
	return &p.conns[len(p.conns)-1], nil
}

The factory function NewConnPool validates the maxSize parameter and pre-allocates the slice. Direct struct initialization (&ConnPool{}) would skip validation, potentially leading to runtime panics. This pattern extends naturally: combine it with Functional Options for more complex configuration scenarios.

Ready to ace your Go interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Observer Pattern with Channels and Goroutines

The Observer pattern notifies multiple subscribers when state changes. In Java or C#, this involves event listeners and callback registration. Go offers a more idiomatic path: channels. Each subscriber receives events through its own channel, and goroutines handle delivery concurrently.

This approach leverages Go's concurrency primitives directly, avoiding callback spaghetti.

eventbus.gogo
package events

import "sync"

// Event carries a topic and payload.
type Event struct {
	Topic   string
	Payload any
}

// Bus manages subscriptions and event dispatch.
type Bus struct {
	mu          sync.RWMutex
	subscribers map[string][]chan Event
}

// NewBus creates an event bus.
func NewBus() *Bus {
	return &Bus{
		subscribers: make(map[string][]chan Event),
	}
}

// Subscribe returns a channel that receives events for a topic.
func (b *Bus) Subscribe(topic string) <-chan Event {
	ch := make(chan Event, 16) // buffered to avoid blocking publisher
	b.mu.Lock()
	b.subscribers[topic] = append(b.subscribers[topic], ch)
	b.mu.Unlock()
	return ch
}

// Publish sends an event to all subscribers of the topic.
func (b *Bus) Publish(topic string, payload any) {
	b.mu.RLock()
	defer b.mu.RUnlock()
	for _, ch := range b.subscribers[topic] {
		select {
		case ch <- Event{Topic: topic, Payload: payload}:
		default:
			// subscriber too slow, drop event
		}
	}
}

The select with a default case prevents a slow subscriber from blocking the entire bus. In production systems, adding a context-based cancellation mechanism and an Unsubscribe method would complete the implementation.

Middleware Pattern for HTTP Request Pipelines

Middleware chains are the backbone of Go HTTP servers. The pattern wraps an http.Handler with additional behavior—logging, authentication, rate limiting—without modifying the handler itself. The standard library's http.Handler interface makes this trivially composable.

middleware.gogo
package middleware

import (
	"log/slog"
	"net/http"
	"time"
)

// Logging records request duration and status.
func Logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		wrapped := &statusWriter{ResponseWriter: w, status: 200}
		next.ServeHTTP(wrapped, r)
		slog.Info("request",
			"method", r.Method,
			"path", r.URL.Path,
			"status", wrapped.status,
			"duration", time.Since(start),
		)
	})
}

// statusWriter captures the HTTP status code.
type statusWriter struct {
	http.ResponseWriter
	status int
}

func (w *statusWriter) WriteHeader(code int) {
	w.status = code
	w.ResponseWriter.WriteHeader(code)
}

// Chain applies middleware in order: first listed = outermost.
func Chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
	for i := len(mw) - 1; i >= 0; i-- {
		h = mw[i](h)
	}
	return h
}

The Chain function applies middleware in declaration order. Each middleware wraps the next, forming a pipeline. This pattern is identical in popular routers like chi, which accepts func(http.Handler) http.Handler as middleware.

Composition Over Inheritance with Struct Embedding

Go does not support inheritance. Instead, struct embedding promotes methods from an inner type to the outer type, achieving code reuse without tight coupling. This is not inheritance—the embedded type has no knowledge of the embedding type, and there is no virtual dispatch.

Understanding this distinction is critical for Go interview questions. Candidates who conflate embedding with inheritance reveal a shallow understanding of Go's type system.

models.gogo
package models

import "time"

// Timestamps provides common audit fields.
type Timestamps struct {
	CreatedAt time.Time
	UpdatedAt time.Time
}

// User embeds Timestamps to gain CreatedAt/UpdatedAt.
type User struct {
	Timestamps
	ID    int
	Email string
}

// Order also embeds Timestamps.
type Order struct {
	Timestamps
	ID     int
	UserID int
	Total  float64
}

Both User and Order now expose CreatedAt and UpdatedAt fields directly. Any methods defined on Timestamps are also promoted. The key rule: embedding provides delegation, not substitution. A User is not a Timestamps—it has a Timestamps.

Interview Questions on Go Design Patterns

Design pattern questions in Go interviews test whether a candidate can adapt classical patterns to Go's type system. The following questions appear frequently across technical screens and on-site rounds.

Q: When should a factory function return an error instead of panicking?

Factory functions should return an error whenever validation depends on runtime input (user-provided config, environment variables, external data). Panicking is reserved for programmer errors—situations that indicate a bug, such as passing a nil pointer where the API contract forbids it. The standard library follows this convention: os.Open returns an error, while regexp.MustCompile panics because it expects a compile-time-constant pattern.

Q: How does the Functional Options pattern improve API evolution?

Adding a new WithXxx function is a non-breaking change. Existing callers continue to work without modification. This contrasts with config structs, where adding a required field breaks all call sites, or with positional parameters, where reordering arguments introduces bugs.

Q: What makes Go interfaces different from Java or C# interfaces?

Go interfaces are satisfied implicitly. A type implements an interface simply by having the required methods—no implements declaration needed. This enables the "accept interfaces, return structs" principle: define narrow interfaces at the call site, not at the implementation site. The result is lower coupling and higher testability.

Interview Pattern

Interviewers often ask candidates to refactor a concrete dependency into an interface. The test: can the candidate identify the minimal method set needed and extract it at the consumer side rather than the producer side?

Q: How does channel-based Observer differ from callback-based Observer?

Channels decouple the publisher and subscriber in both time and space. The publisher does not hold references to subscriber functions—it sends to channels. Subscribers can process events at their own pace using buffered channels. The select statement enables timeout handling, cancellation via context, and multiplexing across multiple event sources—none of which come free with callbacks.

Common Pitfall

Forgetting to close subscriber channels causes goroutine leaks. Every Subscribe should have a corresponding Unsubscribe that closes the channel and removes it from the bus.

Ready to ace your Go interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Conclusion

  • The Functional Options pattern replaces builders and config structs with a clean, extensible API that never breaks existing callers
  • Strategy in Go maps to interface-based polymorphism—define the interface at the consumer, not the provider
  • Factory functions enforce invariants that zero-value initialization cannot; return errors for runtime input, panic only for programmer bugs
  • Channel-based Observer leverages goroutines and select for concurrent, decoupled event delivery without callback chains
  • HTTP middleware chains compose through the func(http.Handler) http.Handler signature, identical across the standard library and third-party routers
  • Struct embedding provides delegation, not inheritance—understanding this distinction separates Go-literate developers from those translating Java patterns verbatim
  • Interview success requires demonstrating idiomatic adaptation of patterns to Go's type system, not textbook recitation of Gang of Four definitions

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Tags

#go
#design-patterns
#best-practices
#interview
#software-architecture

Share

Related articles