11 Design Patterns Overview of Commonly Used Go Design Patterns

11 Design Patterns Overview of Commonly Used Go Design Patterns #

Hello, I’m Kong Lingfei. Today, let’s talk about the common design patterns used in Go project development.

In software development, various coding scenarios often occur repeatedly, making them typical. For these typical scenarios, we can solve them through coding on our own, or we can adopt a more time-saving and efficient approach: using design patterns.

So what exactly are design patterns? Simply put, design patterns are abstract models that provide solutions for repetitive coding scenarios in software development according to best practices. By using design patterns, code becomes easier to understand and ensures reusability and reliability.

In the software field, the Gang of Four (GoF) introduced a systematically organized set of 25 reusable classic design solutions divided into three main categories—creational, structural, and behavioral patterns—providing a theoretical foundation for reusable software design.

In general, these design patterns can be categorized into three main types: creational, structural, and behavioral patterns, each used for different scenarios. In this session, I will introduce several commonly used design patterns in Go project development, helping you tackle different coding scenarios in a simpler and more efficient way. Among them, the simple factory pattern, the abstract factory pattern, and the factory method pattern all belong to the factory pattern, and I will discuss them together.

Design Patterns

Creational Patterns #

Let’s first take a look at the Creational Patterns. They provide a way to hide the creation logic of objects and do not directly instantiate objects using the new operator.

Singleton pattern and factory pattern (including simple factory pattern, abstract factory pattern, and factory method pattern) are commonly used in Go project development. Let’s start with the singleton pattern.

Singleton Pattern #

Singleton pattern is the simplest pattern. In Go, the singleton pattern refers to having only one instance globally and it is responsible for creating its own object. Singleton pattern is not only beneficial for reducing memory usage, but also reduces system performance overhead, prevents conflicts with multiple instances, and more.

Because singleton pattern ensures the global uniqueness of the instance and is only initialized once, it is more suitable for scenarios where a global instance needs to be shared and only needs to be initialized once, such as database instances, global configurations, global task pools, etc.

Singleton pattern can be further divided into eager initialization and lazy initialization. Eager initialization refers to creating a global singleton instance when the package is loaded, while lazy initialization refers to creating the singleton instance when it is first used. As you can see, this naming convention vividly reflects their different characteristics.

Next, I will introduce these two approaches separately. Let’s start with the eager initialization.

Here is an example of singleton pattern using eager initialization:

package singleton

type singleton struct {
}

var ins *singleton = &singleton{}

func GetInsOr() *singleton {
    return ins
}

Please note that since the instance is initialized when the package is imported, if the initialization is time-consuming, it may lead to a longer program loading time.

The lazy initialization is the most commonly used approach in open source projects. However, its drawback is that it is not concurrent-safe and requires locking when used in practice. Here is an implementation of lazy initialization without locking:

package singleton

type singleton struct {
}

var ins *singleton

func GetInsOr() *singleton {
    if ins == nil {
        ins = &singleton{}
    }
        
    return ins
}

As you can see, when creating the ins, if ins==nil, another ins instance will be created, which leads to multiple instances of the singleton.

To solve the concurrency safety issue of lazy initialization, the instance needs to be locked. Here is an implementation with a checked lock:

import "sync"

type singleton struct {
}

var ins *singleton
var mu sync.Mutex

func GetIns() *singleton {
    if ins == nil {
        mu.Lock()
        if ins == nil {
            ins = &singleton{}
        }
        mu.Unlock()
    }
    return ins
}

The above code only locks during creation, which improves code efficiency and ensures concurrency safety.

In addition to eager initialization and lazy initialization, there is another more elegant implementation approach in Go development, which I recommend you to use. The code is as follows:

package singleton

import (
    "sync"
)

type singleton struct {
}

var ins *singleton
var once sync.Once

func GetInsOr() *singleton {
    once.Do(func() {
        ins = &singleton{}
    })
    return ins
}

Using once.Do ensures that the ins instance is only created once globally. The once.Do function also ensures that when multiple creation actions occur simultaneously, only one creation action is executed.

In addition, the singleton pattern is widely used in the IAM application. If you want to learn more about the usage of the singleton pattern, you can directly view the IAM project code. IAM uses the singleton pattern in GetStoreInsOr, GetEtcdFactoryOr, GetMySQLFactoryOr, GetCacheInsOr, etc.

Factory Pattern #

The factory pattern is a commonly used pattern in object-oriented programming. In Go project development, you can use different factory patterns to make your code more concise and clear. In Go, structures can be understood as classes in object-oriented programming. For example, the Person struct (class) implements the Greet method.

type Person struct {
    Name string
    Age  int
}

func (p Person) Greet() {
    fmt.Printf("Hi! My name is %s", p.Name)
}

With the Person “class”, you can create instances of Person. You can create a Person instance using three types of factory patterns: simple factory pattern, abstract factory pattern, and factory method pattern.

Among these three factory patterns, the simple factory pattern is the most commonly used and simplest one. It is a function that accepts some parameters and returns a Person instance:

type Person struct {
    Name string
    Age  int
}

func (p Person) Greet() {
    fmt.Printf("Hi! My name is %s", p.Name)
}

func NewPerson(name string, age int) *Person {
    return &Person{
        Name: name,
        Age:  age,
    }
}

Compared to creating an instance with p := &Person{}, the simple factory pattern ensures that the created instance has the required parameters, thus ensuring that the methods of the instance can be executed as expected. For example, when creating a Person instance using NewPerson, you can ensure that the name and age attributes of the instance are set correctly. Let’s take a look at the abstract factory pattern, which is the only difference from the simple factory pattern. The difference is that it returns an interface instead of a structure.

By returning an interface, you can allow the caller to use various functionalities that you provide without exposing the internal implementation, for example:

type Person interface {
  Greet()
}

type person struct {
  name string
  age int
}

func (p person) Greet() {
  fmt.Printf("Hi! My name is %s", p.name)
}

// Here, NewPerson returns an interface, and not the person struct itself
func NewPerson(name string, age int) Person {
  return person{
    name: name,
    age: age,
  }
}

In the above code, a non-exported structure person is defined, and when creating an instance through NewPerson, it returns an interface instead of a structure.

By returning an interface, we can also implement multiple factory functions to return different interface implementations, for example:

// We define a Doer interface, that has the method signature
// of the `http.Client` structs `Do` method
type Doer interface {
  Do(req *http.Request) (*http.Response, error)
}

// This gives us a regular HTTP client from the `net/http` package
func NewHTTPClient() Doer {
  return &http.Client{}
}

type mockHTTPClient struct{}

func (*mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
  // The `NewRecorder` method of the httptest package gives us
  // a new mock request generator
  res := httptest.NewRecorder()

  // calling the `Result` method gives us
  // the default empty *http.Response object
  return res.Result(), nil
}

// This gives us a mock HTTP client, which returns
// an empty response for any request sent to it
func NewMockHTTPClient() Doer {
  return &mockHTTPClient{}
}

NewHTTPClient and NewMockHTTPClient both return the same interface type Doer, which allows them to be used interchangeably. This is particularly useful when you want to test code that calls the Do method of the Doer interface, as you can use a mock HTTP client, avoiding potential failures from calling the actual external interface.

Let’s take an example, assuming we want to test the following code:

func QueryUser(doer Doer) error {
  req, err := http.NewRequest("Get", "http://iam.api.marmotedu.com:8080/v1/secrets", nil)
  if err != nil {
    return err
  }

  _, err := doer.Do(req)
  if err != nil {
    return err
  }

  return nil
}

The test case for it would be:

func TestQueryUser(t *testing.T) {
  doer := NewMockHTTPClient()
  if err := QueryUser(doer); err != nil {
    t.Errorf("QueryUser failed, err: %v", err)
  }
}

Furthermore, when returning instance objects using the simple factory pattern and the abstract factory pattern, you can return pointers. For example, the simple factory pattern can return instance objects like this:

return &Person{
  Name: name,
  Age: age
}

The abstract factory pattern can return instance objects like this:

return &person{
  name: name,
  age: age
}

In actual development, I recommend returning non-pointer instances because we mainly want to call the provided methods by creating instances, rather than modify the instances. If modifications are needed, you can implement SetXXX methods. By returning non-pointer instances, you can ensure the properties of the instances and prevent them from being accidentally/arbitrarily modified.

In the simple factory pattern, it depends on a single factory object. If we need to instantiate a product, we need to pass a parameter to the factory to get the corresponding object. If we want to add a new product, we need to modify the function that creates the product in the factory. This will result in a high coupling level. This is where the factory method pattern can be used.

In the factory method pattern, it relies on factory functions. We can create multiple factories by implementing factory functions. Instead of having one object responsible for instantiating all concrete classes, we can have a group of subclasses responsible for instantiating the concrete classes, thus decoupling the process.

Here is an example implementation of the factory method pattern:

type Person struct {
  name string
  age int
}

func NewPersonFactory(age int) func(name string) Person {
  return func(name string) Person {
    return Person{
      name: name,
      age: age,
    }
  }
}

Then, we can use this function to create factories with a default age:

newBaby := NewPersonFactory(1)
baby := newBaby("john")

newTeenager := NewPersonFactory(16)
teen := newTeenager("jill")

Structural Patterns #

I have introduced you to two creational patterns, Singleton Pattern and Factory Pattern. Next, let’s talk about Structural Patterns, which focus on the composition of classes and objects. In this category, I would like to explain the Strategy Pattern and the Template Pattern in detail.

Strategy Pattern #

The Strategy Pattern defines a set of algorithms, encapsulates each algorithm, and makes them interchangeable.

When do we need to use the Strategy Pattern?

In project development, we often need to take different measures based on different scenarios, which are different strategies. For example, let’s say we need to perform calculations on two integers, a and b, and depending on different conditions, we need to use different calculation methods. We can encapsulate all the operations in the same function and call different calculation methods using if ... else ... statements. This approach is called hardcoding.

In practical applications, as features and experiences grow, we need to frequently add or modify strategies. This requires constantly modifying existing code, which not only makes the function harder to maintain, but may also introduce bugs. Therefore, to decouple dependencies, we need to use the Strategy Pattern, which defines independent classes to encapsulate different algorithms, with each class encapsulating a specific algorithm (i.e., strategy).

Below is an implementation of the Strategy Pattern:

package strategy

// Strategy Pattern

// Define a strategy class
type IStrategy interface {
    do(int, int) int
}

// Strategy implementation: add
type add struct{}

func (*add) do(a, b int) int {
    return a + b
}

// Strategy implementation: subtract
type subtract struct{}

func (*subtract) do(a, b int) int {
    return a - b
}

// Executor of a specific strategy
type Operator struct {
    strategy IStrategy
}

// Set the strategy
func (operator *Operator) setStrategy(strategy IStrategy) {
    operator.strategy = strategy
}

// Call the method in the strategy
func (operator *Operator) calculate(a, b int) int {
    return operator.strategy.do(a, b)
}

In the above code, we define a strategy interface IStrategy and two strategy implementations, add and subtract. Finally, we define an executor that can set different strategies and execute them, for example:

func TestStrategy(t *testing.T) {
    operator := Operator{}

    operator.setStrategy(&add{})
    result := operator.calculate(1, 2)
    fmt.Println("add:", result)

    operator.setStrategy(&subtract{})
    result = operator.calculate(2, 1)
    fmt.Println("subtract:", result)
}

As you can see, we can change the strategy at will without affecting all the implementations of Operator.

Template Pattern #

The Template Pattern defines the skeleton of an algorithm in an operation, but defers some steps to subclasses. This allows subclasses to redefine specific steps of the algorithm without changing its structure.

In simple terms, the Template Pattern is to place methods that can be commonly used in a class into an abstract class for implementation, while abstracting the methods that cannot be commonly used as abstract methods, and forcing subclasses to implement them. This way, a class serves as a template, allowing developers to fill in the necessary parts.

Here is an implementation of the Template Pattern:

package template

import "fmt"

type Cooker interface {
    fire()
    cook()
    outfire()
}

// Acts as an abstract class
type CookMenu struct{}

func (CookMenu) fire() {
    fmt.Println("Start the fire")
}

// Cook the dish, to be implemented by specific subclasses
func (CookMenu) cook() {
}

func (CookMenu) outfire() {
    fmt.Println("Stop the fire")
}

// Encapsulate specific steps
func doCook(cook Cooker) {
    cook.fire()
    cook.cook()
    cook.outfire()
}

type XiHongShi struct {
    CookMenu
}

func (*XiHongShi) cook() {
    fmt.Println("Cooking Xi Hong Shi (Tomato)")
}

type ChaoJiDan struct {
    CookMenu
}

func (ChaoJiDan) cook() {
    fmt.Println("Cooking Chao Ji Dan (Scrambled Eggs)")
}

Let’s take a look at the test cases:

func TestTemplate(t *testing.T) {
    // Cook Xi Hong Shi (Tomato)
    xihongshi := &XiHongShi{}
    doCook(xihongshi)

    fmt.Println("\n=====> Let's cook another dish")
    // Cook Chao Ji Dan (Scrambled Eggs)
    chaojidan := &ChaoJiDan{}
    doCook(chaojidan)

}

Behavioral Patterns #

Now, let’s take a look at the last category, Behavioral Patterns, which focus on communication between objects. In this category of design patterns, we will discuss the Proxy Pattern and the Options Pattern.

Proxy Pattern #

The Proxy Pattern provides a surrogate or placeholder for another object to control access to that object.

Here is an implementation of the Proxy Pattern:

package proxy

import "fmt"

type Seller interface {
    sell(name string)
}

// Train station
type Station struct {
    stock int // inventory
}

func (station *Station) sell(name string) {
    if station.stock > 0 {
        station.stock--
        fmt.Printf("Proxy: %s bought a ticket, remaining: %d \n", name, station.stock)
    } else {
        fmt.Println("Tickets sold out")
    }

}

// Train station proxy
type StationProxy struct {
    station *Station // holds a train station object
}

func (proxy *StationProxy) sell(name string) {
    if proxy.station.stock > 0 {
        proxy.station.stock--
        fmt.Printf("Proxy: %s bought a ticket, remaining: %d \n", name, proxy.station.stock)
    } else {
        fmt.Println("Tickets sold out")
    }
}

In the above code, StationProxy acts as a proxy for Station. The proxy class holds an instance of the class it is proxying and implements the same interface as the class being proxied.

Options Pattern #

The Options Pattern is also commonly used in Go project development. For example, the NewServer function in grpc/grpc-go and the New function in uber-go/zap both utilize the Options Pattern. With the Options Pattern, we can create a struct variable with default values and selectively modify some of the parameter values.

In Python, when creating an object, we can provide default values for parameters. This allows us to return an object with default values when no arguments are passed and modify the attributes of the object when needed. This feature greatly simplifies the development cost of creating an object, especially when the object has many properties.

However, in Go, since it does not support default parameter values, developers often use the following two methods to achieve the goal of creating instances with default values and custom parameters:

The first method involves developing two separate functions for creating instances: one that creates instances with default values and one that allows customization.

package options

import (
    "time"
)

const (
    defaultTimeout = 10
    defaultCaching = false
)

type Connection struct {
    addr    string
    cache   bool
    timeout time.Duration
}

// NewConnect creates a connection.
func NewConnect(addr string) (*Connection, error) {
    return &Connection{
        addr:    addr,
        cache:   defaultCaching,
        timeout: defaultTimeout,
    }, nil
}

// NewConnectWithOptions creates a connection with options.
func NewConnectWithOptions(addr string, cache bool, timeout time.Duration) (*Connection, error) {
    return &Connection{
        addr:    addr,
        cache:   cache,
        timeout: timeout,
    }, nil
}

Using this approach, we need to implement two different functions to create the same Connection instance, which is not very elegant.

The second method is more elegant. We need to create an options struct with default values and use it to create instances:

package options

import (
    "time"
)

const (
    defaultTimeout = 10
    defaultCaching = false
)

type Connection struct {
    addr    string
    cache   bool
    timeout time.Duration
}
type ConnectionOptions struct {
	Caching bool
	Timeout time.Duration
}

func NewDefaultOptions() *ConnectionOptions {
	return &ConnectionOptions{
		Caching: defaultCaching,
		Timeout: defaultTimeout,
	}
}

// NewConnect creates a connection with options.
func NewConnect(addr string, opts *ConnectionOptions) (*Connection, error) {
	return &Connection{
		addr:    addr,
		cache:   opts.Caching,
		timeout: opts.Timeout,
	}, nil
}

Although only one function needs to be implemented to create an instance using this method, there is also a disadvantage: in order to create a Connection instance, we have to create a ConnectionOptions every time, which is quite cumbersome to operate.

So is there a more elegant solution? The answer is of course yes, that is to use the options pattern to create instances. The following code uses the options pattern to achieve the above functions:

package options

import (
	"time"
)

type Connection struct {
	addr    string
	cache   bool
	timeout time.Duration
}

const (
	defaultTimeout = 10
	defaultCaching = false
)

type options struct {
	timeout time.Duration
	caching bool
}

// Option overrides behavior of Connect.
type Option interface {
	apply(*options)
}

type optionFunc func(*options)

func (f optionFunc) apply(o *options) {
	f(o)
}

func WithTimeout(t time.Duration) Option {
	return optionFunc(func(o *options) {
		o.timeout = t
	})
}

func WithCaching(cache bool) Option {
	return optionFunc(func(o *options) {
		o.caching = cache
	})
}

// Connect creates a connection.
func NewConnect(addr string, opts ...Option) (*Connection, error) {
	options := options{
		timeout: defaultTimeout,
		caching: defaultCaching,
	}

	for _, o := range opts {
		o.apply(&options)
	}

	return &Connection{
		addr:    addr,
		cache:   options.caching,
		timeout: options.timeout,
	}, nil
}

In the above code, we first define the options structure, which carries two attributes: timeout and caching. Next, we create a connection through NewConnect, which first creates an options structure variable with default values, and then modifies the created options structure variable by calling

for _, o := range opts {
    o.apply(&options)
}

The properties that need to be modified are passed in as Option-type options parameters when NewConnect is called. Option-type options parameters can be created through the WithXXX function, such as WithTimeout, WithCaching.

Option-type options parameters need to implement the apply(*options) function. Combining the return value of WithTimeout and WithCaching with the apply method of optionFunc, it can be understood that o.apply(&options) actually assigns the parameters passed by options of WithTimeout and WithCaching to the options structure variable, thus dynamically setting the attributes of the options structure variable.

There is also a benefit: we can customize the assignment logic in the apply function, for example, o.timeout = 100 * t. Through this method, we will have greater flexibility in setting the attributes of the structure.

The option pattern has many advantages, such as: supporting the passing of multiple parameters and maintaining compatibility when parameters change; supporting the passing of parameters in any order; supporting default values; easy to extend; by using functions with WithXXX naming, the meaning of the parameters can be more clear, and so on.

However, in order to implement the option pattern, we have added a lot of code, so in development, it is necessary to choose whether to use the option pattern according to the actual scenario. The option pattern is usually applicable to the following scenarios:

  • When there are many structure parameters, we expect to create a structure variable with default values and selectively modify the values of some parameters.
  • When the structure parameters change frequently and we do not want to modify the function that creates the instance. For example, a retry parameter is added to the structure, but we do not want to add a retry int parameter declaration to the NewConnect parameter list.

If there are few structure parameters, you should carefully consider whether or not to use the option pattern.

Summary #

Design patterns are the best solutions specifically designed for certain scenarios that have been accumulated in the industry. In the software field, the Gang of Four (GoF) first systematically proposed three categories of design patterns: Creational Patterns, Structural Patterns, and Behavioral Patterns.

In this lecture, I introduced six commonly used design patterns in Go project development. Each design pattern addresses a specific type of scenario. I have summarized them in a table for you to choose from according to your needs.

Exercises #

  1. In the project you are currently developing, which parts can be re-implemented using singleton pattern, factory pattern, and strategy pattern? If possible, I suggest you try to rewrite this part of the code.
  2. Apart from the 6 design patterns we learned in this lecture, have you used any other design patterns? Feel free to share your experiences or any pitfalls you encountered in the comments section.

Feel free to discuss and exchange ideas with me in the comments section. See you in the next lecture.