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
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
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]
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!
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
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!
}
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}
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)