One Config struct feeds both NewWorker and NewPublisher — where should defaulting and validation live?
answer
- two constructors, one set of rules
- make the rules methods on Config
- fill in first, then check
- a copy comes back, the caller's stays put
basics
~20 sPut both on the Config itself: a withDefaults method returning a filled copy, and a validate method returning an error. Every constructor calls them in that order, once, so validation judges the values that will actually be used.
solid answer
~50 sWrite the rules once as methods on `Config` with value receivers: `func (c Config) withDefaults() Config` fills zero fields and returns the modified **copy**, and `func (c Config) validate() error` checks the invariants. Every constructor starts with `cfg = cfg.withDefaults()` then `if err := cfg.validate(); err != nil { return nil, err }`. Order matters — defaults first, because validating a still-zero field rejects a caller the default would have satisfied. Duplicating the `if == 0` chain in each constructor guarantees the two will drift the first time someone changes a default in only one of them. Constructor-specific rules stay in that constructor, on top of the shared pass. And if `NewWorker` and `NewPublisher` genuinely want different defaults for the same field, that is the signal to split the Config rather than branch inside it — one struct is quietly serving two contracts.
code
go · 21 linesfunc (c Config) withDefaults() Config {
if c.Workers == 0 {
c.Workers = defaultWorkers
}
return c // c is a copy; the caller's Config is untouched
}
func (c Config) validate() error {
if c.Workers < 1 {
return fmt.Errorf("config: Workers must be >= 1, got %d", c.Workers)
}
return nil
}
func NewWorker(cfg Config) (*Worker, error) {
cfg = cfg.withDefaults()
if err := cfg.validate(); err != nil {
return nil, err
}
return &Worker{cfg: cfg}, nil
}go deeper
Know that the checks belong in the constructor rather than scattered through later code, and that a constructor reports a bad Config by returning an error.
Be able to write withDefaults and validate as methods with value receivers, explain why the copy is returned rather than mutated, and justify the defaults-then-validate order.
Show how you stop two constructors drifting apart, where constructor-specific rules go, and why lazily validated configuration turns a deploy-time mistake into a runtime incident.
Decide whether the validation surface is exported and therefore promised to other teams, and when one shared Config should become two. Those calls outlive the code that motivated them.
## The failure mode this prevents When two constructors take the same `Config`, the naive shape copies the defaulting chain into both. It works on the day it is written. Six months later someone raises `defaultWorkers` from 4 to 8 in `NewWorker`, misses `NewPublisher`, and the same `Config{}` now produces two different behaviours. Nothing fails, no test catches it, and the divergence is only found when the numbers in a dashboard stop matching. The fix is structural: the rules belong to the `Config`, not to its consumers. ## Two methods, value receivers ```go func (c Config) withDefaults() Config { ... return c } func (c Config) validate() error { ... } ``` A value receiver is a copy, exactly like a value parameter, so `withDefaults` can assign to `c` freely and the caller's `Config` is untouched. Returning the copy — rather than taking a pointer receiver and mutating in place — keeps the whole thing side-effect free: `cfg = cfg.withDefaults()` says at the call site that a new value is being produced. Keep both methods unexported unless callers genuinely need to validate ahead of construction; exporting `Validate` is a reasonable choice for a Config that is decoded from an operator-supplied file, because it lets a `--check-config` path reuse exactly the rules the constructor will apply. ## Defaults before validation This ordering is the detail interviewers probe. `validate` should judge the values that will actually be used, which means it runs on the defaulted copy. Reverse the two and a caller who omits `Workers` is rejected with `Workers must be >= 1` for a field they never touched and the constructor was about to fill in. The same trap appears when validation is spread across several helpers: one of them runs before the defaults and rejects a legal empty Config. The other half of the ordering rule is that a required field is *not* a defaulting concern. `Queue` has no default; its check belongs in `validate` and produces a real error. Optional fields get defaults; required ones get errors; the two passes stay separate in your head even though they run back to back. ## Shared rules versus per-constructor rules Not every rule is shared. `validate` holds the invariants that are true of the `Config` no matter who consumes it — ranges, non-empty required strings, cross-field consistency such as 'MaxIdle must not exceed MaxOpen'. A rule that only one consumer cares about (`NewPublisher` needs a topic; `NewWorker` does not) stays in that constructor, after the shared pass. If you find yourself passing a mode flag into `validate` so it can apply different rules, the Config is serving two contracts and should be two structs, possibly sharing an embedded common part. ## Validate at construction, not on first use The temptation is to check a field lazily, where it is used. Resist it: the whole value of a constructor is that the error surfaces at the moment the caller can still do something about it, with a stack that points at the call site. Lazily validated configuration fails on the first message, which for a long-running worker may be minutes after start-up, or at 3am under load. Construction is also the only place where returning an `error` is natural — deep in a method you are choosing between a panic and an awkward extra return value. ## Error messages are part of the API `fmt.Errorf("config: Workers must be >= 1, got %d", c.Workers)` names the package, the field, the rule and the offending value. That single line is what an operator reads at 3am. Prefixing with the package name matters because the message will be wrapped and printed far from here. If several fields can be wrong at once, consider collecting the failures and joining them, so a misconfigured deploy reports every problem in one restart rather than one problem per restart. ## After construction, the Config is frozen Once `New` stores the resolved copy, treat it as immutable. No method re-applies a default; no setter mutates it. If a value genuinely needs to change at runtime, that is a different mechanism — an explicit method with its own locking — and not a field on the construction-time Config. ## The interview point Name the drift risk, put the rules on the type, get the order right, and know when the shared Config has stopped being one thing.
- Why must defaulting run before validation and not after?Because validation should judge the values that will actually be used. Run it first and a caller who omitted an optional field is rejected for a zero the constructor was about to replace, so a legal empty Config fails with a confusing message about a field the caller never wrote.
- What if the two constructors want different defaults for the same field?That is a sign the struct is serving two contracts. Split it into two Configs, sharing an embedded common part if the overlap is large, rather than branching inside withDefaults on a mode flag. One struct with conditional meaning is harder to document than two honest ones.
- Should validate be exported?Export it when the Config is assembled elsewhere — decoded from operator input, or built by another package — so a pre-flight check can run exactly the rules the constructor will apply. Keep it unexported when the only path into the type is New, since an exported method is then a surface you must keep working.
saying these in an interview costs you the question
- Copies the same defaulting chain into every constructor
- Validates before applying defaults and rejects legal empty Configs
- Uses a pointer receiver so withDefaults mutates the caller's Config
- Defers validation to first use instead of construction
- Panics from a library constructor instead of returning an error
- Writes error messages that do not name the offending field