Design Patterns in Go Advanced¶
Introduction¶
Go doesn't have classes, inheritance, or generics-heavy type hierarchies. Design patterns in Go look fundamentally different from Java or C++ — they're simpler, more explicit, and built on interfaces, composition, and first-class functions. The goal isn't to implement GoF patterns verbatim, but to solve the same problems idiomatically.
Why This Matters
Interviewers expect senior Go engineers to recognize when a pattern applies and implement it cleanly without over-engineering. Knowing when not to use a pattern is just as important as knowing how.
How Go Approaches Patterns Differently¶
graph LR
A[OOP Languages] -->|"Classes + Inheritance"| B[Complex Hierarchies]
C[Go] -->|"Interfaces + Composition"| D[Simple, Explicit Code]
C -->|"Functions as Values"| E[Strategy without Classes]
C -->|"Embedding"| F[Decoration without Inheritance]
| OOP Concept | Go Equivalent |
|---|---|
| Abstract class | Interface |
| Inheritance | Embedding (composition) |
| Class constructor | Factory function (NewXxx) |
| Method overriding | Interface satisfaction |
| Decorator pattern | Middleware / wrapping |
| Strategy pattern | Interface or func type |
| Singleton | sync.Once |
Factory Pattern¶
Constructor Functions (The Standard Way)¶
type Server struct {
addr string
timeout time.Duration
logger *slog.Logger
tls *tls.Config
}
// Simple constructor with required params
func NewServer(addr string) *Server {
return &Server{
addr: addr,
timeout: 30 * time.Second,
logger: slog.Default(),
}
}
Functional Options (Production Pattern)¶
type Option func(*Server)
func WithTimeout(d time.Duration) Option {
return func(s *Server) { s.timeout = d }
}
func WithLogger(l *slog.Logger) Option {
return func(s *Server) { s.logger = l }
}
func WithTLS(cfg *tls.Config) Option {
return func(s *Server) { s.tls = cfg }
}
func NewServer(addr string, opts ...Option) *Server {
s := &Server{
addr: addr,
timeout: 30 * time.Second,
logger: slog.Default(),
}
for _, opt := range opts {
opt(s)
}
return s
}
// Usage
srv := NewServer(":8080",
WithTimeout(10*time.Second),
WithLogger(slog.New(slog.NewJSONHandler(os.Stdout, nil))),
WithTLS(tlsConfig),
)
Functional Options with Validation¶
type Option func(*Server) error
func WithTimeout(d time.Duration) Option {
return func(s *Server) error {
if d <= 0 {
return fmt.Errorf("timeout must be positive, got %v", d)
}
s.timeout = d
return nil
}
}
func NewServer(addr string, opts ...Option) (*Server, error) {
s := &Server{addr: addr, timeout: 30 * time.Second}
for _, opt := range opts {
if err := opt(s); err != nil {
return nil, fmt.Errorf("invalid option: %w", err)
}
}
return s, nil
}
Interview Tip
"I use functional options for any constructor with more than 2-3 optional parameters. It's self-documenting, extensible without breaking existing callers, and avoids the config struct explosion. Libraries like google.golang.org/grpc use this pattern extensively."
Strategy Pattern¶
In Go, strategy is either an interface or a function type — no need for a class hierarchy.
Interface-Based Strategy¶
type Compressor interface {
Compress(data []byte) ([]byte, error)
Extension() string
}
type GzipCompressor struct{}
func (GzipCompressor) Compress(data []byte) ([]byte, error) {
var buf bytes.Buffer
w := gzip.NewWriter(&buf)
if _, err := w.Write(data); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (GzipCompressor) Extension() string { return ".gz" }
type ZstdCompressor struct{ level int }
func (z ZstdCompressor) Compress(data []byte) ([]byte, error) {
enc, err := zstd.NewWriter(nil, zstd.WithEncoderLevel(zstd.EncoderLevel(z.level)))
if err != nil {
return nil, err
}
return enc.EncodeAll(data, nil), nil
}
func (ZstdCompressor) Extension() string { return ".zst" }
// Archiver uses whichever strategy is injected
type Archiver struct {
compressor Compressor
}
func NewArchiver(c Compressor) *Archiver {
return &Archiver{compressor: c}
}
func (a *Archiver) Archive(name string, data []byte) error {
compressed, err := a.compressor.Compress(data)
if err != nil {
return err
}
return os.WriteFile(name+a.compressor.Extension(), compressed, 0o644)
}
Function-Based Strategy¶
type HashFunc func(data []byte) []byte
func MD5Hash(data []byte) []byte {
h := md5.Sum(data)
return h[:]
}
func SHA256Hash(data []byte) []byte {
h := sha256.Sum256(data)
return h[:]
}
type FileStore struct {
hash HashFunc
}
func NewFileStore(hash HashFunc) *FileStore {
return &FileStore{hash: hash}
}
Observer Pattern (Channels)¶
Go replaces traditional observer/listener patterns with channels.
type Event struct {
Type string
Payload any
}
type EventBus struct {
mu sync.RWMutex
subscribers map[string][]chan Event
}
func NewEventBus() *EventBus {
return &EventBus{
subscribers: make(map[string][]chan Event),
}
}
func (eb *EventBus) Subscribe(eventType string, bufSize int) <-chan Event {
ch := make(chan Event, bufSize)
eb.mu.Lock()
eb.subscribers[eventType] = append(eb.subscribers[eventType], ch)
eb.mu.Unlock()
return ch
}
func (eb *EventBus) Publish(e Event) {
eb.mu.RLock()
defer eb.mu.RUnlock()
for _, ch := range eb.subscribers[e.Type] {
// Non-blocking send — drop if subscriber is slow
select {
case ch <- e:
default:
}
}
}
func (eb *EventBus) Close() {
eb.mu.Lock()
defer eb.mu.Unlock()
for _, subs := range eb.subscribers {
for _, ch := range subs {
close(ch)
}
}
}
// Usage
bus := NewEventBus()
orders := bus.Subscribe("order.created", 100)
go func() {
for event := range orders {
fmt.Printf("New order: %v\n", event.Payload)
}
}()
bus.Publish(Event{Type: "order.created", Payload: Order{ID: "123"}})
Decorator Pattern (Middleware / Wrapping)¶
HTTP Middleware¶
type Middleware func(http.Handler) http.Handler
func Logging(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(wrapped, r)
logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.status,
"duration", time.Since(start),
)
})
}
}
func RateLimit(rps float64) Middleware {
limiter := rate.NewLimiter(rate.Limit(rps), int(rps))
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(wrapped, r)
})
}
}
func Recovery() Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}
// Chain applies middleware in order (outermost first)
func Chain(handler http.Handler, middlewares ...Middleware) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
handler = middlewares[i](handler)
}
return handler
}
// Usage
mux := http.NewServeMux()
mux.HandleFunc("GET /api/users", handleUsers)
handler := Chain(mux,
Recovery(),
Logging(logger),
RateLimit(100),
)
http.ListenAndServe(":8080", handler)
Interface Wrapping (io.Writer Decorator)¶
type CountingWriter struct {
w io.Writer
count int64
}
func NewCountingWriter(w io.Writer) *CountingWriter {
return &CountingWriter{w: w}
}
func (cw *CountingWriter) Write(p []byte) (int, error) {
n, err := cw.w.Write(p)
cw.count += int64(n)
return n, err
}
func (cw *CountingWriter) BytesWritten() int64 {
return cw.count
}
// Decorates any io.Writer
var buf bytes.Buffer
cw := NewCountingWriter(&buf)
fmt.Fprintf(cw, "hello, %s", "world")
fmt.Println(cw.BytesWritten()) // 12
Singleton Pattern (sync.Once)¶
type Database struct {
pool *pgxpool.Pool
}
var (
dbInstance *Database
dbOnce sync.Once
)
func GetDatabase() *Database {
dbOnce.Do(func() {
pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL"))
if err != nil {
panic(fmt.Sprintf("failed to connect: %v", err))
}
dbInstance = &Database{pool: pool}
})
return dbInstance
}
Prefer Dependency Injection
Singletons make testing harder. In most cases, pass the dependency explicitly via constructors. Reserve sync.Once for truly global, shared resources like a metrics registry or logging setup.
Builder Pattern¶
Chained Methods¶
type QueryBuilder struct {
table string
conditions []string
args []any
orderBy string
limit int
}
func NewQueryBuilder(table string) *QueryBuilder {
return &QueryBuilder{table: table}
}
func (qb *QueryBuilder) Where(condition string, args ...any) *QueryBuilder {
qb.conditions = append(qb.conditions, condition)
qb.args = append(qb.args, args...)
return qb
}
func (qb *QueryBuilder) OrderBy(field string) *QueryBuilder {
qb.orderBy = field
return qb
}
func (qb *QueryBuilder) Limit(n int) *QueryBuilder {
qb.limit = n
return qb
}
func (qb *QueryBuilder) Build() (string, []any) {
query := fmt.Sprintf("SELECT * FROM %s", qb.table)
if len(qb.conditions) > 0 {
query += " WHERE " + strings.Join(qb.conditions, " AND ")
}
if qb.orderBy != "" {
query += " ORDER BY " + qb.orderBy
}
if qb.limit > 0 {
query += fmt.Sprintf(" LIMIT %d", qb.limit)
}
return query, qb.args
}
// Usage
query, args := NewQueryBuilder("users").
Where("age > $1", 18).
Where("active = $2", true).
OrderBy("created_at DESC").
Limit(10).
Build()
Repository Pattern¶
type User struct {
ID string
Name string
Email string
CreatedAt time.Time
}
type UserRepository interface {
FindByID(ctx context.Context, id string) (*User, error)
FindByEmail(ctx context.Context, email string) (*User, error)
Save(ctx context.Context, user *User) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, offset, limit int) ([]*User, error)
}
// PostgreSQL implementation
type postgresUserRepo struct {
db *sql.DB
}
func NewPostgresUserRepository(db *sql.DB) UserRepository {
return &postgresUserRepo{db: db}
}
func (r *postgresUserRepo) FindByID(ctx context.Context, id string) (*User, error) {
var u User
err := r.db.QueryRowContext(ctx,
"SELECT id, name, email, created_at FROM users WHERE id = $1", id,
).Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
return &u, err
}
func (r *postgresUserRepo) Save(ctx context.Context, user *User) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO users (id, name, email, created_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE SET name = $2, email = $3`,
user.ID, user.Name, user.Email, user.CreatedAt,
)
return err
}
// In-memory implementation for tests
type memoryUserRepo struct {
mu sync.RWMutex
users map[string]*User
}
func NewMemoryUserRepository() UserRepository {
return &memoryUserRepo{users: make(map[string]*User)}
}
func (r *memoryUserRepo) FindByID(_ context.Context, id string) (*User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
u, ok := r.users[id]
if !ok {
return nil, ErrNotFound
}
return u, nil
}
Dependency Injection¶
Constructor Injection (Idiomatic Go)¶
type UserService struct {
repo UserRepository
cache Cache
logger *slog.Logger
}
func NewUserService(repo UserRepository, cache Cache, logger *slog.Logger) *UserService {
return &UserService{
repo: repo,
cache: cache,
logger: logger,
}
}
// In main.go — wire everything together
func main() {
db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL"))
repo := NewPostgresUserRepository(db)
cache := NewRedisCache(os.Getenv("REDIS_URL"))
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
userSvc := NewUserService(repo, cache, logger)
handler := NewUserHandler(userSvc)
http.ListenAndServe(":8080", handler)
}
Wire (Google's Compile-Time DI)¶
//go:build wireinject
package main
import "github.com/google/wire"
func InitializeApp() (*App, error) {
wire.Build(
NewPostgresUserRepository,
NewRedisCache,
NewUserService,
NewUserHandler,
NewApp,
)
return nil, nil // wire generates the real implementation
}
Interview Tip
"In Go, I use constructor injection — pass dependencies as interface parameters to New functions. This is explicit, testable, and doesn't require a framework. For large applications with many dependencies, I use Google's wire for compile-time dependency injection — it catches wiring errors at build time, not runtime."
Quick Reference¶
| Pattern | Go Implementation | When to Use |
|---|---|---|
| Factory | NewXxx() + functional options |
Object creation with optional configuration |
| Strategy | Interface or func type |
Swappable algorithms at runtime |
| Observer | Channels + goroutines | Event-driven, pub/sub within a process |
| Decorator | Middleware func(H) H |
Cross-cutting concerns (logging, auth, metrics) |
| Singleton | sync.Once |
Truly global resources (prefer DI instead) |
| Builder | Chained methods returning *Builder |
Complex object construction with many fields |
| Repository | Interface over data access layer | Abstract storage, enable testing |
| Middleware | func(http.Handler) http.Handler |
HTTP/gRPC request pipeline |
| DI | Constructor injection (or wire) |
Testable, decoupled architecture |
Best Practices¶
- Prefer composition over inheritance — embed structs, don't simulate class hierarchies
- Use functional options for constructors with 3+ optional parameters
- Keep interfaces small — 1-3 methods; let consumers define them
- Use
functypes as strategies when only one method is needed - Middleware pattern is the idiomatic Go decorator — use it for HTTP, gRPC, and custom pipelines
- Avoid premature abstraction — write the concrete implementation first, extract interfaces when you need a second implementation or testability
- Constructor injection is the default DI pattern; frameworks are optional
Common Pitfalls¶
Over-Abstraction
Don't create interfaces for everything "just in case." Go's philosophy is accept interfaces, return structs. Create an interface when you have a real reason: multiple implementations, testability, or decoupling.
Java-Style Patterns in Go
Don't port Java patterns directly. Go doesn't need Abstract Factory, Template Method via inheritance, or complex visitor hierarchies. Use interfaces and functions instead.
Singleton Abuse
Singletons create hidden dependencies and make testing hard. Pass dependencies explicitly. The only acceptable singletons are truly global resources like the default logger or metrics registry.
Builder Without Validation
If your builder can produce invalid objects, add a Build() (T, error) method that validates. Don't let callers create broken state.
Performance Considerations¶
- Interface calls have a tiny overhead (indirect dispatch) — negligible in almost all cases
- Functional options allocate closures — zero impact for constructors called once, but avoid in hot paths
- Channel-based observer adds goroutine overhead — for high-throughput event systems, consider callback slices with a mutex
- Middleware chains add one function call per layer — stack 5-10 middlewares without concern
sync.Oncehas near-zero cost after first call (atomic check only)
Interview Tips¶
Interview Tip
"Go patterns are simpler than their OOP equivalents. A factory is just a NewXxx function. A strategy is just an interface. A decorator is middleware. I focus on solving the problem idiomatically rather than naming the pattern."
Interview Tip
"The most important pattern in Go is the middleware pattern: func(next Handler) Handler. It composes cleanly for HTTP, gRPC interceptors, and any pipeline. I've used it to add logging, metrics, auth, rate limiting, and tracing — each as an independent, testable layer."
Key Takeaways¶
- Go patterns are simpler — no classes, no inheritance, just interfaces, composition, and functions
- Functional options are the production standard for configurable constructors
- Strategy = interface or func type — choose based on complexity
- Middleware is Go's decorator — the most widely used pattern in Go services
- Constructor injection is the idiomatic DI approach — explicit, testable, no magic
sync.Oncefor singletons, but prefer passing dependencies explicitly- Repository pattern with interfaces enables clean separation of data access and business logic
- Don't over-engineer — apply patterns when they solve a real problem, not prophylactically