Design Patterns in Go
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.Creational Patterns
Factory Pattern
Builder Pattern
Functional Options Pattern (Idiomatic Go)
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).Singleton Pattern
Structural Patterns
Adapter Pattern
Decorator Pattern
Middleware Pattern (Go Idiomatic Decorator)
Composite Pattern
Behavioral Patterns
Strategy Pattern
Strategy with Functions (Go Idiomatic)
Observer Pattern
Observer with Channels (Go Idiomatic)
State Pattern
Go-Specific Patterns
Embedding (Composition over Inheritance)
Interface Segregation
Interview Questions
How does Go implement polymorphism without inheritance?
How does Go implement polymorphism without inheritance?
- Interfaces define behavior contracts
- Types implicitly implement interfaces
- Embedding allows composition of behaviors
- Duck typing: if it implements the methods, it satisfies the interface
What's the functional options pattern and when should you use it?
What's the functional options pattern and when should you use it?
- You have many optional configuration parameters
- You want clean, readable construction
- You need validation during configuration
- Default values should be easy to override
How do you implement dependency injection in Go?
How do you implement dependency injection in Go?
- Pass dependencies as parameters to constructors
- Use interfaces for dependencies (testability)
- Avoid global variables and singletons
- Wire dependencies in main() or use DI containers sparingly
What's the difference between embedding and composition?
What's the difference between embedding and composition?
- Embedding: Type is embedded directly, methods are promoted
- Composition: Type is a field, access via field name
Summary
| Pattern | Go Implementation |
|---|---|
| Factory | Constructor functions |
| Builder | Fluent methods or functional options |
| Singleton | sync.Once or package-level init |
| Adapter | Wrapper struct implementing target interface |
| Decorator | Middleware pattern with function composition |
| Strategy | Interface + concrete implementations or functions |
| Observer | Channels or subscriber interface |
| State | Interface per state, context holds current state |
Interview Deep-Dive
Compare the functional options pattern with the builder pattern in Go. When would you choose each, and what are the trade-offs?
Compare the functional options pattern with the builder pattern in Go. When would you choose each, and what are the trade-offs?
- 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, andBuild()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.
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?
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?
- 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 readingmain()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 afunc newApp(cfg Config) (*App, error)that returns a struct holding the fully wired application. This keepsmain()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.
maincreates 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.
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?
Explain the observer pattern in Go. How would you implement it using channels versus interfaces, and what are the concurrency implications of each?
- Interface-based observer: define an
Observerinterface with anOnEvent(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
OnEventis 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.
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.