Go Programming

Modern Go: Mastering Generics with Builder, Factory, and Strategy Patterns

With the introduction of generics in Go 1.18, the language has evolved from a strictly structural tool into a more expressive, type-safe engineering platform. For intermediate and advanced developers, this is not just about convenience; it is about eliminating boilerplate while maintaining the strong static typing that Go is famous for. In this post, we will explore how to implement three fundamental design patterns—Builder, Factory, and Strategy—using modern Go generics. These patterns, once requiring complex interfaces or reflection, can now be implemented with elegant, compile-time safety.

The Builder Pattern with Generics

The Builder pattern is essential for constructing complex objects step-by-step. Before generics, developers often used the "functional options" pattern or relied on empty interfaces, which sacrificed type safety. With generics, we can ensure that our builder methods return a struct with a specific generic type parameter, allowing for fluent APIs without losing type information. Consider a configuration struct for a database connection. By using a generic builder, we can enforce that the final output is of the correct type.
package main

import "fmt"

// Generic Config struct
type Config[T any] struct {
    Driver string
    Params map[string]T
}

// Generic Builder
type Builder[T any] struct {
    config *Config[T]
}

func NewConfigBuilder[T any]() *Builder[T] {
    return &Builder[T]{
        config: &Config[T]{
            Params: make(map[string]T),
        },
    }
}

func (b *Builder[T]) Driver(d string) *Builder[T] {
    b.config.Driver = d
    return b
}

func (b *Builder[T]) AddParam(key string, val T) *Builder[T] {
    b.config.Params[key] = val
    return b
}

func (b *Builder[T]) Build() *Config[T] {
    return b.config
}

// Usage
func main() {
    // T is inferred as int
    cfg := NewConfigBuilder[int]().
        Driver("postgres").
        AddParam("max_connections", 100).
        AddParam("timeout", 30).
        Build()
    
    fmt.Println(cfg.Params["max_connections"]) // Output: 100
}
This approach ensures that `AddParam` only accepts the specified type `T`, preventing runtime panics associated with `interface{}`.

The Factory Pattern: Eliminating Switch Statements

The Factory pattern abstracts object creation. In traditional Go, this often involved large switch statements or registration maps. Generics allow us to create a generic factory that can instantiate different types based on a provided constructor function, reducing coupling and improving testability.
package main

import "fmt"

// Generic Factory type
type Factory[T any] struct {
    creators map[string]func() T
}

func NewFactory[T any]() *Factory[T] {
    return &Factory[T]{
        creators: make(map[string]func() T),
    }
}

// Register allows adding concrete types or factory functions
func (f *Factory[T]) Register(name string, creator func() T) {
    f.creators[name] = creator
}

// Create instantiates the type using the registered function
func (f *Factory[T]) Create(name string) (T, error) {
    creator, ok := f.creators[name]
    if !ok {
        var t T
        return t, fmt.Errorf("unknown type: %s", name)
    }
    return creator(), nil
}

// Example usage with structs
type Animal struct {
    Name string
}

func DogFactory() Animal {
    return Animal{Name: "Dog"}
}

func CatFactory() Animal {
    return Animal{Name: "Cat"}
}

func main() {
    animalFactory := NewFactory[Animal]()
    animalFactory.Register("dog", DogFactory)
    animalFactory.Register("cat", CatFactory)

    animal, _ := animalFactory.Create("dog")
    fmt.Println(animal.Name) // Output: Dog
}
This pattern is particularly useful in plugin architectures where the specific implementations are not known at compile time but must adhere to a generic interface or struct definition.

Strategy Pattern with Type Constraints

The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. While interfaces have always been the Go way for this, generics offer a performance advantage by avoiding interface dispatch overhead and allowing for better inlining by the compiler. We can use type constraints to ensure our strategy applies only to types that support specific operations.
package main

import (
    "fmt"
    "golang.org/x/exp/constraints"
)

// NumericStrategy applies an algorithm to any numeric type
type NumericStrategy[T constraints.Integer | constraints.Float] interface {
    Process(items []T) T
}

type MaxFinder[T constraints.Integer | constraints.Float] struct{}

func (m MaxFinder[T]) Process(items []T) T {
    if len(items) == 0 {
        var zero T
        return zero
    }
    max := items[0]
    for _, item := range items[1:] {
        if item > max {
            max = item
        }
    }
    return max
}

func main() {
    strategy := MaxFinder[int]{}
    result := strategy.Process([]int{1, 5, 3, 9, 2})
    fmt.Println(result) // Output: 9
    
    floatStrategy := MaxFinder[float64]{}
    fResult := floatStrategy.Process([]float64{1.1, 5.5, 3.3})
    fmt.Println(fResult) // Output: 5.5
}

Conclusion

Generics in Go are not a silver bullet, but they are a powerful tool for reducing code duplication and enhancing type safety. By applying the Builder, Factory, and Strategy patterns with generics, you can write code that is not only more performant but also easier to maintain and test. As the Go ecosystem matures, we expect to see these patterns become standard practice in high-scale applications. Embrace generics, but always prioritize readability and simplicity.
Share: