Design patterns are reusable solutions to common problems in software design.

They provide a shared language for developers and encourage best practices in system architecture.

In this article, we’ll explore some of the most widely used design patterns in Go, grouped into three categories: creational, structural, and behavioral.

Design Patterns in Go

๐Ÿ”ง Creational Patterns

  1. ๐Ÿ”‚ Singleton

    Ensures a class has only one instance and provides a global point of access to it.

    โ€œWhen discussing which pattern to drop, we found that we still love them all. (Not really โ€” I’m in favor of dropping Singleton. Its use is almost always a design smell.)โ€
    โ€” Erich Gamma, Design Patterns: Elements of Reusable Object-Oriented Software

    While Singleton often gets a bad reputation, there are still valid use cases in Go:

    • โœ… You only want one component in the system (e.g., database repository, object factory)
    • โณ The object is expensive to construct, so you instantiate it only once
    • ๐Ÿšซ You want to prevent the creation of additional instances
    • ๐Ÿ’ค You want lazy instantiation (e.g. load config or connect to DB only when needed)

    Go makes this easy and thread-safe with sync.Once. To stay testable and modular, follow the Dependency Inversion Principle (DIP) โ€” depend on interfaces, not concrete types.

    Hint:

    Singleton quite often breaks the Dependency Inversion Principle!

    ๐Ÿง‘โ€๐Ÿ’ป Example:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    
    package singleton
    
    import (
        "sync"
    )
    
    var (
        instance *singleton
        once     sync.Once
    )
    
    type singleton struct{}
    
    func GetInstance() *singleton {
        once.Do(func() {
            instance = &singleton{}
    })
    
    return instance
    }
    

    ๐Ÿงช Usage

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    
    package main
    
    import (
    "fmt"
    "singleton"
    )
    
    func main() {
       a := singleton.GetInstance()
       b := singleton.GetInstance()
    
       fmt.Println(a.Value) // Output: I am the only one
    
       // Confirm both variables point to the same instance
       fmt.Println(a == b) // Output: true
    }
    
  2. ๐Ÿญ Factory

    Creates objects without specifying the exact class.

    A factory helps simplify object creation when:

    • ๐ŸŒ€ Object creation logic becomes too convoluted
    • ๐Ÿงฑ A struct has too many fields that need to be correctly initialized
    • ๐Ÿ’ก You want to delegate creation logic away from the calling code

    There are two flavors of factories in Go:

    • ๐Ÿ”ง Factory function (also called a constructor): a helper function to initialize struct instances
    • ๐Ÿ—๏ธ Factory struct: a dedicated struct responsible for managing object creation

    Unlike the Builder pattern, which is piecewise, the Factory creates the object wholesale โ€” usually in one go.

    ๐Ÿง‘โ€๐Ÿ’ป Example:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    
    package factory
    
    type Shape interface {
    Draw() string
    }
    
    type Circle struct{}
    func (c Circle) Draw() string { return "Drawing Circle" }
    
    type Square struct{}
    func (s Square) Draw() string { return "Drawing Square" }
    
    func GetShape(shapeType string) Shape {
        switch shapeType {
        case "circle":
            return Circle{}
        case "square":
            return Square{}
        default:
            return nil
        }
    }
    

    ๐Ÿงช Usage

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
    package main
    
    import (
    "fmt"
    "factory"
    )
    
    func main() {
        circle := factory.GetShape("circle")
        square := factory.GetShape("square")
    
        fmt.Println(circle.Draw()) // Output: Drawing Circle
        fmt.Println(square.Draw()) // Output: Drawing Square
    }
    
  3. ๐Ÿงฑ Builder

    Separates the construction of a complex object from its representation.

    Not all objects are created equal:

    • โœ… Some are simple and can be created with a single constructor call
    • โš ๏ธ Others require a lot of ceremony to set up
    • ๐Ÿงฉ Factory functions with 10+ parameters become hard to use and maintain

    When you want more flexibility and readability, use the Builder pattern.

    • ๐Ÿ› ๏ธ A Builder is a separate component used to construct an object step-by-step
    • ๐Ÿ”„ It exposes a fluent API โ€” each method returns the receiver (*Builder) to enable chaining
    • ๐Ÿง  In advanced designs, different builders can operate on different facets of the same object

    ๐Ÿง‘โ€๐Ÿ’ป Example:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    
    package builder
    
    type Car struct {
        Engine string
        Wheels int
        Color  string
    }
    
    type CarBuilder struct {
        car Car
    }
    
    func (b *CarBuilder) SetEngine(engine string) *CarBuilder {
        b.car.Engine = engine
        return b
    }
    
    func (b *CarBuilder) SetWheels(wheels int) *CarBuilder {
        b.car.Wheels = wheels
        return b
    }
    
    func (b *CarBuilder) SetColor(color string) *CarBuilder {
        b.car.Color = color
        return b
    }
    
    func (b *CarBuilder) Build() Car {
        return b.car
    }
    

    ๐Ÿงช Usage

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    
    package main
    
    import (
         "fmt"
         "builder"
    )
    
    func main() {
        car := builder.CarBuilder{}.
        SetEngine("V8").
        SetWheels(4).
        SetColor("Red").
        Build()
    
            fmt.Printf("%+v\n", car)
            // Output: {Engine:V8 Wheels:4 Color:Red}
    }
    

๐Ÿงฉ Structural Patterns

1.๐Ÿ”Œ Adapter

Allows incompatible interfaces to work together.

An Adapter is a design construct that adapts an existing interface X to conform to the required interface Y. It acts as a translator or bridge between two systems that otherwise couldnโ€™t work together.

๐Ÿงญ To implement an adapter in Go:

- ๐Ÿ” Determine the **API you have** (e.g. `Adaptee`)
- ๐ŸŽฏ Define the **API you need** (e.g. `Target`)
- ๐Ÿงฉ Create an adapter struct that **aggregates** the adaptee (usually via a pointer)
- โšก Optimize when needed โ€” adapters may introduce intermediate representations, so use **caching** or other performance strategies as required

This is especially useful when integrating legacy code or 3rd-party libraries into a new system with different interfaces.

๐Ÿง‘โ€๐Ÿ’ป Example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
 package adapter
 
 type Target interface {
     Request() string
 }
 
 type Adaptee struct{}
     func (a Adaptee) SpecificRequest() string {
         return "Specific behavior"
 }
 
 type Adapter struct {
     adaptee Adaptee
 }
 
 func (a Adapter) Request() string {
     return a.adaptee.SpecificRequest()
 }

๐Ÿงช Usage

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package main

import (
    "fmt"
    "adapter"
)

func main() {
    adaptee := adapter.Adaptee{}
    adapterInstance := adapter.Adapter{Adaptee: adaptee}

    var target adapter.Target = adapterInstance
    fmt.Println(target.Request()) // Output: Specific behavior
}
  1. ๐ŸŽ€ Decorator

    Adds behavior to objects dynamically by embedding and extending existing functionality.

    The Decorator pattern is used when you want to:

    • โž• Augment an object with additional behavior
    • ๐Ÿšซ Avoid modifying existing code (โœ… Open/Closed Principle โ€” OCP)
    • ๐Ÿงผ Keep new functionality separate and modular (โœ… Single Responsibility Principle โ€” SRP)
    • ๐Ÿ”„ Retain the ability to interact with existing interfaces

    The solution is to embed the decorated object and override or extend its behavior. This lets you build stackable, reusable enhancements without altering the base struct.

    ๐Ÿง‘โ€๐Ÿ’ป Example: wrapping a basic Coffee with a MilkDecorator:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    package decorator
    
    type Coffee interface {
        Cost() float64
    }
    
    type SimpleCoffee struct{}
        func (s SimpleCoffee) Cost() float64 { 
            return 2.0 
        }
    
    type MilkDecorator struct {
        Coffee
    }
    
    func (m MilkDecorator) Cost() float64 {
        return m.Coffee.Cost() + 0.5
    }
    

    ๐Ÿงช Usage

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
    package main
    
    import (
    "fmt"
    "decorator"
    )
    
    func main() {
        var coffee decorator.Coffee = decorator.SimpleCoffee{}
        fmt.Println("Base cost:", coffee.Cost()) // Output: 2.0
    
        coffeeWithMilk := decorator.MilkDecorator{Coffee: coffee}
        fmt.Println("With milk:", coffeeWithMilk.Cost()) // Output: 2.5
    }
    
  2. ๐Ÿ›ก Proxy

    Provides a surrogate or placeholder.

    ๐Ÿง‘โ€๐Ÿ’ป Example:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    
     package proxy
    
     type Image interface {
         Display() string
     }
    
     type RealImage struct {
         filename string
     }
    
     func (r RealImage) Display() string {
         return "Displaying " + r.filename
     }
    
     type ProxyImage struct {
         realImage *RealImage
         filename  string
     }
    
     func (p *ProxyImage) Display() string {
         if p.realImage == nil {
         p.realImage = &RealImage{filename: p.filename}
         }
    
         return p.realImage.Display()
     }
    

    ๐Ÿงช Usage

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    
    package main
    
    import (
        "fmt"
        "proxy"
    )
    
    func main() {
        img := &proxy.ProxyImage{filename: "cat.png"}
    
        // The real image is not loaded yet
        fmt.Println(img.Display()) // Output: Displaying cat.png
    
        // The real image is reused without reloading
        fmt.Println(img.Display()) // Output: Displaying cat.png
    }
    
  3. ๐ŸŒณ Composite

    Composes objects into tree structures. Composes objects into tree structures and lets you treat individual and composite objects uniformly.

    The Composite pattern is ideal when some components are single objects (like files), and others are containers of other components (like folders). Both should support a common interface so clients donโ€™t need to differentiate between them.

    ๐Ÿงญ To implement a composite in Go:

    • ๐Ÿงฑ Define a common interface that all components implement.

    • ๐ŸŒฟ Implement Leaf objects (e.g. File, Button, TextField).

    • ๐Ÿงบ Implement Composite objects (e.g. Folder, Panel) that aggregate children and delegate behavior to them.

    • ๐Ÿ” Add iteration if you need to traverse or walk the tree (e.g. using the Iterator pattern).

    This pattern shines when building hierarchical or nested structures such as UI components, file systems, or organization charts.

    ๐Ÿง‘โ€๐Ÿ’ป Example:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    
    package composite
    
    type Component interface {
        Operation() string
    }
    
    type Leaf struct {
        name string
    }
    
    func (l Leaf) Operation() string {
        return l.name
    }
    
    type Composite struct {
        children []Component
    }
    
    func (c *Composite) Add(child Component) {
        c.children = append(c.children, child)
    }
    
    func (c *Composite) Operation() string {
        result := ""
        for _, child := range c.children {
            result += child.Operation() + " "
        }
    
        return result
    }
    

    ๐Ÿ“ฆ Example usage:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    func main() {
    file1 := Leaf{name: "FileA.txt"}
    file2 := Leaf{name: "FileB.txt"}
    
        folder := &Composite{}
        folder.Add(file1)
        folder.Add(file2)
    
        fmt.Println(folder.Operation()) // Output: FileA.txt FileB.txt
    }
    

    โœ… When to use Composite:

    • You want to treat individual and group objects the same way
    • You have recursive or nested structures
    • You want to delegate behavior to child components

    ๐Ÿ” Bonus: Pair with the Iterator pattern to walk tree structures cleanly without exposing their internal representation.


๐Ÿง  Behavioral Patterns

  1. ๐Ÿงฎ Strategy

    Defines a family of algorithms.

    Encapsulates a family of algorithms and allows them to be selected and swapped at runtime.

    The Strategy pattern is used when you want to:

    • ๐Ÿง  Separate an algorithm into its skeleton and implementation steps
    • ๐Ÿงฉ Decompose behavior into high-level workflows and low-level operations
    • ๐Ÿ”„ Swap logic dynamically without changing the calling code
    • โœ… Adhere to the Open/Closed Principle (OCP) โ€” new strategies without changing the high-level logic

    The solution is to define a high-level algorithm that delegates part of its logic to an injected strategy. This strategy follows a shared interface, so any implementation can be plugged in without breaking the algorithm. ๐Ÿต Analogy: making a hot beverage

    Many real-world algorithms follow this structure. Take making tea as an example:

    Skeleton algorithm:
    Boil water โ†’ Pour into cup โ†’ Add ingredient
    
    Concrete implementation:
    Add tea bag, coffee grounds, or cocoa powder
    

    The high-level process is reusable, and the final step is delegated to a drink-specific strategy. This is exactly how Strategy works.

    ๐Ÿง‘โ€๐Ÿ’ป Example: Choosing an operation strategy

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    
     package strategy
    
     type Strategy interface {
         Execute(a, b int) int
     }
    
     type Add struct{}
     func (Add) Execute(a, b int) int { 
         return a + b 
     }
    
     type Multiply struct{}
     func (Multiply) Execute(a, b int) int { 
         return a * b 
     }
    
     type Context struct {
         strategy Strategy
     }
    
     func (c *Context) SetStrategy(s Strategy) {
         c.strategy = s
     }
    
     func (c Context) ExecuteStrategy(a, b int) int {
         return c.strategy.Execute(a, b)
     }
    

    ๐Ÿงช Usage

    1
    2
    3
    4
    5
    6
    7
    
    ctx := strategy.Context{}
    
    ctx.SetStrategy(strategy.Add{})
    fmt.Println(ctx.ExecuteStrategy(3, 4)) // Output: 7
    
    ctx.SetStrategy(strategy.Multiply{})
    fmt.Println(ctx.ExecuteStrategy(3, 4)) // Output: 12
    

    By:

    • Defining a common interface (Strategy)
    • Creating multiple concrete strategies (Add, Multiply)
    • Supporting runtime injection into a reusable context (Context)

    You separate the structure of the algorithm from its implementation. Just like boiling water and pouring it into a cup โ€” what happens next depends on the drink you’re making.

    This makes your code modular, extensible, and easy to adapt to new behaviors without touching your existing flow.

  2. ๐Ÿ‘€ Observer

    Wants to listen to events and be notified when something happens.

    The Observer pattern is used when you want to:

    • ๐Ÿ“ฃ Be informed when a particular object changes state, does something, or reacts to an external event
    • ๐Ÿ‘‚ Let other objects (observers) subscribe to and react to those changes
    • ๐Ÿ”„ Decouple the source of truth from those reacting to it
    • โœ… Support dynamic subscription and unsubscription

    The solution is to have two participants:

    • ๐ŸŸข Observable: emits events and holds a list of observers
    • ๐ŸŸก Observer: subscribes and reacts to events

    When the observable changes, it notifies all observers โ€” sending event data (commonly as interface{} in Go) to each subscriber. This is an intrusive approach since the observable must provide explicit subscription management.

    ๐Ÿง‘โ€๐Ÿ’ป Example:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    
     package observer
    
     type Observer interface {
         Update(string)
     }
    
     type Subject interface {
         Attach(Observer)
         Notify()
     }
    
     type ConcreteSubject struct {
         observers []Observer
         state     string
     }
    
     func (s *ConcreteSubject) Attach(o Observer) {
         s.observers = append(s.observers, o)
     }
    
     func (s *ConcreteSubject) SetState(state string) {
         s.state = state
         s.Notify()
     }
    
     func (s *ConcreteSubject) Notify() {
         for _, o := range s.observers {
             o.Update(s.state)
         }
     }
    
     type ConcreteObserver struct {
         id string
     }
    
     func (o ConcreteObserver) Update(state string) {
         println("Observer", o.id, "received new state:", state)
     }
    

    ๐Ÿงช Usage

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    subject := &observer.ConcreteSubject{}
    
    observer1 := observer.ConcreteObserver{id: "A"}
    observer2 := observer.ConcreteObserver{id: "B"}
    
    subject.Attach(observer1)
    subject.Attach(observer2)
    
    subject.SetState("๐Ÿš€ Launching")
    // Output:
    // Observer A received new state: ๐Ÿš€ Launching
    // Observer B received new state: ๐Ÿš€ Launching
    

    With Observer, you give objects the ability to react automatically to changes elsewhere, without tightly coupling them together. This pattern is especially helpful for:

    • UIs reacting to data changes
    • Logging and monitoring
    • Event-based systems

    Hint:

    This approach is intrusive โ€” the observable must explicitly support subscriptions and notify logic.

  3. ๐Ÿ” State

    Allows an object to alter its behavior when its internal state changes โ€” effectively changing its class at runtime.

    The State pattern is used when you want to:

    • ๐Ÿ”„ Let an object change behavior dynamically based on its current state
    • ๐Ÿ“ฒ Model real-world systems where actions depend on state
    • ๐Ÿง  Manage complex state logic in a modular, maintainable way

    The solution is to encapsulate each state in its own type and let the context object delegate behavior to the current state. When the state changes, so does the object’s behavior โ€” without conditionals scattered throughout the code.

    These transitions are triggered by events (e.g. dialing, picking up, hanging up), and actions vary depending on the state. This is a perfect fit for a state machine โ€” a formal model that defines:

    • ๐Ÿ“ฅ Entry/exit actions for each state
    • ๐Ÿ”„ Transitions between states, often triggered by events
    • โœ… Guards that control whether a transition is allowed
    • โš™๏ธ A default behavior if no transition is found

    When systems grow in complexity, it pays to define states and transitions explicitly to keep logic clean and modular.

    ๐Ÿง‘โ€๐Ÿ’ป Example:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    
     package state
    
     type State interface {
         Handle() string
     }
    
     type Context struct {
         state State
     }
    
     func (c *Context) SetState(s State) {
         c.state = s
     }
    
     func (c Context) Request() string {
         return c.state.Handle()
     }
    
     type OnState struct{}
     func (OnState) Handle() string { 
         return "State is ON" 
     }
    
     type OffState struct{}
     func (OffState) Handle() string { 
         return "State is OFF" 
     }
    

    ๐Ÿงช Usage

    1
    2
    3
    4
    5
    6
    7
    
    ctx := state.Context{}
    
    ctx.SetState(state.OnState{})
    fmt.Println(ctx.Request()) // Output: State is ON
    
    ctx.SetState(state.OffState{})
    fmt.Println(ctx.Request()) // Output: State is OFF
    

    With the State pattern:

    • You encapsulate each state and its logic in a separate type
    • The object transitions explicitly in response to triggers
    • Behavior is cleanly modular, without long chains of if or switch

    Whether you’re modeling a telephone, a TCP connection, or a video player, state machines help you handle transitions with clarity, flexibility, and control.


โœ… Conclusion

Design patterns are powerful tools in every Go developerโ€™s toolkit. While Go encourages simplicity, these patterns still applyโ€”especially in large-scale systems or when writing reusable libraries. Using patterns like Singleton, Adapter, and Strategy can lead to cleaner, more testable, and maintainable code.

Happy Go coding! ๐Ÿน


๐Ÿš€ Follow me on norbix.dev for more insights on Go, system design, and engineering wisdom.