Skip to main content

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.

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)

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