DEV Community

Shrijith Venkatramana
Shrijith Venkatramana

Posted on

Level Up Your Go Code: Must-Know Design Patterns for Cleaner, Scalable Apps

Hi there! I'm Shrijith Venkatrama, founder of Hexmos. Right now, I’m building LiveAPI, a first of its kind tool for helping you automatically index API endpoints across all your repositories. LiveAPI helps you discover, understand and use APIs in large tech infrastructures with ease.

Design patterns in Go help you write code that's easier to maintain, scale, and understand. Go’s simplicity doesn’t mean you skip patterns—it means you use them thoughtfully. This post dives into the most useful design patterns for Go developers, with practical examples you can compile and run. We’ll cover 7 patterns, each with clear explanations, code, and tables to break things down. Let’s get started.

1. Singleton: One Instance, No Fuss

The Singleton pattern ensures a single instance of a struct exists and provides global access to it. In Go, you don’t need complex tricks—goroutines and sync.Once make it clean and thread-safe. Use this when you need one shared resource, like a database connection or logger.

Why it’s useful: Prevents multiple instances from causing chaos (e.g., multiple DB connections overwriting each other).

When to use: Logging, configuration managers, or connection pools.

Watch out: Overuse can make testing harder and code less modular.

Here’s a thread-safe Singleton for a logger:

package main

import (
    "fmt"
    "sync"
)

type Logger struct {
    name string
}

var instance *Logger
var once sync.Once

func GetLogger() *Logger {
    once.Do(func() {
        instance = &Logger{name: "AppLogger"}
    })
    return instance
}

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

func main() {
    logger1 := GetLogger()
    logger2 := GetLogger()
    logger1.Log("Hello from logger1")
    logger2.Log("Hello from logger2")
    fmt.Println("Same instance?", logger1 == logger2)
}

// Output:
// [AppLogger] Hello from logger1
// [AppLogger] Hello from logger2
// Same instance? true
Enter fullscreen mode Exit fullscreen mode

Table: Singleton Pros and Cons

Pros Cons
Ensures one instance Can complicate testing
Thread-safe with sync.Once Global state can hide bugs
Simple to implement in Go Overuse reduces modularity

Learn more: Go’s sync package.

2. Factory: Build Objects the Easy Way

The Factory pattern creates objects without exposing instantiation logic. In Go, this often means a function that returns an interface, letting you swap implementations. Use it when you have multiple types that share a common interface but need different setups.

Why it’s useful: Keeps object creation clean and flexible.

When to use: When you have multiple structs implementing the same interface.

Watch out: Don’t overcomplicate simple structs with factories.

Here’s a Factory for different payment methods:

package main

import "fmt"

type Payment interface {
    Pay(amount float64) string
}

type CreditCard struct{}
type PayPal struct{}

func (c *CreditCard) Pay(amount float64) string {
    return fmt.Sprintf("Paid %.2f with CreditCard", amount)
}

func (p *PayPal) Pay(amount float64) string {
    return fmt.Sprintf("Paid %.2f with PayPal", amount)
}

func PaymentFactory(paymentType string) (Payment, error) {
    switch paymentType {
    case "credit":
        return &CreditCard{}, nil
    case "paypal":
        return &PayPal{}, nil
    default:
        return nil, fmt.Errorf("unknown payment type: %s", paymentType)
    }
}

func main() {
    credit, _ := PaymentFactory("credit")
    paypal, _ := PaymentFactory("paypal")
    fmt.Println(credit.Pay(100.50))
    fmt.Println(paypal.Pay(75.25))
}

// Output:
// Paid 100.50 with CreditCard
// Paid 75.25 with PayPal
Enter fullscreen mode Exit fullscreen mode

Table: Factory Use Cases

Use Case Example
Multiple struct types Payment methods, DB drivers
Hide complex setup logic API clients with configs
Swap implementations easily Mock objects for testing

Learn more: Go interfaces.

3. Builder: Step-by-Step Object Creation

The Builder pattern constructs complex objects step by step. In Go, this is great for structs with many optional fields, avoiding messy constructors or giant parameter lists. It’s like a fluent API for building structs.

Why it’s useful: Makes code readable and avoids invalid states.

When to use: Config structs, HTTP requests, or complex models.

Watch out: Can be overkill for simple structs.

Here’s a Builder for an HTTP request:

package main

import (
    "fmt"
    "net/http"
)

type RequestBuilder struct {
    method  string
    url     string
    headers map[string]string
}

func NewRequestBuilder() *RequestBuilder {
    return &RequestBuilder{
        headers: make(map[string]string),
    }
}

func (rb *RequestBuilder) Method(method string) *RequestBuilder {
    rb.method = method
    return rb
}

func (rb *RequestBuilder) URL(url string) *RequestBuilder {
    rb.url = url
    return rb
}

func (rb *RequestBuilder) Header(key, value string) *RequestBuilder {
    rb.headers[key] = value
    return rb
}

func (rb *RequestBuilder) Build() (*http.Request, error) {
    req, err := http.NewRequest(rb.method, rb.url, nil)
    if err != nil {
        return nil, err
    }
    for k, v := range rb.headers {
        req.Header.Set(k, v)
    }
    return req, nil
}

func main() {
    req, _ := NewRequestBuilder().
        Method("GET").
        URL("https://api.example.com").
        Header("Authorization", "Bearer token123").
        Build()
    fmt.Printf("Method: %s, URL: %s, Headers: %v\n", req.Method, req.URL, req.Header)
}

// Output:
// Method: GET, URL: https://api.example.com, Headers: map[Authorization:Bearer token123]
Enter fullscreen mode Exit fullscreen mode

Learn more: Effective Go: Structs.

4. Strategy: Swap Behaviors on the Fly

The Strategy pattern lets you define interchangeable algorithms or behaviors via interfaces. In Go, this shines with interfaces and dependency injection, making code flexible and testable.

Why it’s useful: Easily swap implementations without changing the core logic.

When to use: Sorting algorithms, data formatters, or business rules.

Watch out: Too many strategies can clutter your codebase.

Here’s a Strategy for text formatters:

package main

import (
    "fmt"
    "strings"
)

type Formatter interface {
    Format(text string) string
}

type UpperCaseFormatter struct{}
type LowerCaseFormatter struct{}

func (u *UpperCaseFormatter) Format(text string) string {
    return strings.ToUpper(text)
}

func (l *LowerCaseFormatter) Format(text string) string {
    return strings.ToLower(text)
}

type TextProcessor struct {
    formatter Formatter
}

func (tp *TextProcessor) SetFormatter(f Formatter) {
    tp.formatter = f
}

func (tp *TextProcessor) Process(text string) string {
    return tp.formatter.Format(text)
}

func main() {
    processor := &TextProcessor{}
    processor.SetFormatter(&UpperCaseFormatter{})
    fmt.Println(processor.Process("Hello, Go!"))
    processor.SetFormatter(&LowerCaseFormatter{})
    fmt.Println(processor.Process("Hello, Go!"))
}

// Output:
// HELLO, GO!
// hello, go!
Enter fullscreen mode Exit fullscreen mode

Table: Strategy Benefits

Benefit Example
Swap behavior at runtime Change formatters
Easy to test Mock strategies
Clean interface separation Sorting or encoding logic

5. Observer: Keep Everyone in the Loop

The Observer pattern lets objects subscribe to events and get notified when things change. In Go, channels and goroutines make this pattern natural for event-driven systems.

Why it’s useful: Decouples publishers from subscribers.

When to use: Event systems, pub/sub, or real-time updates.

Watch out: Memory leaks if subscribers aren’t cleaned up.

Here’s an Observer for stock price updates:

package main

import (
    "fmt"
    "sync"
)

type Stock struct {
    symbol     string
    price      float64
    subscribers []chan float64
    mu         sync.Mutex
}

func NewStock(symbol string, price float64) *Stock {
    return &Stock{symbol: symbol, price: price}
}

func (s *Stock) Subscribe() chan float64 {
    s.mu.Lock()
    defer s.mu.Unlock()
    ch := make(chan float64)
    s.subscribers = append(s.subscribers, ch)
    return ch
}

func (s *Stock) UpdatePrice(price float64) {
    s.mu.Lock()
    s.price = price
    for _, ch := range s.subscribers {
        ch <- price
    }
    s.mu.Unlock()
}

func main() {
    stock := NewStock("GOOG", 1000.0)
    sub1 := stock.Subscribe()
    sub2 := stock.Subscribe()

    go func() {
        for price := range sub1 {
            fmt.Printf("Sub1: %s price updated to %.2f\n", stock.symbol30, price)
        }
    }()
    go func() {
        for price := range sub2 {
            fmt.Printf("Sub2: %s price updated to %.2f\n", stock.symbol, price)
        }
    }()

    stock.UpdatePrice(1050.0)
    stock.UpdatePrice(1100.0)
    // Allow goroutines to process
    <-time.After(time.Millisecond * 100)
}

// Output (order may vary):
// Sub1: GOOG price updated to 1050.00
// Sub2: GOOG price updated to 1050.00
// Sub1: GOOG price updated to 1100.00
// Sub2: GOOG price updated to 1100.00
Enter fullscreen mode Exit fullscreen mode

Learn more: Go channels.

6. Decorator: Add Features Without Messing Up Code

The Decorator pattern adds behavior to objects without modifying their core code. In Go, this often means wrapping structs with additional functionality via composition.

Why it’s useful: Extends functionality cleanly.

When to use: Middleware, logging wrappers, or feature toggles.

Watch out: Too many decorators can make code hard to follow.

Here’s a Decorator for logging HTTP handlers:

package main

import (
    "fmt"
    "net/http"
)

type HandlerFunc func(http.ResponseWriter, *http.Request)

func Logger(handler HandlerFunc) HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        fmt.Printf("Request received: %s %s\n", r.Method, r.URL.Path)
        handler(w, r)
    }
}

func HelloHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Hello, Go!")
}

func main() {
    http.HandleFunc("/", Logger(HelloHandler))
    go http.ListenAndServe(":8080", nil)
    fmt.Println("Server running on :8080")
    // Visit http://localhost:8080 in browser or curl
    // Output in console:
    // Server running on :8080
    // Request received: GET /
    // Browser output: Hello, Go!
}
Enter fullscreen mode Exit fullscreen mode

7. Repository: Keep Data Access Clean

The Repository pattern abstracts data access logic, making it easy to swap databases or test with mocks. In Go, this means an interface for data operations and a concrete struct for the actual logic.

Why it’s useful: Isolates business logic from database code.

When to use: Database access, API clients, or file operations.

Watch out: Don’t over-abstract simple CRUD operations.

Here’s a Repository for a user store:

package main

import (
    "fmt"
    "sync"
)

type User struct {
    ID   int
    Name string
}

type UserRepository interface {
    Save(user User) error
    Find(id int) (User, error)
}

type InMemoryUserRepo struct {
    users map[int]User
    mu    sync.Mutex
}

func NewInMemoryUserRepo() *InMemoryUserRepo {
    return &InMemoryUserRepo{users: make(map[int]User)}
}

func (r *InMemoryUserRepo) Save(user User) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.users[user.ID] = user
    return nil
}

func (r *InMemoryUserRepo) Find(id int) (User, error) {
    r.mu.Lock()
    defer r.mu.Unlock()
    user, exists := r.users[id]
    if !exists {
        return User{}, fmt.Errorf("user %d not found", id)
    }
    return user, nil
}

func main() {
    repo := NewInMemoryUserRepo()
    repo.Save(User{ID: 1, Name: "Alice"})
    user, _ := repo.Find(1)
    fmt.Printf("Found user: %+v\n", user)
}

// Output:
// Found user: {ID:1 Name:Alice}
Enter fullscreen mode Exit fullscreen mode

Learn more: Go database/sql.

Keep Your Go Code Sharp

These patterns—Singleton, Factory, Builder, Strategy, Observer, Decorator, and Repository—aren’t just academic exercises. They solve real problems in Go, like managing concurrency, abstracting data access, or making code extensible. Use them when they fit, but don’t force them. Go’s simplicity means you can often solve problems without patterns, but when you need structure, these are battle-tested tools. Pick the right one, keep your code clean, and make your apps easier to maintain and scale.

Top comments (0)