Fail-Fast — Middle¶
1. Where Fail-Fast actually shows up in Go¶
At a junior level, Fail-Fast is "validate at the top of your function". At a middle level, it's a whole-system discipline: where do you put validation, what shape do errors take, how do they survive being wrapped, when do you panic instead, and how do you make all of this consistent across a codebase.
You'll see Fail-Fast in:
- HTTP handler input parsing — reject malformed JSON before any business logic.
- gRPC server methods —
req.Validate()fromprotoc-gen-validate, or buf's newerprotovalidate-go. - Cobra CLI commands —
Args: cobra.ExactArgs(2),RunEthat returns errors. - Database driver setup —
db.Ping()immediately afterOpento catch DSN issues. - Startup config loading — bail at
main()if config is unreadable or missing required fields. - Loop bodies —
if err := ctx.Err(); err != nil { return err }before each expensive iteration. must-helpers at init —regexp.MustCompile,template.Must, panicking constructors for "this can't be wrong".
The pattern is the same in each case: detect the bad state at the earliest defendable point and surface it loudly. The middle-level skill is knowing which point and how loudly.
2. Boundary placement: where to validate¶
A common mistake is to validate everywhere. Validation has a cost; duplication has a maintenance cost. Pick the boundary where untrusted data crosses into typed, trusted code:
| Boundary | Validation kind | Example |
|---|---|---|
| HTTP / gRPC ingress | Schema + business rules | JSON Decode + field checks |
| Message queue consumer | Schema + replay-safety | protobuf Validate + idempotency check |
| Database read | Format only (data was validated on write) | Parse time.Time, decode JSON column |
| Internal function call | None (trust internal code) | — |
| External API call (you're the caller) | Response schema + error code | check HTTP status + decode body |
Internal-to-internal calls don't need defensive checks. Once data has passed the boundary, it's typed Go values; trust them. Re-validating at every layer is wasted code that makes refactoring harder.
The boundary is the gate. Past the gate, the type system carries the weight.
3. Error shape: meaningful messages¶
A useful Fail-Fast error tells the caller exactly what was wrong:
A useless one says only "invalid input". The middle-level move is a structured error type when callers need to act on the failure:
type ValidationError struct {
Field string
Reason string
Value any
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: field %q invalid: %s (got %v)", e.Field, e.Reason, e.Value)
}
// usage
return &ValidationError{Field: "percent", Reason: "must be 0..100", Value: percent}
Now the HTTP layer can convert to a 400 with a field-keyed JSON response. Tests can assert errors.As(err, &vErr). Logs can carry structured fields. The error stops being a string and becomes data.
For multi-field accumulation use errors.Join (Go 1.20+):
var errs []error
if req.Email == "" { errs = append(errs, &ValidationError{Field: "email", Reason: "required"}) }
if req.Age < 0 { errs = append(errs, &ValidationError{Field: "age", Reason: "must be ≥ 0", Value: req.Age}) }
return errors.Join(errs...) // nil if no errors
Caller-side:
if err := validate(req); err != nil {
var vErr *ValidationError
// errors.As walks Join'd errors too
if errors.As(err, &vErr) { /* one field's worth */ }
}
4. Validation libraries vs hand-written¶
Two practical libraries dominate:
go-playground/validator — struct tags drive validation:
type CreateUserRequest struct {
Email string `validate:"required,email"`
Age int `validate:"gte=0,lte=130"`
Name string `validate:"required,min=2,max=100"`
}
v := validator.New()
if err := v.Struct(req); err != nil { return err }
Pros: zero code per request type. Cons: struct tags grow unwieldy; harder to test rules in isolation; some rules require custom registrations anyway.
bufbuild/protovalidate-go — generated from .proto constraints:
message CreateUserRequest {
string email = 1 [(buf.validate.field).string.email = true];
int32 age = 2 [(buf.validate.field).int32.gte = 0, (buf.validate.field).int32.lte = 130];
}
Validator is generated. Pros: schema and validation co-located; consistent across services. Cons: requires protobuf workflow.
Hand-written validation wins when rules are complex (cross-field invariants, multi-step lookups), or when you want every check to be unit-testable. Library-driven wins when there are dozens of nearly-identical request types.
5. Three flavours, one decision tree¶
Fail-Fast comes in three Go-shaped flavours; pick deliberately:
| Tool | When to use |
|---|---|
error return | Expected failures: bad user input, network failure, race against another writer |
panic | Programmer errors: violated invariant, contract broken, "this is a bug if it ever happens" |
ctx.Err() check | The caller has given up; bail before more work |
Rules of thumb:
- A bad HTTP request should never panic the server. Return 400.
- A misconfigured
panic-on-error path that gets hit in production once a month is worse than the bug it was trying to surface — every panic is downtime. - Reserve
panicfor things only fixed by deploying new code. Reserveerrorfor everything else.ctx.Err()is orthogonal — it's about cooperation, not validation.
must-helpers (regexp.MustCompile, template.Must) are panics at init. They're allowed because their failure means "this binary cannot start" — and that's a deployable fix, not a runtime hazard.
6. Init-time validation: catch it before traffic¶
The earliest possible Fail-Fast is at process startup:
func main() {
cfg, err := config.Load()
if err != nil { log.Fatalf("config: %v", err) }
if cfg.DBURL == "" { log.Fatal("config: DB_URL required") }
db, err := sql.Open("postgres", cfg.DBURL)
if err != nil { log.Fatalf("db open: %v", err) }
defer db.Close()
if err := db.PingContext(ctx); err != nil { log.Fatalf("db ping: %v", err) }
// ... start the server
}
By the time you accept traffic, the system has demonstrated: config readable, DB reachable, dependencies up. A bad config that only fails on the first request is hours of wasted "is it our service?" debugging — Ping at startup turns that into 2 seconds of "server didn't start".
This is defensive, and it's the rare case where defensiveness is correct.
7. context.Context as a Fail-Fast signal¶
ctx.Err() becomes Fail-Fast in long-running work:
for _, item := range items {
if err := ctx.Err(); err != nil { return err } // skip remaining work
if err := process(item); err != nil { return err }
}
If the caller cancelled the context, the loop bails before fetching the next item. This is the cancellation-aware companion to the "early validation" rule. Same spirit: stop wasted work as early as you can detect it's wasted.
Watch for the trap: ctx.Err() only tells you whether the context has been cancelled. It doesn't help if the work inside process blocks on something that doesn't respect the context. The check is only as useful as the next thing's cancellation discipline.
8. Wrapping vs not wrapping¶
When a low-level function fails, wrap with %w so callers can unwrap:
The wrap preserves the original; errors.Is(err, sql.ErrNoRows) still works.
Don't wrap when:
- You're at the top of a handler returning HTTP/gRPC errors. The error message is going to the client; don't leak internal detail.
- You're returning a sentinel error (
io.EOF) and callers doerr == io.EOF. Wrapping breaks that. (Modern code useserrors.Is, but legacy callers may not.) - You're already at the right level of abstraction.
The wrap-or-not call is a middle-level judgment about who reads the error: the user, the developer, or both.
9. Constructors and the "half-built object" trap¶
A common bug:
func NewServer(cfg Config) *Server {
s := &Server{cfg: cfg}
s.db, _ = sql.Open(cfg.DSN) // err swallowed
return s
}
Now s.db may be nil. The bug surfaces on the first request, not at startup. The fix is to fail at construction:
func NewServer(cfg Config) (*Server, error) {
if cfg.DSN == "" { return nil, errors.New("server: DSN required") }
db, err := sql.Open(cfg.DSN)
if err != nil { return nil, fmt.Errorf("server: open db: %w", err) }
return &Server{cfg: cfg, db: db}, nil
}
Constructors that can fail must return an error. Period. Half-built objects are how you ship null-pointer crashes to production.
10. Common middle-level mistakes¶
- Validating after partial work has happened. "Charge the card, then check the address" leaves the customer charged with no shipment.
- Wrapping the wrong error.
fmt.Errorf("save: %w", err)whereerris itself already wrapped twice; the chain grows and the meaning fades. - Sentinel comparison with
==instead oferrors.Is. The wrap is invisible to==. - Treating
errors.Joinas a single error. The Join wraps multiple; UI logic needs to walk them. - Panicking on user input. A bad request shouldn't crash the server. The right answer is
400+ a logged error. log.Fataldeep in a library. Libraries shouldn't kill processes. Return an error; let the caller decide.- No
Validate()method on a struct when the same checks are repeated in three handlers. Move them onto the type.
11. Summary¶
Middle-level Fail-Fast is about placement and shape. Place validation at the boundary (HTTP, RPC, queue ingress), not deep inside business logic. Shape the error as a typed ValidationError (or errors.Join of them) so callers can act on it. Reach for panic only for unrecoverable programmer errors and must-helpers at init. Add ctx.Err() checks in long loops. Construct with errors. Wrap with %w so the chain survives. The point is the same throughout: the earlier and louder the failure, the cheaper it is to fix.
Further reading¶
errorspackage documentation (Go 1.13 wrap, 1.20 Join)go-playground/validatordocsbufbuild/protovalidate-godocs- Dave Cheney, "Don't just check errors, handle them gracefully"
- Rob Pike, "Errors are values"
- Eric Allman, "The Robustness Principle Reconsidered" (Postel's law critique)
sony/gobreaker— circuit breaker (advances Fail-Fast into runtime)