DEV Community

Cover image for Functional Options vs Builder vs Config Struct in Go: Pick One
Gabriel Anhaia
Gabriel Anhaia

Posted on

Functional Options vs Builder vs Config Struct in Go: Pick One


You have a NewServer constructor. Today it takes one argument: an address. Three sprints from now it takes nine. Read timeout, write timeout, max connections, TLS config, logger, metrics sink, request ID generator, idle timeout, and a hook for graceful shutdown.

The first version was a positional parameter list. By argument three the call site stopped being readable. By argument five it is the kind of bug where somebody passes 5*time.Second where 30*time.Second was wanted, and a payment service times out for a weekend before its database does.

Go gives you three patterns for this and they are not equivalent. Functional options, the builder pattern, and a config struct each solve a different version of the same problem. The wrong choice locks your package's public API into a shape you will fight for years.

Here is how to pick.

The starting point: positional parameters that grew up

This is what every package looks like before someone reads a style guide:

func NewServer(
    addr string,
    readTimeout time.Duration,
    writeTimeout time.Duration,
    maxConns int,
    tls *tls.Config,
    logger *slog.Logger,
) *Server {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Six parameters, no defaults, every caller writes them all. Adding idleTimeout is a breaking change. Reordering them is a breaking change. Reading a call site requires the IDE because nothing reminds you which time.Duration is which.

Option A: functional options

This is the pattern Dave Cheney popularised in his 2014 post Functional options for friendly APIs, and it is what most Go libraries default to now — gRPC-Go's grpc.DialOption, oauth2.Option, and most of the AWS SDK v2 client constructors all follow the same shape.

The idea: the constructor takes the required arguments positionally, then a variadic of functions that mutate the server. Each option is its own typed function.

package server

import (
    "crypto/tls"
    "log/slog"
    "time"
)

type Server struct {
    addr         string
    readTimeout  time.Duration
    writeTimeout time.Duration
    maxConns     int
    tls          *tls.Config
    logger       *slog.Logger
}

type Option func(*Server)

func WithReadTimeout(d time.Duration) Option {
    return func(s *Server) {
        s.readTimeout = d
    }
}

func WithWriteTimeout(d time.Duration) Option {
    return func(s *Server) {
        s.writeTimeout = d
    }
}

func WithMaxConns(n int) Option {
    return func(s *Server) { s.maxConns = n }
}

func WithTLS(c *tls.Config) Option {
    return func(s *Server) { s.tls = c }
}

func WithLogger(l *slog.Logger) Option {
    return func(s *Server) { s.logger = l }
}

func NewServer(addr string, opts ...Option) *Server {
    s := &Server{
        addr:         addr,
        readTimeout:  10 * time.Second,
        writeTimeout: 10 * time.Second,
        maxConns:     1000,
        logger:       slog.Default(),
    }
    for _, opt := range opts {
        opt(s)
    }
    return s
}
Enter fullscreen mode Exit fullscreen mode

The call site reads like English:

srv := server.NewServer(
    ":8080",
    server.WithReadTimeout(5*time.Second),
    server.WithTLS(tlsCfg),
    server.WithLogger(log),
)
Enter fullscreen mode Exit fullscreen mode

What you get for free:

  • Defaults live in NewServer, not at every call site. A caller that wants the default just omits the option.
  • Adding a tenth option next quarter is not a breaking change. Old call sites keep compiling.
  • Each option is documented at its own godoc anchor. Readers find WithTLS next to its alphabetical neighbours instead of buried in a struct definition.
  • Required arguments stay positional, so the compiler still flags you if you forget the address.

Where it falls apart:

  • Validation that depends on combinations of options has to happen in NewServer after every option has run. That is fine for two or three coupled fields. It gets ugly when the rule is "either provide a TLS config or set Insecure=true, but not both, and only when the address starts with https://".
  • Each option is a function value. The compiler usually inlines it, but on hot paths, or in codebases that try to keep symbol counts low, the extra function values add up.
  • An option that needs to fail (a parser, a file read) cannot just return an error. The classic signature returns only Option. Library authors cope by having the constructor return (*Server, error) and storing the deferred error in the server, which works but pushes the error onto the next method call.

This is the right default for any package that will be imported by other people's code.

Option B: the builder

A builder swaps the variadic for a fluent chain. Each call returns the builder so you can keep adding state, and a final Build() produces the value.

package server

import (
    "crypto/tls"
    "errors"
    "fmt"
    "log/slog"
    "os"
    "time"
)

type Builder struct {
    addr         string
    readTimeout  time.Duration
    writeTimeout time.Duration
    maxConns     int
    tls          *tls.Config
    logger       *slog.Logger
    err          error
}

func NewBuilder(addr string) *Builder {
    return &Builder{
        addr:         addr,
        readTimeout:  10 * time.Second,
        writeTimeout: 10 * time.Second,
        maxConns:     1000,
        logger:       slog.Default(),
    }
}

func (b *Builder) ReadTimeout(d time.Duration) *Builder {
    b.readTimeout = d
    return b
}

func (b *Builder) WriteTimeout(d time.Duration) *Builder {
    b.writeTimeout = d
    return b
}

func (b *Builder) MaxConns(n int) *Builder {
    b.maxConns = n
    return b
}

func (b *Builder) TLS(c *tls.Config) *Builder {
    b.tls = c
    return b
}

func (b *Builder) TLSFromFile(path string) *Builder {
    if b.err != nil {
        return b
    }
    raw, err := os.ReadFile(path)
    if err != nil {
        b.err = fmt.Errorf("read tls config: %w", err)
        return b
    }
    cfg, err := parseTLSConfig(raw)
    if err != nil {
        b.err = fmt.Errorf("parse tls config: %w", err)
        return b
    }
    b.tls = cfg
    return b
}

func (b *Builder) Logger(l *slog.Logger) *Builder {
    b.logger = l
    return b
}

func (b *Builder) Build() (*Server, error) {
    if b.err != nil {
        return nil, b.err
    }
    if b.readTimeout > b.writeTimeout {
        return nil, errors.New(
            "readTimeout must be <= writeTimeout",
        )
    }
    if b.tls != nil && b.addr == "" {
        return nil, errors.New(
            "tls is set but addr is empty",
        )
    }
    return &Server{
        addr:         b.addr,
        readTimeout:  b.readTimeout,
        writeTimeout: b.writeTimeout,
        maxConns:     b.maxConns,
        tls:          b.tls,
        logger:       b.logger,
    }, nil
}
Enter fullscreen mode Exit fullscreen mode
srv, err := server.NewBuilder(":8080").
    ReadTimeout(5 * time.Second).
    TLS(tlsCfg).
    Logger(log).
    Build()
Enter fullscreen mode Exit fullscreen mode

What the builder buys you:

  • A natural place to validate cross-field rules. Build() runs once, sees the whole shape, returns an error. No "deferred error" pattern.
  • Setter methods can return errors via the stored err field, so a chain that includes a parser does not fall over the moment one option needs to fail.
  • Step-by-step IDE autocompletion guides a reader through the configurable surface.

Where it costs:

  • The builder type is now part of your public API. Adding a setter is non-breaking; removing one is not. Renaming one is not.
  • Two allocations per construction (the builder and the server) instead of one. On a hot path that creates short-lived servers (test fixtures, in-memory shards) this matters.
  • The chain pattern reads as more foreign in a Go codebase that is otherwise functional-options-shaped.

A builder earns its weight in two places: cross-field validation that has to run once with the whole picture in view, and APIs you are willing to keep stable as a public type.

Option C: the config struct

The third pattern is the most direct: pass a struct.

package server

import (
    "crypto/tls"
    "log/slog"
    "time"
)

type Config struct {
    Addr         string
    ReadTimeout  time.Duration
    WriteTimeout time.Duration
    MaxConns     int
    TLS          *tls.Config
    Logger       *slog.Logger
}

func NewServer(c Config) *Server {
    if c.ReadTimeout == 0 {
        c.ReadTimeout = 10 * time.Second
    }
    if c.WriteTimeout == 0 {
        c.WriteTimeout = 10 * time.Second
    }
    if c.MaxConns == 0 {
        c.MaxConns = 1000
    }
    if c.Logger == nil {
        c.Logger = slog.Default()
    }
    return &Server{
        addr:         c.Addr,
        readTimeout:  c.ReadTimeout,
        writeTimeout: c.WriteTimeout,
        maxConns:     c.MaxConns,
        tls:          c.TLS,
        logger:       c.Logger,
    }
}
Enter fullscreen mode Exit fullscreen mode
srv := server.NewServer(server.Config{
    Addr:        ":8080",
    ReadTimeout: 5 * time.Second,
    TLS:         tlsCfg,
    Logger:      log,
})
Enter fullscreen mode Exit fullscreen mode

The good parts:

  • One type, one place to add a field. Reading the godoc for Config shows every knob at once.
  • Zero values are real. A time.Duration that defaults to 0 is fine if 0 is the sentinel for "use default", and the constructor normalises.
  • The struct serialises to JSON or YAML for free, which makes it the right shape when configuration comes from disk.

The catch the docs do not warn you about: any caller that uses positional struct literals breaks the moment you add a field. This is what blows up in batches:

srv := server.NewServer(server.Config{
    ":8080", 5 * time.Second,
    10 * time.Second, 1000, nil, nil,
})
Enter fullscreen mode Exit fullscreen mode

Add IdleTimeout to Config between fields three and four, and every positional literal in the codebase fails to compile. Go won't let that compile, but the build break is severe and the fix touches every positional call site.

Mitigation: ship Config with a comment that says "use named fields" and configure govet (or golangci-lint) with the composites analyzer enabled so positional literals on exported types from other packages get flagged. That moves the protection from convention to compiler.

Reach for a config struct when configuration originates from a file, when the number of fields is large enough that a variadic of options would be tedious, or when the config object itself wants to be passed around (logging it, hashing it, comparing two instances).

The decision rule

Three forcing functions, one fallback.

  • Does configuration cross a process boundary (env, file, RPC)? Use a config struct. The struct is the wire format; functional options would translate badly. The shape is already a record.
  • If validation has to see the whole shape before construction can finish, that is what a builder is for. The setter chain gives you a place to accumulate state, and Build() is the natural validation seam.
  • For a library other people will import, with a small set of obvious knobs and an open-ended growth path, functional options give you the non-breaking-extension property library users expect from idiomatic Go.

Everything else is taste. Most internal packages do not need any of the three; they need a constructor that takes the two required arguments and does its job. Patterns earn their keep when the alternative is worse. They do not earn it by making the package feel like a real library.

The most common mistake is reaching for a builder because it looks "more object-oriented" when functional options would have been less code. The second is reaching for functional options because Cheney's pattern is well-known, when the configuration was always going to come from a YAML file and the config struct was the right shape from the start. Either is recoverable, but both leak into your package's public API, where they harden in place and become very expensive to remove.

Pick once, document it in the package doc, and stop second-guessing it.


If this saved you a refactor

Constructor design is one of the small choices in a Go codebase that quietly compounds. Pick wrong on one package and the next twenty packages copy the shape, and the whole module starts to feel inconsistent. The Complete Guide to Go Programming covers the language-level reasons each of these patterns works the way it does — variadics, method values, struct field initialisation, and the way Go's lack of overloading shapes idiomatic constructor design. The companion volume, Hexagonal Architecture in Go, takes the same care to package boundaries: where the constructor sits in your dependency graph and which side of the port owns the configuration.

If your day job is shipping Go alongside an AI coding assistant, Hermes IDE is the editor I build for that workflow. It is built for the loop where the AI is reading and editing your Go code with you, not at you.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)