Go
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Functions and Methods
-
Section 4: Concurrency
-
Section 5: Packages and Tooling
-
Section 6: More Standard Library
-
Section 7: Building Services
-
Section 8: Advanced Go
-
Section 9: Go in the Cloud-Native Ecosystem
-
Section 10: Data Structures and Algorithms in Go
-
Section 11: Testing and Deployment
-
Section 12: Practical Projects
-
Section 13: More Standard Library Practice
-
Section 14: More Practice Projects
-
Section 15: Design Patterns in Go
-
Section 16: Interview Practice
-
Section 17: Package fmt In Depth
-
Section 18: Package strings and strconv
-
Section 19: Package os and io
-
Section 20: Package time
-
Section 21: Package sort and container
-
Section 22: Package encoding
-
Section 23: Package net/http In Depth
-
Section 24: Package context
-
Section 25: Package regexp and bytes
-
Section 26: Package errors In Depth
-
Section 27: Package crypto and hash
-
Section 28: Package flag and log
-
Section 29: Package sync In Depth
-
Section 30: More Practice Exercises
-
Section 31: Go Modules and Workspaces In Depth
-
Section 32: Generics Deep Dive (Go 1.18+)
-
Section 33: Testing Package In Depth
-
Section 34: More Interview and Whiteboard Practice
-
Section 35: Package math and unicode
-
Section 36: Package path and filepath
-
Section 37: Package database/sql
-
Section 38: Package text/template and html/template
-
Section 39: Package archive and compress
-
Section 40: Lower-Level net Package
-
Section 41: Package runtime and reflect
-
Section 42: Package embed and io/fs
-
Section 43: Go Assembly and CGO Basics
-
Section 44: Building CLIs and TUIs
-
Section 45: Go Performance Tuning
-
Section 46: More Real-World Projects
-
Section 47: Go in Production
-
Section 48: Go Design Patterns
-
Section 49: Go Interfaces Deep Dive
-
Section 50: Final Practice and Review
231: The Functional Options Pattern Revisited
We've touched on basic struct initialization before, but as your projects grow, you'll hit a wall where your constructors start looking like a grocery list. I've seen this happen in almost every production codebase I've joined. You start with a simple NewServer(port int), and six months later, you're staring at NewServer(port int, timeout time.Duration, maxConn int, logger *log.Logger, tlsConfig *tls.Config). It's a nightmare to maintain, and it's an even worse experience for whoever has to call that function.
The Telescoping Constructor Trap
The most naive approach is simply adding arguments to your constructor as requirements evolve. This is what I call "telescoping." The problem isn't just the length of the function signature; it's the rigidity. If a user only wants to change the tlsConfig but is happy with the default timeout and maxConn, they are still forced to provide values for everything in between.
// This is brittle. Adding one more field breaks every single caller in the app.
func NewServer(port int, timeout time.Duration, maxConn int) *Server {
return &Server{
port: port,
timeout: timeout,
maxConn: maxConn,
}
}
// Caller has to pass "zero values" for things they don't care about
srv := NewServer(8080, 0, 100) // Does 0 mean "no timeout" or "use default"?
You might think, "I'll just use a Config struct." That's a step up, but it introduces a new ambiguity: the zero-value problem. If you pass a Config struct and the MaxConn field is 0, did the user explicitly want zero connections, or did they just leave the field blank? You end up writing a mountain of if cfg.MaxConn == 0 { s.maxConn = defaultMaxConn } boilerplate, which is tedious and error-prone.
Defining Options as Behavior
This is where the Functional Options pattern becomes a lifesaver. Instead of passing data (a struct or a list of ints), we pass behavior. We define a function type that knows how to modify our server, and then we pass a slice of those functions to the constructor. It feels a bit meta at first, but it's incredibly clean once it clicks.
type Server struct {
port int
timeout time.Duration
logger *log.Logger
}
// The magic happens here: a function that modifies a Server
type Option func(*Server)
func WithTimeout(t time.Duration) Option {
return func(s *Server) {
s.timeout = t
}
}
func WithLogger(l *log.Logger) Option {
return func(s *Server) {
s.logger = l
}
}
func NewServer(port int, opts ...Option) *Server {
// 1. Set your sensible defaults first
srv := &Server{
port: port,
timeout: 30 * time.Second,
}
// 2. Apply each option provided by the caller
for _, opt := range opts {
opt(srv)
}
return srv
}
Why This Actually Scales
Think about the API surface now. If I decide next week that the Server needs a MaxHeaderBytes setting, I don't have to change the NewServer signature. I just add a new WithMaxHeaderBytes function. Every existing call to NewServer in the entire codebase continues to compile and run perfectly. I've effectively decoupled the construction of the object from its configuration.
I will admit, there is a small cost: you're allocating a few more functions on the heap during initialization. In 99% of cases—especially for things like servers, database clients, or API wrappers—this cost is completely irrelevant compared to the benefit of a stable, readable API. You're trading a few nanoseconds of startup time for a codebase that doesn't break every time you add a feature. That's a trade I'll take every single time.
📋 Practical Task
Refactor the DatabaseClient Configuration
You have been handed a legacy DatabaseClient implementation that uses a clunky Config struct. This has led to bugs where developers forget to set critical fields, and the code is littered with if cfg.Field == 0 checks.
Your Task: Refactor the following code to use the Functional Options pattern. Implement WithTimeout and WithMaxRetries options, ensuring that NewClient applies defaults first and then overrides them with the provided options.
package main
import (
"fmt"
"time"
)
type DatabaseClient struct {
connString string
timeout time.Duration
maxRetries int
}
type Config struct {
Timeout time.Duration
MaxRetries int
}
// REFACTOR THIS: Remove the Config struct and implement Functional Options
func NewClient(connString string, cfg Config) *DatabaseClient {
client := &DatabaseClient{
connString: connString,
timeout: 5 * time.Second, // Default
maxRetries: 3, // Default
}
if cfg.Timeout != 0 {
client.timeout = cfg.Timeout
}
if cfg.MaxRetries != 0 {
client.maxRetries = cfg.MaxRetries
}
return client
}
func main() {
// This should eventually look like:
// client := NewClient("postgres://localhost:5432", WithTimeout(10*time.Second))
client := NewClient("postgres://localhost:5432", Config{Timeout: 10 * time.Second})
fmt.Printf("Client: %+v\n", client)
}
There are no comments for now.