Go’s philosophy favors simplicity over complexity. While traditional OOP patterns exist, Go often provides more idiomatic solutions using composition, interfaces, and first-class functions.An important mindset shift: do not try to force Java or C++ design patterns into Go. Go does not have classes, inheritance, or abstract base classes. Instead, it uses interfaces (implicit satisfaction), struct embedding (composition over inheritance), and functions as first-class values. The patterns in this chapter are the Go translations of classic patterns, adapted to work with the language rather than against it.
This is the most important pattern in this chapter for Go developers. The functional options pattern was popularized by Dave Cheney and Rob Pike and is used extensively in production Go codebases (gRPC, Uber’s Zap logger, AWS SDK). It elegantly solves the problem of configuring an object with many optional parameters without requiring a builder, a config struct, or function overloading (which Go does not support).
type Server struct { host string port int timeout time.Duration maxConns int tls *TLSConfig middleware []Middleware}type TLSConfig struct { CertFile string KeyFile string}// Option is a function that configures the servertype Option func(*Server) errorfunc WithHost(host string) Option { return func(s *Server) error { s.host = host return nil }}func WithPort(port int) Option { return func(s *Server) error { if port < 1 || port > 65535 { return errors.New("invalid port") } s.port = port return nil }}func WithTimeout(timeout time.Duration) Option { return func(s *Server) error { s.timeout = timeout return nil }}func WithTLS(certFile, keyFile string) Option { return func(s *Server) error { if certFile == "" || keyFile == "" { return errors.New("cert and key files required") } s.tls = &TLSConfig{CertFile: certFile, KeyFile: keyFile} return nil }}func WithMiddleware(mw ...Middleware) Option { return func(s *Server) error { s.middleware = append(s.middleware, mw...) return nil }}// Constructor with optionsfunc NewServer(opts ...Option) (*Server, error) { // Default values s := &Server{ host: "localhost", port: 8080, timeout: 30 * time.Second, maxConns: 100, } // Apply options for _, opt := range opts { if err := opt(s); err != nil { return nil, err } } return s, nil}// Usageserver, err := NewServer( WithHost("0.0.0.0"), WithPort(443), WithTimeout(60 * time.Second), WithTLS("cert.pem", "key.pem"), WithMiddleware(loggingMiddleware),)
Compare the functional options pattern with the builder pattern in Go. When would you choose each, and what are the trade-offs?
Strong Answer:
The builder pattern uses a separate builder struct with fluent method chaining: NewServerBuilder().Host("0.0.0.0").Port(443).Build(). Each method returns the builder, and Build() validates and returns the final object. This is familiar to Java/C# developers and works well when construction involves complex conditional logic or multiple steps.
The functional options pattern uses variadic functions: NewServer(WithHost("0.0.0.0"), WithPort(443)). Each option is a function that modifies the config. The constructor sets defaults, applies options, and returns the final object.
Functional options advantages: options are composable and can be stored in variables for reuse (var prodDefaults = []Option{WithPort(443), WithTLS(...)}; NewServer(append(prodDefaults, WithHost("0.0.0.0"))...)). Each option can validate its own arguments and return an error. Options can be defined in different packages, extending the constructor without modifying it.
Builder advantages: the IDE shows all available methods on autocomplete, making discovery easier. Step-by-step construction is more natural for complex objects where certain settings depend on others.
In the Go ecosystem, functional options won out. It is the pattern used by grpc.NewServer, zap.New, http.NewRequest, and most production libraries. The Go community favors it because it does not require a separate builder type, it integrates naturally with variadic parameters, and it follows Go’s preference for functions over types.
Trade-off: functional options can be hard to discover if the With* functions are not well-documented. Good naming and package organization mitigate this.
Follow-up: How does Go achieve polymorphism without inheritance, and why is composition preferred?Go achieves polymorphism through interfaces and embedding (composition). An interface defines behavior, and any type with the right methods satisfies it — no explicit inheritance hierarchy needed. Composition via struct embedding lets you reuse behavior without the tight coupling of inheritance. In languages with inheritance, changing a parent class can break all children (fragile base class problem). In Go’s composition model, the embedded type and the outer type are independent — changing the embedded type’s internal implementation does not affect the outer type’s contract. The practical benefit: in a large codebase, composition produces a flat, decoupled dependency graph. Each type declares exactly what it needs via interfaces and includes exactly what it reuses via embedding. There are no deep hierarchies to navigate, no diamond inheritance problems, and no “god classes” that accumulate methods through multiple inheritance levels.
You need to implement dependency injection in a Go service with 15 dependencies (database, cache, logger, config, multiple service layers). How do you wire it all together without a DI framework?
Strong Answer:
Go favors manual, constructor-based dependency injection. Each component declares its dependencies as interface parameters in its constructor. You wire everything in main(), which becomes the composition root.
The pattern: create infrastructure first (config, logger, database, cache), then create repositories (passing database), then create services (passing repositories and other dependencies), then create HTTP handlers (passing services), then create the router (registering handlers), then create the server.
For 15 dependencies, this creates a long main() function, which is actually a feature, not a bug. Anyone reading main() can see the entire dependency graph of the application in one place. There are no hidden bindings, no reflection-based autowiring, and no runtime surprises.
If main() becomes unwieldy, extract initialization into a func newApp(cfg Config) (*App, error) that returns a struct holding the fully wired application. This keeps main() clean while maintaining explicit wiring.
I specifically avoid DI frameworks (like Wire, Uber’s dig/fx) in most Go projects. They add indirection, magic, and learning curve. The exception is very large applications (50+ dependencies) where the wiring code itself becomes a maintenance burden — Google’s Wire generates the wiring code at compile time, which is acceptable.
The key principle: dependencies flow downward. main creates everything, handlers depend on services, services depend on repositories, repositories depend on database. No component creates its own dependencies. This makes testing trivial — each layer can be tested by injecting mocks.
Follow-up: How do you implement the strategy pattern in Go without classes, and when would you use a function type instead of an interface?In Go, the strategy pattern can be implemented two ways. With an interface: define type Hasher interface { Hash([]byte) []byte }, create concrete implementations (SHA256Hasher, MD5Hasher), and inject the desired strategy via the constructor. With a function type: define type HashFunc func([]byte) []byte, and pass the function directly (document := NewDocument(data, sha256Hash)). Use the function approach when the strategy is a single operation with no state. Use the interface approach when the strategy has multiple methods or carries configuration. Function types are more lightweight and idiomatic in Go for simple strategies. The standard library uses this extensively: sort.Slice(data, func(i, j int) bool { ... }) is a function-based strategy for comparison.
Explain the observer pattern in Go. How would you implement it using channels versus interfaces, and what are the concurrency implications of each?
Strong Answer:
Interface-based observer: define an Observer interface with an OnEvent(event Event) method. The subject maintains a slice of observers and calls each one when an event occurs. Each call can be synchronous (blocking the publisher until all observers process) or asynchronous (each call in a goroutine).
Channel-based observer: each subscriber receives a channel. The publisher sends events to all subscriber channels. Subscribers read from their channel in their own goroutine. This is more Go-idiomatic because it uses the language’s native concurrency primitives.
Concurrency implications of the interface approach: if OnEvent is called synchronously, a slow observer blocks the publisher and all other observers. If called in goroutines, you need to handle the case where an observer panics (recover in the goroutine), and you lose ordering guarantees.
Concurrency implications of the channel approach: each subscriber processes at its own pace. The publisher’s select { case ch <- event: default: } pattern drops events if a subscriber’s channel is full, preventing a slow subscriber from blocking the publisher. However, you need to manage channel lifecycle (closing on unsubscribe), and the buffer size determines how much backpressure you can absorb.
In production, I prefer the channel-based approach because it naturally decouples publisher and subscriber speeds, integrates with Go’s select for timeouts and cancellation, and avoids the need for locks on the observer list during notification (you only lock during subscribe/unsubscribe).
A critical detail: when unsubscribing, close the channel from the publisher side (the side that sends) and have the subscriber detect the close via for event := range ch. Never close from the subscriber side — that could panic if the publisher sends after the close.
Follow-up: What is the difference between embedding an interface and embedding a struct, and when is each appropriate?Embedding an interface in a struct means the struct satisfies that interface, but methods are dispatched dynamically — you must set the embedded interface to a concrete implementation at runtime. This is useful for partial implementation: embed the interface to satisfy it, then override specific methods. Embedding a struct means the outer struct gets all the embedded struct’s methods and fields, dispatched statically. This is true composition — the behavior is fixed at compile time. Use interface embedding when you need runtime polymorphism (like a decorator that wraps any implementation). Use struct embedding when you want to reuse concrete behavior (like embedding sync.Mutex to get Lock/Unlock). The gotcha with struct embedding: the embedded type’s methods operate on the embedded instance, not the outer struct. If Logger.Log references l.prefix, it uses the embedded Logger’s prefix, not anything from the outer struct.