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 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.Handlersignature, 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
Share
Related articles

Go 1.26 Interview: Green Tea GC, go fix and Stack Optimizations
Prepare for Go 1.26 interview questions covering the Green Tea garbage collector, revamped go fix tool, stack allocation optimizations, and key performance improvements.

Top 25 Go Interview Questions: Complete Developer Guide
Ace your Go interviews with the 25 most asked questions. Master goroutines, channels, interfaces, concurrency patterns with practical code examples.

Go Technical Interview: Goroutines, Channels and Concurrency
Go interview questions on goroutines, channels, and concurrency patterns. Code examples, common pitfalls, and expert-level answers to prepare for Go technical interviews in 2026.