When the pattern book meets Go
You open a classic design patterns reference and see a chapter on creational patterns. The book shows you Factory methods, Builder chains, and Singleton guards. You try to translate that into Go and immediately hit a wall. Go does not have classes. It does not have inheritance. It does not have a static keyword. The language refuses to hand you a prebuilt pattern template because it expects you to build the behavior directly from functions, structs, and package scope.
The good news is that Go makes these patterns simpler than their Java or C# counterparts. You do not need a separate interface hierarchy for a factory. You do not need a fluent API framework for a builder. You do not need a lazy initialization wrapper for a singleton. You just write a function, chain a few methods, or declare a variable at the package level. The patterns still solve the same problems. They just wear different clothes.
What creational patterns actually mean here
Creational patterns answer one question: how do we bring a value into existence without scattering setup logic across the codebase? In object oriented languages, constructors are often overloaded, hidden behind access modifiers, or buried in inheritance chains. Go strips that away. A struct is just a collection of fields. A function is just a function. If you want to control how an object is created, you write a function that returns it. If you want to control how it is configured, you chain methods on a temporary struct. If you want exactly one instance across the program, you declare it at the package level and guard it with the standard library.
Think of it like ordering food at a restaurant. The factory is the kitchen ticket: you hand over a simple order and get back a prepared dish. The builder is the customization menu: you start with a base item and add toppings step by step until you are ready to eat. The singleton is the house special: there is only one version, and everyone who asks for it gets the same plate. The kitchen does not care about the names. It cares about the workflow.
Factory: just a function with a job
A factory in Go is a function that returns a struct or an interface. The function name usually starts with New followed by the type name. This is a community convention that makes code readable without documentation. The function handles validation, sets defaults, and returns a ready to use value.
// NewDatabaseClient creates a configured database connection.
// It validates the DSN and sets sensible defaults for timeouts.
func NewDatabaseClient(dsn string) (*DatabaseClient, error) {
// Return early on empty input to avoid panics later.
if dsn == "" {
return nil, fmt.Errorf("dsn cannot be empty")
}
// Allocate the struct on the heap and fill required fields.
client := &DatabaseClient{
dsn: dsn,
timeout: 5 * time.Second,
maxConns: 10,
}
// Test the connection immediately to fail fast.
if err := client.ping(); err != nil {
return nil, fmt.Errorf("failed to connect: %w", err)
}
return client, nil
}
The compiler checks the return types at compile time. If you forget to return an error or return the wrong type, you get cannot use client (type *DatabaseClient) as error in return argument. The function runs once per call. Each call allocates a fresh struct. The caller owns the pointer and decides when to close the connection. This matches Go's philosophy of explicit ownership and clear boundaries.
Go developers follow a simple rule here: accept interfaces, return structs. The factory returns a concrete *DatabaseClient so the caller knows exactly what they are holding. If the caller only needs the behavior, they pass it to a function that accepts an interface. This keeps dependencies loose without hiding implementation details behind unnecessary abstraction layers.
Factories are cheap. They run in a single goroutine, allocate once, and return. Do not overcomplicate them with dependency injection containers or reflection. Write the function. Validate the input. Return the value.
Builder: optional fields without the noise
Structs with ten optional fields look ugly when you initialize them inline. You end up with a sparse struct literal where most fields are zero values. A builder solves this by letting you set only the fields you care about, then calling Build() to get the final struct. The builder struct holds the configuration state. Each setter method returns a pointer to itself so you can chain calls.
// NewServerBuilder returns a fresh builder with default values.
// Defaults prevent nil pointer dereferences during Build.
func NewServerBuilder() *ServerBuilder {
return &ServerBuilder{
port: 8080,
workers: runtime.NumCPU(),
}
}
// SetPort configures the listening port and returns the builder.
// Returning the pointer enables method chaining.
func (b *ServerBuilder) SetPort(port int) *ServerBuilder {
b.port = port
return b
}
// SetWorkers overrides the default concurrency limit.
// We validate the range to avoid resource exhaustion.
func (b *ServerBuilder) SetWorkers(n int) *ServerBuilder {
if n < 1 {
n = 1
}
b.workers = n
return b
}
// Build validates the configuration and returns the server.
// It copies values to avoid shared mutable state.
func (b *ServerBuilder) Build() (*Server, error) {
if b.port < 1 || b.port > 65535 {
return nil, fmt.Errorf("invalid port: %d", b.port)
}
return &Server{
port: b.port,
workers: b.workers,
}, nil
}
The runtime behavior is straightforward. NewServerBuilder allocates a small struct on the heap. Each setter mutates that struct in place. The Build method reads the fields, validates them, and allocates the final Server struct. The builder is discarded after Build returns. Garbage collection reclaims it when no references remain.
Receiver naming follows a tight convention here. The receiver is usually one or two letters that match the type, like (b *ServerBuilder). You will rarely see (this *ServerBuilder) or (self *ServerBuilder). The short name keeps the method signatures clean and matches the standard library style.
Builders shine when configuration is complex but the final object is immutable. They prevent accidental mutation after creation. They also make testing easier because you can construct exact scenarios without touching global state. Do not use a builder for two fields. Use a struct literal. Save the builder for when the setup actually needs scaffolding.
Singleton: the package variable that stays put
Go does not have a static keyword. It has package scope. A variable declared at the top of a file lives for the entire program lifetime. If you need exactly one instance of a type across the whole application, you declare it at the package level and expose it through a getter function. To make it safe for concurrent access, you wrap the initialization in sync.Once.
// logger holds the single application logger instance.
// Package level variables are initialized before main runs.
var logger *Logger
// initOnce ensures the logger is created exactly once.
// sync.Once handles race conditions without explicit locks.
var initOnce sync.Once
// GetLogger returns the shared logger instance.
// It blocks until initialization completes on the first call.
func GetLogger() *Logger {
initOnce.Do(func() {
// Allocate and configure the logger on first access.
logger = &Logger{
level: "info",
output: os.Stdout,
buffer: make([]byte, 0, 1024),
}
})
return logger
}
The compiler guarantees that package variables are initialized in a deterministic order. sync.Once guarantees that the closure inside Do runs exactly once, even if ten goroutines call GetLogger simultaneously. The first goroutine executes the closure. The others wait. When the closure finishes, all waiting goroutines proceed and receive the same pointer. No locks. No busy waiting. Just standard library primitives doing exactly what they advertise.
Testing singletons requires care. If your test suite runs in parallel, multiple test files might call GetLogger and share state. The common workaround is to reset the package variable between tests or to inject the logger through a function parameter instead of reaching for the global. Go developers prefer dependency injection over global state. If you can pass the logger as a parameter, do it. If you cannot, guard the global with sync.Once and document the testing strategy.
Goroutine leaks happen when a background task waits on a channel that never closes. Singletons often spawn background workers. Always give those workers a cancellation path. Pass a context.Context as the first parameter to any long running function. Respect deadlines. Close channels explicitly. The worst goroutine bug is the one that never logs.
Pitfalls and compiler realities
You will hit a few predictable walls when implementing these patterns in Go. The compiler catches most of them early, but the error messages can feel blunt if you are used to IDE hints.
Forgetting to return the builder pointer breaks the chain. The compiler rejects this with cannot use b.config.Opt (type string) as *ServerBuilder value in return statement. You must return b explicitly. Go does not do implicit this returns.
Misusing sync.Once for value types causes silent bugs. If you assign a struct instead of a pointer inside Do, each goroutine gets a copy after initialization. The compiler will not stop you, but your program will behave as if the singleton is shared when it is not. Always store pointers for singletons that hold mutable state.
Trying to pass a *string to a factory or builder adds unnecessary allocation. Strings are already cheap to pass by value. They are immutable and stored as a pointer plus length internally. The compiler will accept *string, but the runtime will allocate more memory and trigger more garbage collection. Pass the string directly.
If you forget to import fmt or sync, you get undefined: fmt or undefined: sync. If you import a package and never use it, the compiler stops with imported and not used. Go enforces clean imports strictly. Remove unused imports before the code runs.
The if err != nil { return err } pattern looks verbose compared to try catch blocks. The community accepts the boilerplate because it makes the unhappy path visible. You cannot accidentally swallow an error. Every error must be acknowledged or explicitly discarded with _. Using _ for errors says "I considered this return value and chose to drop it." Use it sparingly. Log the error or return it.
When to reach for which
Use a factory function when you need to validate input, set defaults, or return an interface behind a concrete type. Use a builder when a struct has many optional fields and you want to avoid sparse literals or constructor overloading. Use a package level variable with sync.Once when you need exactly one shared instance across the entire program and you can guarantee thread safe initialization. Use a plain struct literal when the type has fewer than four fields and no validation is required. Use dependency injection instead of a singleton when you need to swap implementations for testing or when the component holds mutable state that complicates parallel test runs.
Patterns are tools, not rules. Go gives you functions, structs, and package scope. Combine them deliberately. Trust the standard library. Argue logic, not formatting.