The Constructor That Returned Three Nil Things
A payment client ships with a tempting signature:
func NewClient() *Client // returns Client with nil http.Client, nil logger, nil clock
The README shows c := NewClient(); c.Charge(...). Three teams discover at runtime which fields they forgot to set — each hitting a nil dereference in a different method, in production, weeks apart. The signature promised a client and delivered an object that must be assembled correctly by convention. Every one of those failures traces to the same design error: the zero value of the struct is not usable, but the API allows returning it.
Go offers two honest answers, and the package must pick one.
A required-dependency constructor fails loudly:
package payments
type Client struct {
http *http.Client
base *url.URL
clock func() time.Time
}
func NewClient(baseURL string, hc *http.Client) (*Client, error) {
u, err := url.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("payments: bad base URL %q: %w", baseURL, err)
}
if hc == nil {
hc = &http.Client{Timeout: 10 * time.Second}
}
return &Client{http: hc, base: u, clock: time.Now}, nil
}
The invariants live in the constructor: the URL parses, the HTTP client exists, the clock is set. After NewClient succeeds, no method needs a nil guard — and if it fails, the caller has an error at wiring time, not a panic at charge time. Returning (*Client, error) costs callers an if err != nil and saves them an entire class of bugs.
A zero-value-usable type skips the constructor entirely, which is the stdlib's preferred shape when no validation is needed:
var buf bytes.Buffer // usable immediately, no New
var mu sync.Mutex // zero value is an unlocked mutex
var m map[string]string // needs make, but not a constructor
bytes.Buffer has no New() because its zero value is valid. If your type has an unusable zero value, that is a signal: either add a constructor that enforces the dependencies (as above) or restructure so the zero value works.
Two conventions finish the discipline. Pointer or value return? Return a pointer when the type has identity — mutable state, fields that must not be copied — and a value when it is small and copyable, matching Value Semantics versus Pointers. Name the constructor for the package, not the type: inside payments, payments.NewClient stutters; stdlib precedent is payments.New(...), sql.Open, time.ParseDuration — the package is already half the identifier. And for errors that callers must branch on, define sentinels or types the caller can match with errors.Is/errors.As (The error Interface and Error Messages), rather than forcing text matching on constructor failures.
Options, Growth, and the Surface You Cannot Shrink
The cache constructor starts reasonable — NewCache(addr string, db int, ttl time.Duration, maxItems int) — and then adds a namespace, a password, a dial timeout, and a retry count. At seven parameters, every call site is a line of positional arguments where swapping db and ttl compiles cleanly and fails at runtime. The design question is not "struct or options?" but how much will this grow, and how clearly can callers see which argument is which?
Three shapes cover the realistic cases:
| Shape | Signature | Use when |
|---|---|---|
| Positional | New(addr string, db int) |
1–2 required params, unlikely to grow |
| Config struct | New(Config{Addr: ..., TTL: ...}) |
3+ params, most optional, stable set |
| Functional options | New(addr, WithTTL(30*time.Second), WithDB(3)) |
Params grow over time, defaults matter, API is public |
The config struct is the workhorse:
type Config struct {
Addr string // required
DB int // required
TTL time.Duration // optional, 0 means default
Password string // optional
}
func New(cfg Config) (*Cache, error) {
if cfg.Addr == "" {
return nil, errors.New("cache: Addr is required")
}
if cfg.TTL == 0 {
cfg.TTL = time.Minute
}
return &Cache{...}, nil
}
Named fields survive insertion of new options: adding MaxItems next year changes one struct, not five call sites. Validation stays in one place.
Functional options earn their complexity when the API is exported and parameters will keep arriving:
type Option func(*Cache) error
func WithTTL(d time.Duration) Option {
return func(c *Cache) error {
if d <= 0 {
return fmt.Errorf("ttl must be positive, got %s", d)
}
c.ttl = d
return nil
}
}
func New(addr string, opts ...Option) (*Cache, error) {
c := &Cache{addr: addr, ttl: time.Minute}
for _, o := range opts {
if err := o(c); err != nil {
return nil, fmt.Errorf("cache: %w", err)
}
}
return c, nil
}
The cost is real: options are harder to read in godoc, they let callers pass the same option twice, and every With* is permanent API. Reach for them when the alternative is a breaking signature change each release.
The rule that outranks all three: interfaces belong on the consumer side, and you return concrete types.
// producer package: returns the concrete type
cache := redis.New(addr)
// consumer package: declares only what IT needs
type Getter interface {
Get(ctx context.Context, key string) ([]byte, error)
}
func render(g Getter, key string) ([]byte, error) { ... }
Returning *redis.Cache keeps the producer free to add methods; declaring Getter where it is used keeps the consumer's requirement minimal — the interface-segregation half of Accept interfaces, return structs. Exported methods are forever: an added method is compatible, a changed signature is not. Before exporting anything, ask whether an unexported method or a new package could serve internal needs — because in Go's compatibility promise, the cheapest API to maintain is the one you did not publish.
Naming, Documentation, and Change-Resistance
The code review request adds one method to a widely used package. The diff is small; the comment is not: "renaming this exported method breaks every caller — did we consider deprecating instead?" Package design in Go is mostly about which changes become breaking later, and the leverage points are naming, placement, and documentation.
Names: short, lowercase, no stutter. The package name is already the prefix, so user.New() beats user.NewUser(), user.Type beats user.UserType — the stdlib pattern is http.Request, not http.HTTPRequest, and tls.Client, not tls.TLSClient. Package names should be single lowercase words that say what the package provides (ring, filepath, template); if you cannot name it in one word, it may be two packages. Avoid util, common, and helpers — they are where names from unrelated domains collide.
Placement decides visibility more than exported/unexported does. Go's rule is lexical: identifiers are visible to their package, and packages are the unit of encapsulation. This means API design is file and package design:
// internal/secrets/ — invisible to every importer outside this module
package secrets
type Provider interface { Get(ctx context.Context, key string) ([]byte, error) }
The internal/ directory enforces an architectural boundary the compiler checks: code outside cannot import it at all, so an implementation detail cannot leak through an exported function by accident. When a helper is needed by exactly one package, it stays unexported; when two packages need it, it moves to a shared package with a name that describes the concept, not the coincidence of reuse.
Documentation is part of the signature. Go doc comments read as sentences starting with the identifier — // Cache is a bounded key-value store... for a type, // New returns a Cache backed by addr... for a function. The comment should state the contract the compiler cannot: what happens on failure, whether the zero value works, whether the returned value is safe for concurrent use, who closes what. The most valuable sentences are the negative ones — // New does not dial; connections are established lazily on first use prevents a misunderstanding no type can express.
Change-resistance is measured by asking what a plausible future edit would break. Add a field to an exported struct literal used positionally? Callers who construct with Config{Addr: x, DB: 1} by field name survive; Config{x, 1} breaks. Add a method to an exported interface? Every implementer outside the package breaks — which is why Interfaces and Type Assertions warns to define interfaces where they are consumed: your own consumer interfaces are yours to grow, and exported ones belong to implementers you do not control. Remove a parameter? Breaks everyone. Introduce one? Breaks no one. The design goal is a surface where the common evolution — adding — is always the safe one, and the rare evolution — removal — requires a deprecation cycle and a major version. Every export is a promise; count them before making it.
Quiz
1. A package exports `func New() *Client` returning a struct whose http and logger fields must be set manually afterward. What is the core design failure?
2. A client library has 2 required parameters and is expected to gain more optional ones over the next year. Which signature best survives that growth?
3. Your package implements a store, and a caller needs only Get and Put. Where should the interface be declared?
4. Why does `user.NewUser()` in package user violate Go naming convention?
Flashcards
Question
What does 'accept interfaces, return structs' mean in practice, and why does it keep both sides free?
Click to reveal answer
Answer
A function accepts a small interface it declares itself (only the methods it needs) so tests can substitute a fake, while it returns a concrete type rather than an interface it owns. The producer can add methods without breaking anyone; the consumer declares exactly the requirement it has.
Question
When is a zero-value-usable type better than one with a constructor?
Click to reveal answer
Answer
When no validation or dependency is required — bytes.Buffer, sync.Mutex, time.Time are usable with no New call. If a type cannot work uninitialized, that is a signal to add a constructor returning (*T, error) that enforces every invariant, so no method needs nil guards.
Question
How do you choose between a Config struct and functional options for a constructor?
Click to reveal answer
Answer
Config struct for a mostly stable set of 3+ parameters where named fields and one validation site win. Functional options for exported APIs that will keep growing: each With* is an additive, backward-compatible change — but they cost godoc clarity and every option you publish is permanent API.
Question
What makes an exported method or struct field a long-term liability?
Click to reveal answer
Answer
Exported API is forever: adding a method is safe, but changing a signature or removing a field breaks every importer at once, which in Go's compatibility model requires a deprecation cycle. Struct literals constructed positionally also break when fields are inserted, so prefer field-name construction and keep unexported what might change.
Question
How does the internal/ directory differ from unexported identifiers as an encapsulation tool?
Click to reveal answer
Answer
Unexported hides names from other packages; internal/ makes whole packages unimportable outside their parent tree, enforced by the compiler. Use unexported for implementation details of a package and internal/ for cross-package helpers or architecture boundaries that must not leak into the public surface.
Revision Notes
Key Takeaways
- 1. A constructor either establishes every invariant or does not exist — partially initialized returns become production nil panics
- 2. Config structs survive growth through named fields; functional options survive growth through signature stability
- 3. Interfaces are declared where they are consumed and list only the methods used; producers return concrete types
- 4. Exported names are permanent API: each export should have a reason, and internal/ protects the rest
- 5. Doc comments carry the contract — failure behavior, concurrency safety, zero-value usability, who closes what
Interview Tips
- • "Design a Go client library for a queue" — walk New(cfg) with validation, context-first method signatures, consumer-side interfaces for testing, and errors the caller can match
- • "Struct options or functional options?" — give the decision rule (stable param set vs growing exported API) and name the costs of each: positional breakage versus godoc opacity
- • "How do you evolve a public API without breaking callers?" — additive changes only, deprecate rather than remove, keep struct literals field-named, consider internal/ for new implementation
- • "Where would you define an interface in a cache package?" — consumer side with only needed methods; be ready with the reason: producer freedom to add methods, easy fakes
Cheat Sheet
Idiomatic API and Package Design
Constructor decision:
| Situation | Shape |
|---|---|
| No dependencies needed | zero value usable, no constructor (bytes.Buffer) |
| Required deps / validation | func New(...) (*T, error) |
| Optional deps, growing | func New(addr string, opts ...Option) (*T, error) |
| 3+ mostly-stable params | func New(cfg Config) (*T, error) |
Rules:
- Never return a partially initialized object; invariants are established in the constructor
- No stutter:
user.New, notuser.NewUser - Accept a consumer-declared interface; return the concrete type
- Exported API is additive-only — additions are safe, changes are breaking
internal/for boundaries the compiler enforces; doc comments state contracts the types cannot
Functional option template:
type Option func(*Cache) error
func WithTTL(d time.Duration) Option {
return func(c *Cache) error {
if d <= 0 { return fmt.Errorf("ttl must be positive") }
c.ttl = d
return nil
}
}
Continue Learning
Before You Start
- No prerequisites listed.