Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

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

// Product interface
type Database interface {
    Connect() error
    Query(sql string) ([]Row, error)
    Close() error
}

// Concrete products
type PostgresDB struct {
    host     string
    port     int
    user     string
    password string
}

func (p *PostgresDB) Connect() error {
    // PostgreSQL connection logic
    return nil
}

func (p *PostgresDB) Query(sql string) ([]Row, error) {
    // Query logic
    return nil, nil
}

func (p *PostgresDB) Close() error {
    return nil
}

type MySQLDB struct {
    host     string
    port     int
    user     string
    password string
}

func (m *MySQLDB) Connect() error { return nil }
func (m *MySQLDB) Query(sql string) ([]Row, error) { return nil, nil }
func (m *MySQLDB) Close() error { return nil }

// Factory function
func NewDatabase(dbType string, config Config) (Database, error) {
    switch dbType {
    case "postgres":
        return &PostgresDB{
            host:     config.Host,
            port:     config.Port,
            user:     config.User,
            password: config.Password,
        }, nil
    case "mysql":
        return &MySQLDB{
            host:     config.Host,
            port:     config.Port,
            user:     config.User,
            password: config.Password,
        }, nil
    default:
        return nil, fmt.Errorf("unsupported database type: %s", dbType)
    }
}

// Usage
db, err := NewDatabase("postgres", config)
if err != nil {
    log.Fatal(err)
}
defer db.Close()

Builder Pattern

type Server struct {
    host         string
    port         int
    timeout      time.Duration
    maxConns     int
    tls          bool
    certFile     string
    keyFile      string
    middleware   []Middleware
}

type ServerBuilder struct {
    server *Server
}

func NewServerBuilder() *ServerBuilder {
    return &ServerBuilder{
        server: &Server{
            host:     "localhost",
            port:     8080,
            timeout:  30 * time.Second,
            maxConns: 100,
        },
    }
}

func (b *ServerBuilder) Host(host string) *ServerBuilder {
    b.server.host = host
    return b
}

func (b *ServerBuilder) Port(port int) *ServerBuilder {
    b.server.port = port
    return b
}

func (b *ServerBuilder) Timeout(timeout time.Duration) *ServerBuilder {
    b.server.timeout = timeout
    return b
}

func (b *ServerBuilder) MaxConnections(max int) *ServerBuilder {
    b.server.maxConns = max
    return b
}

func (b *ServerBuilder) WithTLS(certFile, keyFile string) *ServerBuilder {
    b.server.tls = true
    b.server.certFile = certFile
    b.server.keyFile = keyFile
    return b
}

func (b *ServerBuilder) WithMiddleware(mw ...Middleware) *ServerBuilder {
    b.server.middleware = append(b.server.middleware, mw...)
    return b
}

func (b *ServerBuilder) Build() (*Server, error) {
    if b.server.tls && (b.server.certFile == "" || b.server.keyFile == "") {
        return nil, errors.New("TLS requires cert and key files")
    }
    return b.server, nil
}

// Usage
server, err := NewServerBuilder().
    Host("0.0.0.0").
    Port(443).
    Timeout(60 * time.Second).
    WithTLS("cert.pem", "key.pem").
    WithMiddleware(loggingMiddleware, authMiddleware).
    Build()

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).
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 server
type Option func(*Server) error

func 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 options
func 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
}

// Usage
server, err := NewServer(
    WithHost("0.0.0.0"),
    WithPort(443),
    WithTimeout(60 * time.Second),
    WithTLS("cert.pem", "key.pem"),
    WithMiddleware(loggingMiddleware),
)

Singleton Pattern

// Using sync.Once (thread-safe)
type Database struct {
    connection *sql.DB
}

var (
    dbInstance *Database
    dbOnce     sync.Once
)

func GetDatabase() *Database {
    dbOnce.Do(func() {
        conn, err := sql.Open("postgres", connectionString)
        if err != nil {
            panic(err)
        }
        dbInstance = &Database{connection: conn}
    })
    return dbInstance
}

// Alternative: Package-level initialization
var db = mustInitDB()

func mustInitDB() *sql.DB {
    db, err := sql.Open("postgres", connectionString)
    if err != nil {
        panic(err)
    }
    return db
}

Structural Patterns

Adapter Pattern

// Target interface (what the client expects)
type PaymentProcessor interface {
    ProcessPayment(amount float64, currency string) error
}

// Adaptee (third-party SDK with different interface)
type StripeSDK struct{}

func (s *StripeSDK) Charge(amountCents int64, cur string, source string) (*StripeCharge, error) {
    // Stripe-specific logic
    return &StripeCharge{ID: "ch_123"}, nil
}

// Adapter
type StripeAdapter struct {
    sdk    *StripeSDK
    source string
}

func NewStripeAdapter(apiKey string) *StripeAdapter {
    return &StripeAdapter{
        sdk:    &StripeSDK{},
        source: "tok_default",
    }
}

func (a *StripeAdapter) ProcessPayment(amount float64, currency string) error {
    amountCents := int64(amount * 100)
    _, err := a.sdk.Charge(amountCents, currency, a.source)
    return err
}

// Usage - client code works with PaymentProcessor interface
func checkout(processor PaymentProcessor, total float64) error {
    return processor.ProcessPayment(total, "USD")
}

func main() {
    stripe := NewStripeAdapter("sk_test_xxx")
    checkout(stripe, 99.99)
}

Decorator Pattern

// Component interface
type Handler interface {
    Handle(ctx context.Context, request *Request) (*Response, error)
}

// Concrete component
type UserHandler struct{}

func (h *UserHandler) Handle(ctx context.Context, request *Request) (*Response, error) {
    return &Response{Data: "user data"}, nil
}

// Decorators
type LoggingDecorator struct {
    handler Handler
    logger  *slog.Logger
}

func WithLogging(h Handler, logger *slog.Logger) Handler {
    return &LoggingDecorator{handler: h, logger: logger}
}

func (d *LoggingDecorator) Handle(ctx context.Context, request *Request) (*Response, error) {
    start := time.Now()
    d.logger.Info("Handling request", "path", request.Path)
    
    resp, err := d.handler.Handle(ctx, request)
    
    d.logger.Info("Request completed",
        "path", request.Path,
        "duration", time.Since(start),
        "error", err,
    )
    
    return resp, err
}

type CachingDecorator struct {
    handler Handler
    cache   Cache
    ttl     time.Duration
}

func WithCaching(h Handler, cache Cache, ttl time.Duration) Handler {
    return &CachingDecorator{handler: h, cache: cache, ttl: ttl}
}

func (d *CachingDecorator) Handle(ctx context.Context, request *Request) (*Response, error) {
    key := request.CacheKey()
    
    if cached, found := d.cache.Get(key); found {
        return cached.(*Response), nil
    }
    
    resp, err := d.handler.Handle(ctx, request)
    if err == nil {
        d.cache.Set(key, resp, d.ttl)
    }
    
    return resp, err
}

// Usage - decorators can be stacked
handler := &UserHandler{}
handler = WithLogging(handler, logger)
handler = WithCaching(handler, cache, time.Minute)

Middleware Pattern (Go Idiomatic Decorator)

type Middleware func(http.Handler) http.Handler

func Chain(h http.Handler, middlewares ...Middleware) http.Handler {
    for i := len(middlewares) - 1; i >= 0; i-- {
        h = middlewares[i](h)
    }
    return h
}

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()
            next.ServeHTTP(w, r)
            logger.Info("Request",
                "method", r.Method,
                "path", r.URL.Path,
                "duration", time.Since(start),
            )
        })
    }
}

func Auth(validator TokenValidator) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            token := r.Header.Get("Authorization")
            if !validator.Validate(token) {
                http.Error(w, "Unauthorized", http.StatusUnauthorized)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

// Usage
handler := Chain(
    myHandler,
    Logging(logger),
    Auth(tokenValidator),
    RateLimit(100),
)

Composite Pattern

// Component interface
type FileSystem interface {
    Name() string
    Size() int64
    Print(indent string)
}

// Leaf
type File struct {
    name string
    size int64
}

func (f *File) Name() string { return f.name }
func (f *File) Size() int64  { return f.size }
func (f *File) Print(indent string) {
    fmt.Printf("%s- %s (%d bytes)\n", indent, f.name, f.size)
}

// Composite
type Directory struct {
    name     string
    children []FileSystem
}

func (d *Directory) Name() string { return d.name }

func (d *Directory) Size() int64 {
    var total int64
    for _, child := range d.children {
        total += child.Size()
    }
    return total
}

func (d *Directory) Add(child FileSystem) {
    d.children = append(d.children, child)
}

func (d *Directory) Print(indent string) {
    fmt.Printf("%s+ %s/\n", indent, d.name)
    for _, child := range d.children {
        child.Print(indent + "  ")
    }
}

// Usage
root := &Directory{name: "root"}
src := &Directory{name: "src"}
src.Add(&File{name: "main.go", size: 1024})
src.Add(&File{name: "utils.go", size: 512})
root.Add(src)
root.Add(&File{name: "README.md", size: 256})

root.Print("")
// Output:
// + root/
//   + src/
//     - main.go (1024 bytes)
//     - utils.go (512 bytes)
//   - README.md (256 bytes)

Behavioral Patterns

Strategy Pattern

// Strategy interface
type CompressionStrategy interface {
    Compress(data []byte) ([]byte, error)
    Decompress(data []byte) ([]byte, error)
}

// Concrete strategies
type GzipStrategy struct{}

func (g *GzipStrategy) Compress(data []byte) ([]byte, error) {
    var buf bytes.Buffer
    w := gzip.NewWriter(&buf)
    _, err := w.Write(data)
    if err != nil {
        return nil, err
    }
    w.Close()
    return buf.Bytes(), nil
}

func (g *GzipStrategy) Decompress(data []byte) ([]byte, error) {
    r, err := gzip.NewReader(bytes.NewReader(data))
    if err != nil {
        return nil, err
    }
    defer r.Close()
    return io.ReadAll(r)
}

type ZlibStrategy struct{}

func (z *ZlibStrategy) Compress(data []byte) ([]byte, error) {
    var buf bytes.Buffer
    w := zlib.NewWriter(&buf)
    _, err := w.Write(data)
    if err != nil {
        return nil, err
    }
    w.Close()
    return buf.Bytes(), nil
}

func (z *ZlibStrategy) Decompress(data []byte) ([]byte, error) {
    r, err := zlib.NewReader(bytes.NewReader(data))
    if err != nil {
        return nil, err
    }
    defer r.Close()
    return io.ReadAll(r)
}

// Context
type Compressor struct {
    strategy CompressionStrategy
}

func NewCompressor(strategy CompressionStrategy) *Compressor {
    return &Compressor{strategy: strategy}
}

func (c *Compressor) SetStrategy(strategy CompressionStrategy) {
    c.strategy = strategy
}

func (c *Compressor) Compress(data []byte) ([]byte, error) {
    return c.strategy.Compress(data)
}

// Usage
compressor := NewCompressor(&GzipStrategy{})
compressed, _ := compressor.Compress(data)

// Switch strategy at runtime
compressor.SetStrategy(&ZlibStrategy{})
compressed, _ = compressor.Compress(data)

Strategy with Functions (Go Idiomatic)

type Hasher func(data []byte) []byte

func MD5Hash(data []byte) []byte {
    hash := md5.Sum(data)
    return hash[:]
}

func SHA256Hash(data []byte) []byte {
    hash := sha256.Sum256(data)
    return hash[:]
}

type Document struct {
    content []byte
    hasher  Hasher
}

func NewDocument(content []byte, hasher Hasher) *Document {
    return &Document{content: content, hasher: hasher}
}

func (d *Document) Hash() []byte {
    return d.hasher(d.content)
}

// Usage
doc := NewDocument([]byte("hello"), SHA256Hash)
hash := doc.Hash()

Observer Pattern

// Observer interface
type Observer interface {
    OnEvent(event Event)
}

type Event struct {
    Type    string
    Payload interface{}
}

// Subject
type EventBus struct {
    mu        sync.RWMutex
    observers map[string][]Observer
}

func NewEventBus() *EventBus {
    return &EventBus{
        observers: make(map[string][]Observer),
    }
}

func (b *EventBus) Subscribe(eventType string, observer Observer) {
    b.mu.Lock()
    defer b.mu.Unlock()
    b.observers[eventType] = append(b.observers[eventType], observer)
}

func (b *EventBus) Publish(event Event) {
    b.mu.RLock()
    observers := b.observers[event.Type]
    b.mu.RUnlock()
    
    for _, observer := range observers {
        go observer.OnEvent(event) // Async notification
    }
}

// Concrete observers
type EmailNotifier struct{}

func (e *EmailNotifier) OnEvent(event Event) {
    if event.Type == "order.created" {
        order := event.Payload.(*Order)
        sendEmail(order.CustomerEmail, "Order Confirmation", "...")
    }
}

type InventoryUpdater struct{}

func (i *InventoryUpdater) OnEvent(event Event) {
    if event.Type == "order.created" {
        order := event.Payload.(*Order)
        updateInventory(order.Items)
    }
}

// Usage
bus := NewEventBus()
bus.Subscribe("order.created", &EmailNotifier{})
bus.Subscribe("order.created", &InventoryUpdater{})

bus.Publish(Event{Type: "order.created", Payload: order})

Observer with Channels (Go Idiomatic)

type EventBus struct {
    subscribers map[string][]chan Event
    mu          sync.RWMutex
}

func NewEventBus() *EventBus {
    return &EventBus{
        subscribers: make(map[string][]chan Event),
    }
}

func (b *EventBus) Subscribe(eventType string) <-chan Event {
    b.mu.Lock()
    defer b.mu.Unlock()
    
    ch := make(chan Event, 10)
    b.subscribers[eventType] = append(b.subscribers[eventType], ch)
    return ch
}

func (b *EventBus) Publish(event Event) {
    b.mu.RLock()
    defer b.mu.RUnlock()
    
    for _, ch := range b.subscribers[event.Type] {
        select {
        case ch <- event:
        default:
            // Channel full, skip or log
        }
    }
}

// Usage
bus := NewEventBus()

orderEvents := bus.Subscribe("order.created")
go func() {
    for event := range orderEvents {
        order := event.Payload.(*Order)
        processOrder(order)
    }
}()

bus.Publish(Event{Type: "order.created", Payload: order})

State Pattern

// State interface
type OrderState interface {
    Process(order *Order) error
    Cancel(order *Order) error
    Ship(order *Order) error
    String() string
}

type Order struct {
    ID    string
    Items []Item
    State OrderState
}

func NewOrder(id string, items []Item) *Order {
    order := &Order{ID: id, Items: items}
    order.State = &PendingState{}
    return order
}

func (o *Order) Process() error { return o.State.Process(o) }
func (o *Order) Cancel() error  { return o.State.Cancel(o) }
func (o *Order) Ship() error    { return o.State.Ship(o) }

// Concrete states
type PendingState struct{}

func (s *PendingState) String() string { return "pending" }

func (s *PendingState) Process(order *Order) error {
    // Validate inventory, charge payment
    order.State = &ProcessingState{}
    return nil
}

func (s *PendingState) Cancel(order *Order) error {
    order.State = &CancelledState{}
    return nil
}

func (s *PendingState) Ship(order *Order) error {
    return errors.New("cannot ship pending order")
}

type ProcessingState struct{}

func (s *ProcessingState) String() string { return "processing" }

func (s *ProcessingState) Process(order *Order) error {
    return errors.New("already processing")
}

func (s *ProcessingState) Cancel(order *Order) error {
    // Refund payment
    order.State = &CancelledState{}
    return nil
}

func (s *ProcessingState) Ship(order *Order) error {
    order.State = &ShippedState{}
    return nil
}

type ShippedState struct{}

func (s *ShippedState) String() string { return "shipped" }
func (s *ShippedState) Process(order *Order) error { return errors.New("already shipped") }
func (s *ShippedState) Cancel(order *Order) error { return errors.New("cannot cancel shipped order") }
func (s *ShippedState) Ship(order *Order) error { return errors.New("already shipped") }

type CancelledState struct{}

func (s *CancelledState) String() string { return "cancelled" }
func (s *CancelledState) Process(order *Order) error { return errors.New("order cancelled") }
func (s *CancelledState) Cancel(order *Order) error { return errors.New("already cancelled") }
func (s *CancelledState) Ship(order *Order) error { return errors.New("order cancelled") }

// Usage
order := NewOrder("123", items)
order.Process() // pending -> processing
order.Ship()    // processing -> shipped
order.Cancel()  // Error: cannot cancel shipped order

Go-Specific Patterns

Embedding (Composition over Inheritance)

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

// Embedding interfaces
type ReadWriter interface {
    Reader
    Writer
}

// Embedding structs
type Logger struct {
    prefix string
}

func (l *Logger) Log(msg string) {
    fmt.Printf("[%s] %s\n", l.prefix, msg)
}

type Server struct {
    *Logger  // Embedded - Server "has-a" Logger and exposes its methods
    port int
}

func NewServer(port int) *Server {
    return &Server{
        Logger: &Logger{prefix: "SERVER"},
        port:   port,
    }
}

// Usage - Server has Log method via embedding
server := NewServer(8080)
server.Log("Starting...") // Calls embedded Logger.Log

Interface Segregation

// ❌ Bad: Large interface
type Repository interface {
    Create(entity interface{}) error
    Read(id string) (interface{}, error)
    Update(entity interface{}) error
    Delete(id string) error
    List(filter Filter) ([]interface{}, error)
    Count(filter Filter) (int, error)
    BeginTransaction() (Transaction, error)
    // ... many more methods
}

// ✅ Good: Small, focused interfaces
type Reader interface {
    Read(id string) (interface{}, error)
}

type Writer interface {
    Create(entity interface{}) error
    Update(entity interface{}) error
}

type Deleter interface {
    Delete(id string) error
}

type Lister interface {
    List(filter Filter) ([]interface{}, error)
}

// Compose as needed
type ReadWriter interface {
    Reader
    Writer
}

type Repository interface {
    Reader
    Writer
    Deleter
    Lister
}

Interview Questions

Go uses interfaces and composition instead of 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
Functional options use variadic functions to configure objects. Use when:
  • You have many optional configuration parameters
  • You want clean, readable construction
  • You need validation during configuration
  • Default values should be easy to override
Go favors constructor injection:
  • 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
  • Embedding: Type is embedded directly, methods are promoted
  • Composition: Type is a field, access via field name
Embedding provides cleaner syntax but can lead to ambiguity. Use composition when you want explicit access patterns.

Summary

PatternGo Implementation
FactoryConstructor functions
BuilderFluent methods or functional options
Singletonsync.Once or package-level init
AdapterWrapper struct implementing target interface
DecoratorMiddleware pattern with function composition
StrategyInterface + concrete implementations or functions
ObserverChannels or subscriber interface
StateInterface per state, context holds current state

Interview Deep-Dive

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.
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.
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.