In a Go main, why should a constructor that opens a dependency return an error instead of calling log.Fatal?
answer
- the caller owns the decision, not the callee
- one place in the binary may exit
- an ignored error still returns a value
- os.Exit runs no deferred functions
basics
~20 sOnly the program's entry point can decide what a startup failure means. Returning an error lets it stop before the graph is half-built and close what is already open; log.Fatal calls os.Exit, which terminates immediately and skips every deferred cleanup.
solid answer
~50 sA constructor that touches the outside world — opening a directory, dialling a service, reading a key — reports failure the ordinary way, as `(*BlobStore, error)`, wrapped with `fmt.Errorf("blob store: %w", err)` so the failing layer is named. That leaves the decision where it belongs: `main` can abort, retry, or come up without an optional piece. If the constructor calls `log.Fatal` instead, it calls `os.Exit` under the hood, which does not run deferred functions, so anything already opened is abandoned and the caller gets no say. The failure this prevents is a half-built graph: an ignored error leaves a collaborator that looks constructed, the process starts, passes a shallow health check, and fails on the first real request. The common idiom is a `run() error` function holding all the wiring, with `main` doing nothing but `if err := run(); err != nil { log.Fatal(err) }` — one exit point, and every `defer` in `run` still unwinds.
code
go · 21 linesfunc main() {
if err := run(); err != nil {
log.Fatal(err) // the only os.Exit in the binary
}
}
func run() error {
store, err := NewBlobStore("/var/lib/thumbs")
if err != nil {
return fmt.Errorf("blob store: %w", err)
}
defer store.Close()
cache, err := NewCache(256)
if err != nil {
return fmt.Errorf("thumbnail cache: %w", err)
}
defer cache.Close()
return serve(NewThumbnailer(store, cache, 4096))
}go deeper
Be ready to write a constructor with the signature (*T, error), check the error at the call site, and say why the value it returned alongside a non-nil error must not be used.
Explain that log.Fatal calls os.Exit and that os.Exit skips deferred functions, and show the run() error shape that keeps one exit point while letting defers unwind.
Describe the half-built-graph failure you have actually seen — process up, health check green, first request failing — and how wrapped startup errors name the collaborator, path and cause for whoever is bringing the service up.
Own it as a policy for a fleet of binaries: exactly one place per binary converts an error to an exit code, libraries never exit, and startup failures are made loud enough that the rollout stops rather than the pager firing later.
## The rule A constructor that can fail returns `(*T, error)`. A constructor that cannot fail returns just `*T`. Nothing below the entry point exits the process. ```go func NewBlobStore(dir string) (*BlobStore, error) { if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("blob store %s: %w", dir, err) } return &BlobStore{dir: dir}, nil } ``` The signature is the contract: callers can see that construction reaches outside the process, and the `%w` verb keeps the underlying cause inspectable rather than flattening it to text. ## Why the entry point decides The wiring function is the only code that knows what the process is for. Faced with an unreachable dependency, it has real options: stop with a clear message, wait and retry for a bounded time, or continue without an optional piece. A constructor buried three packages down knows none of that. When it calls `log.Fatal`, it takes the decision away from every caller it will ever have — including the test that wanted to assert the failure, and the second binary that imports the same package and would rather degrade. `log.Fatal` matters mechanically too. It prints and then calls `os.Exit(1)`, and `os.Exit` terminates the program immediately: **deferred functions do not run**. Every `defer store.Close()` already queued is skipped, buffered writes are lost, temporary directories stay behind. `log.Panic` is not the answer either — it unwinds only the panicking goroutine's frames and produces a stack dump nobody wants at startup. ## The half-built graph The failure mode that makes this a real interview question is not the crash — it is the *non*-crash. Wiring code that ignores an error keeps going: ```go store, _ := NewBlobStore("/var/lib/thumbs") // dir not writable in the new environment cache := NewCache(256) thumb := NewThumbnailer(store, cache, 4096) ``` Now the process starts. The listener binds, a health check that only reports "the HTTP server answers" goes green, the deployment is marked successful, and traffic arrives. Every request then fails inside the thumbnailer, or panics on a nil dereference, at 3am, in whichever environment happened to have the wrong permissions. The whole point of returning the error is that construction stops at the exact line that failed, with a message naming the collaborator, before anything downstream exists. ## The `run() error` idiom The standard shape gives you one exit point and keeps `defer` working: ```go func main() { if err := run(); err != nil { log.Fatal(err) } } func run() error { store, err := NewBlobStore("/var/lib/thumbs") if err != nil { return fmt.Errorf("startup: %w", err) } defer store.Close() // ... build the rest, then serve return nil } ``` Everything the wiring opened is closed on the way out, whether the exit is a startup failure, a shutdown signal, or an error from serving. `main` itself has one job — turning an error into an exit status — and that is the single place in the binary allowed to call `log.Fatal` or `os.Exit`. It also makes the wiring testable: a test can call `run` (or a variant taking a directory and a listener) and assert on the error instead of watching a process die. ## Ordering and cleanup on partial failure Because construction happens in dependency order, a failure halfway through leaves some collaborators open and others not yet created. `defer` handles that exactly right: only the `defer`s reached so far are queued, and they run in reverse order when `run` returns. There is no need for a manual unwind list, and no risk of closing something that was never opened. That property disappears the moment any layer calls `os.Exit`, which is the concrete reason the rule is stated as "no library exits the process". ## Wrapping and messages Wrap once per layer, with the name of the thing that failed and no repetition of the layer below: `fmt.Errorf("thumbnail cache: %w", err)`. The resulting message read at startup — `startup: blob store /var/lib/thumbs: mkdir /var/lib: permission denied` — tells the engineer bringing the service up in a new environment exactly which collaborator, which path, and which syscall. That is the output the whole convention exists to produce, and it is worth writing the wrap for even when the error looks self-explanatory in development. ## Cheap checklist - Constructor touches the outside world → returns `error`. - Wiring code checks every constructor error and returns it wrapped. - Cleanup is registered with `defer` immediately after a successful construction. - Exactly one place in the binary turns an error into an exit code.
- What actually goes wrong when wiring code ignores a constructor's error and keeps building?You get a half-built graph. The process starts, binds its listener, and passes any health check that only proves the server answers — so the rollout is marked successful. The failure surfaces on the first real request, as an error from a collaborator that was never usable or a nil dereference, in whatever environment had the wrong permissions or address.
- How does the run() error idiom interact with defer?Every `defer` registered in `run` is queued only after its construction succeeded, and all of them unwind in reverse order when `run` returns — startup failure, shutdown signal or serve error alike. Keeping `log.Fatal` in `main`, after `run` has returned, means the exit never skips that cleanup, which is exactly what calling `log.Fatal` deeper down would break.
- Is it ever right for a constructor to panic instead of returning an error?Only for a programming error the caller could not recover from and should never ship — a nil required parameter, or a package-level `regexp.MustCompile`-style value built from a constant. Anything that depends on the environment (a path, an address, a credential) is a runtime condition and belongs in the error return, because a different environment is exactly when it fires.
saying these in an interview costs you the question
- Calls log.Fatal from inside a library constructor
- Believes log.Fatal unwinds and runs deferred functions
- Discards the constructor error and uses the returned value anyway
- Wraps nothing, so the message never names which collaborator failed
- Retries forever inside the constructor so startup hangs instead of failing