Home Exploring Design Patterns in Go
Post
Cancel

Exploring Design Patterns in Go

Exploring Design Patterns in Go

Design patterns are reusable solutions to common software design problems that help developers build software that is maintainable, extensible, and scalable. In this article, we will explore some popular design patterns in Go: Builder, Decorator, Factory Method, Fan-in-out, and Singleton.

1. Builder Pattern

The Builder pattern separates the construction of a complex object from its representation, allowing the same construction process to create different representations. It provides a step-by-step approach to building objects. Let’s see an example of the Builder pattern in Go:

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
type User struct {
	Name   string
	Role   string
	Salary int
}

type UserBuilder struct {
	User
}

func (ub *UserBuilder) setName(name string) *UserBuilder {
	ub.User.Name = name
	return ub
}

func (ub *UserBuilder) setRole(role string) *UserBuilder {
	ub.User.Role = role
	return ub
}

func (ub *UserBuilder) setSalary(sal int) *UserBuilder {
	ub.User.Salary = sal
	return ub
}

func (ub *UserBuilder) Build() User {
	return ub.User
}

func main() {
	ub := &UserBuilder{}
	user := ub.
		setName("John Doe").
		setRole("Admin").
		setSalary(5000).
		Build()
}

In the above example, UserBuilder provides methods to set different attributes of the user and a Build method to create the final User object.

2. Decorator Pattern

The Decorator pattern allows you to wrap existing functionality and append or prepend your own custom functionality on top. It enables adding behavior to objects dynamically at runtime. Let’s see an example of the Decorator pattern in Go:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*Decorators essentially allow you to wrap existing functionality and append or prepend
your own custom functionality on top.*/

func mainFun() {
	fmt.Println("main func")
	time.Sleep(1 * time.Second)
}

func additionalFun(a func()) {
	fmt.Printf("Starting function execution: %s\n", time.Now())
	a()
	fmt.Printf("ending function execution: %s\n", time.Now())
}
func main() {
	additionalFun(mainFun)
}

In this example, we have a mainFun function representing the main functionality, and the additionalFun function acts as a decorator, adding additional behavior before and after executing the mainFun function by accepting it as an argument.

3. Factory Method

The Factory Method pattern allows the creation of objects without specifying their exact types and delegates the instantiation to the factory. It provides a way to decouple the abstraction and implementation of object creation and enables flexibility in creating different types of objects.

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
type Engine interface{
	Start()
	Stop()
}
type car struct{

}
func(c car) Start(){
	fmt.Println("car start")
}
func(c car) Stop(){
	fmt.Println("car stop")
}

type train struct{

}
func(c train) Start(){
	fmt.Println("train start")
}
func(c train) Stop(){
	fmt.Println("train stop")
}

func starting( e Engine){
	e.Start()
}

func stopping( e Engine){
	e.Stop()
}

func GetEngine(engineType string) Engine {
	switch engineType {
	case "car":
		return car{}
	case "train":
		return train{}
	default:
		fmt.Println("type undefined")
		return nil
	}
}

func main(){
	engine := GetEngine("car")

	starting(engine)
	stopping(engine)
	engine1 := GetEngine("train")
	starting(engine1)
	stopping(engine1)
}

In this example, we have a factory method called GetEngine that returns Car or Train objects based on the specified engineType. The main function showcases the usage of the factory method by creating engine objects and invoking their Start( ) and Stop( ) methods.

4. Fan-In and Fan-Out Patterns

  1. Fan-In Pattern

In the Fan-In pattern, multiple input channels are combined into a single output channel. The inputs can come from different sources, and the Fan-In pattern allows you to merge and process the data from these sources concurrently. It aggregates data from multiple channels into a single channel, enabling centralized processing. Here’s an 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
39
40
41
42
43
44
45
46
package main

import (
	"fmt"
	"sync"
)

func generator(start, end int) <-chan int {
	ch := make(chan int)
	go func() {
		for i := start; i <= end; i++ {
			ch <- i
		}
		close(ch)
	}()
	return ch
}

func squareWorker(in <-chan int, out chan<- int, wg *sync.WaitGroup) {
	defer wg.Done()
	for num := range in {
		result := num * num // Square the input
		out <- result
	}
}

func main() {
	numbers := generator(1, 5)
	squaredNumbers := make(chan int)

	var wg sync.WaitGroup
	wg.Add(3)

	for i := 0; i < 3; i++ {
		go squareWorker(numbers, squaredNumbers, &wg)
	}

	go func() {
		wg.Wait()
		close(squaredNumbers)
	}()

	for res := range squaredNumbers {
		fmt.Println(res)
	}
}

In this Fan-In example, we have a generator function that returns a channel that produces numbers in a specified range.

We have multiple squareWorker goroutines that receive numbers from the numbers channel, square each number, and send the squared result to the squaredNumbers channel. Each worker goroutine is synchronized using a sync.WaitGroup .

The main goroutine uses the generator function to create a numbers channel with numbers from 1 to 5. It then launches multiple squareWorker goroutines to process the numbers concurrently.

The main goroutine waits for the workers to finish processing by calling wg.Wait() . It then closes the squaredNumbers channel and receives the squared numbers from the channel, printing them.

2. Fan-out Pattern

In the Fan-Out pattern, a single input channel is divided and distributed among multiple worker goroutines. Each worker receives a portion of the workload and operates on it independently. The Fan-Out pattern allows you to parallelize the processing of data by dividing the workload and assigning it to multiple workers.

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
39
40
41
42
43
44
45
package main

import (
	"fmt"
	"sync"
)

func producer(ch chan<- int) {
	for i := 1; i <= 5; i++ {
		ch <- i
	}
	close(ch)
}

func worker(in <-chan int, out chan<- int, wg *sync.WaitGroup) {
	defer wg.Done()
	for num := range in {
		result := num * 2 // Process the input
		out <- result
	}
}

func main() {
	input := make(chan int)
	output := make(chan int)

	go producer(input)

	var wg sync.WaitGroup
	workerCount := 3
	wg.Add(workerCount)

	for i := 0; i < workerCount; i++ {
		go worker(input, output, &wg)
	}

	go func() {
		wg.Wait()
		close(output)
	}()

	for res := range output {
		fmt.Println(res)
	}
}

In this Fan-Out example, we have a producer goroutine that sends numbers from 1 to 5 to the input channel and then close it.

We also have multiple worker goroutines that receive numbers from the input channel, perform some processing (in this case, multiply the number by 2), and send the result to the output channel. Each worker is synchronized using a sync.WaitGroup .

The main goroutine waits for the workers to finish processing by calling wg.Wait() . It then closes the output channel and receives the processed results from the output channel, printing them.

5. Singelton Pattern

The Singleton pattern ensures that only one instance of a class is created throughout the application. It provides a global point of access to this instance, allowing shared access to its resources. Let’s explore different implementations of the Singleton pattern in Go:

1. Not Thread Safe (NTS)

The first implementation we’ll look at is the Non-Thread Safe (NTS) approach. While simple, it’s not suitable for concurrent scenarios:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type singleton struct {
	val int
}

var instance *singleton

/*1) Not thread safe(NTS)*/
func GetSingletonNTS() *singleton {
	if instance == nil {
		instance = &singleton{}
	}
	return instance
}

In this implementation, the GetSingletonNTS function lazily initializes the instance variable. However, if multiple goroutines access GetSingletonNTS simultaneously and instance is still nil , they may end up creating separate instances.

2. Mutex Lock

To introduce thread safety, we can use a sync.Mutex to control access to the initialization code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type singleton struct {
	val int
}

var instance *singleton
var lock sync.Mutex

func GetSingletonML() *singleton {
	lock.Lock()
	defer lock.Unlock()

	if instance == nil {
		instance = &singleton{}
	}
	return instance
}

In this implementation, we use a sync.Mutex named lock to synchronize access to the instance initialization. While it ensures thread safety, it introduces a potential bottleneck when multiple goroutines need to acquire the lock.

3. Check-Lock-Check

We can improve performance by minimizing the number of lock acquisitions using a double-checked locking approach:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type singleton struct {
	val int
}

var instance *singleton
var lock sync.Mutex

func GetSingletonCLC() *singleton {
	if instance == nil {
		lock.Lock()
		defer lock.Unlock()

		if instance == nil {
			instance = &singleton{}
		}
	}
	return instance
}

In this implementation, we first perform a quick check on instance without acquiring the lock. If instance is still nil , we acquire the lock and perform a second check before creating the instance. This approach reduces the number of lock acquisitions but is prone to subtle bugs in certain scenarios.

4. Using sync.Once

The recommended way to implement the Singleton pattern in Go is to use the sync.Once package’s Do function:

1
2
3
4
5
6
7
8
9
10
11
12
13
type singleton struct {
	val int
}

var instance *singleton
var once sync.Once

func GetSingletonOnceDo() *singleton {
	once.Do(func() {
		instance = &singleton{}
	})
	return instance
}

In this implementation, the GetSingletonOnceDo function ensures that the initialization code inside the once.Do block is executed only once, regardless of the number of calls to GetSingletonOnceDo . It provides thread safety and better performance compared to the other implementations.

The complete codebase for the examples discussed in this article is available on the Git repository, allowing you to easily access and experiment with the entire codebase.

Post converted from Medium by ZMediumToMarkdown.

This post is licensed under CC BY 4.0 by the author.