skip to content

As the author of a shared Go client package, when do you expose a setting as a Config field rather than an Option?

level: principalimportance: should knowfreq 34%

answer

  1. ask what kind of value it is
  2. operators fill in a file, not code
  3. a struct can be printed and diffed
  4. an exported field is public writable state
  5. if you ship both, write the boundary down

basics

~20 s

Expose a Config field when the value is data operators supply and need to see echoed back, or when several settings must be validated together. Reserve options for behavioural knobs you want opaque, unexported and unreachable after construction.

solid answer

~50 s

The deciding question is what the setting *is*, not how many there are. Values that arrive from a deployment file — addresses, durations, pool sizes — want to be a struct: a struct can be unmarshalled, diffed, logged at start-up and validated as a set, none of which is possible with a slice of opaque closures. Behavioural settings — a custom dialer, a clock for tests, a diagnostic sink — want to be options, because they are code and they keep the client's fields unexported so nothing can mutate them after construction. The organisational weight is that an exported `Config` field is permanent, type-fixed, publicly writable state other teams compile against, so I want a reviewer's agreement before adding one. A large client can ship both — `New(cfg Config, opts ...Option)` — but only with the boundary written down.

go deeper

for a junior

Know that both shapes exist: a constructor taking a struct of settings, and one taking a list of With... functions. You are not expected to choose between them yet, only to recognise which a package is using.

for a middle

Be able to name the concrete differences: a struct can be unmarshalled from a file, printed and validated as a whole, while options keep the fields unexported and can carry functions as well as data.

for a senior

Argue the choice for a specific package with reasons an interviewer can check — who supplies the value, whether it needs cross-field validation, whether anything should be able to change it after construction.

for a principal

Own it as an exported surface other teams compile against. Say who signs off on adding to it, what rule separates the two mechanisms if you ship both, and how you keep that rule alive across a family of packages.

## Frame the decision correctly The wrong frame is "how many optional parameters do I have". Counting parameters leads to a rule of thumb ("more than four, use options") that produces packages where a deployment timeout and a test clock live in the same mechanism for no reason. The right frame is: **what kind of thing is this setting, who supplies it, and what do they need to do with it afterwards?** That question has an answer per setting, and the answer decides the mechanism. ## What a Config struct gives that options cannot **It is data, so tools can act on it.** A struct with exported fields can be unmarshalled from a deployment file, populated from environment variables, printed at start-up so an operator can confirm what the process actually loaded, diffed between two environments to explain why staging behaves differently, and round-tripped in a test fixture. An `[]Option` is a list of opaque function values: you cannot print it, compare it, serialise it, or ask it what it is going to set. **It validates as a whole.** With a struct, the constructor holds every value at once and can check relationships — that the backoff window fits inside the deadline it retries within, that a keep-alive is shorter than the peer's idle timeout. Per-option checks see one field each. **It reads as a unit in review.** A reviewer looking at a filled-in struct literal sees the whole configuration on one screen. A call with nine options is nine expressions to check individually. ## What options give that a Config struct cannot **Opacity.** A `Config` field is exported by construction: callers can read it, write it, and — if you keep a reference rather than copying — mutate it after the client is running. Options hand the value into unexported fields, so the package retains control of the value's lifetime and of what is even settable. **A natural home for behaviour.** A custom dialer function, an alternative clock, an `io.Writer` for diagnostics, an interceptor to append — these are code. Putting a `func` field in a struct that also gets unmarshalled from a configuration file mixes two populations of value that arrive from different places and are reviewed by different people. **Composability at the call site.** Options can be assembled into named bundles a team applies together, and a caller can pass a `[]Option` built at run time from a feature check. ## The organisational weight, which is the real reason this is a lead's call Both mechanisms grow the exported surface, but they grow it differently, and that difference is what makes it a decision somebody owns: - An **exported Config field** fixes a name and a type publicly and grants write access. Once other teams' code reads and writes `cfg.Timeout`, its type is effectively frozen, and any semantic change is a change to somebody else's compiled code. - An **exported option helper** fixes a name and a signature but keeps the underlying state private, so the package can change how it stores and interprets the value. This is why the review posture differs. I want a second opinion before adding a `Config` field, because it is public writable state; I am more relaxed about an option, because the blast radius is the helper's signature. Neither is free — a package accumulating options nobody uses is its own kind of debt — but they are not the same kind of commitment, and a team should know which one it is granting. There is a second organisational fact: **who fills it in.** If the value is going to end up in a deployment manifest maintained by whoever is on call, it must be a struct field, because that is the shape the file has. If it is going to be set by the one team that needs a fake clock in tests, an option keeps it out of the operators' surface entirely. Deciding by audience gets this right more often than deciding by taste. ## The hybrid, and its one rule Large clients commonly land on `New(cfg Config, opts ...Option)`: the deployment surface in the struct, behavioural hooks as options. It works, and it is honest about the two populations. It has one rule, and skipping it is the failure mode: **document the boundary and hold it**. Write in the package doc what belongs in `Config` and what belongs in an `Option`, and enforce it in review. A package where the timeout is a field, the keep-alive is an option and nobody remembers why is worse than either pure design, because every importing team now has to check the godoc for each setting before they can write a line. ## What I would say in the interview I would give the criterion — data supplied by operators and validated as a set goes in the struct; behaviour and rarely-used tuning goes in options — then name the cost I am accepting on each side, then say who signs off. The point an interviewer is listening for is that this is a surface other people compile against and I cannot quietly retract, so the decision is made deliberately, written down, and applied consistently across the package family rather than per pull request.

  • You ship New(cfg Config, opts ...Option). How do you stop the boundary blurring over time?
    By writing the rule in the package doc — deployment data in the struct, behaviour in options — and treating a pull request that puts a setting on the wrong side as a review comment, not a preference. It also helps to keep Config a flat struct of plain data types with no func fields, so an option-shaped setting has nowhere to hide in it.
  • A team asks for one more knob on a client twenty other services import. What do you weigh?
    Whether it is genuinely per-deployment or one team's workaround, whether it can be derived rather than set, and what it costs everyone else to read past forever. A knob added for one caller is documentation and support burden for twenty. If it must exist, an option keeps it out of the operators' configuration surface and out of the public struct.
  • Should the constructor copy the Config it is given, or keep the caller's value?
    Copy it. If you store the struct or a pointer to it, a caller can mutate the client's configuration after construction, from another goroutine, with nothing synchronising it. Taking Config by value and reading the fields you need into unexported fields makes construction the only moment configuration is read, which is far easier to reason about and to document.

saying these in an interview costs you the question

  • Deciding by parameter count alone, such as more than four means options
  • Putting a func field for a custom dialer in a struct operators unmarshal
  • Treating an exported Config field as retractable once other teams import it
  • Storing the caller's Config pointer so it can be mutated after construction
  • Shipping both mechanisms with no written rule for which setting goes where
  • Adding a knob for one team without weighing the cost to every other importer