An error is a not-found because it says it is, not because of what type it happens to be. That one idea is what lets me delete the pile of errors.Is blocks out of every handler and put the whole HTTP-status decision in a single place. Here is how it hangs together, with the library I use for it, lathos.
The problem it solves
Without this, every handler grows the same tax. It calls a service, gets an error back, and has to decide what that means over HTTP: a 404, a 409, a 422, or a genuine 500? So you write a switch on sentinel errors in the handler, then a slightly different one in the next handler, and the day someone adds a new error class you get to update all of them. You will miss one.
The status decision does not belong in the handler. It belongs in one place, and the handler’s job is to hand the error upward and get out of the way.
Errors that describe themselves
lathos checks errors for behaviour, not type. It was inspired by Dave Cheney’s “don’t just check errors, handle them gracefully”, the assert-for-behaviour-not-type idea, turned into a small library you can import. An error “is” a not-found if it implements NotFound() bool, and nothing cares what the concrete struct behind it is.
So the service returns an error that carries its own meaning and a catalogue code (codes are the next post):
func (s *service) TriedReview(ctx context.Context, id string) (domain.TriedReview, error) {
r, ok := s.reviews[id]
if !ok {
return domain.TriedReview{}, errs.NewErrNotFoundf(
errcodes.NotReview, "tried review %q not found", id)
}
return r, nil
}errs.NewErrNotFoundf builds an error that satisfies lathos’s NotFound behaviour and carries an id, a code, a title and a detail. The service does not know that “not found” is going to become a 404. It is stating a fact about the domain, not making an HTTP decision.
The handler does nothing clever
func (h *ReviewHandler) TriedReview(c echo.Context) error {
r, err := h.reviews.TriedReview(c.Request().Context(), c.Param("id"))
if err != nil {
return err // straight up, no inspection
}
return c.JSON(http.StatusOK, toResponse(r))
}That return err is the whole error-handling story in the handler. No status code, no body assembly, no switch. If you catch yourself writing errors.Is in a handler to pick a status, the decision is in the wrong layer.
One place decides the status
Echo lets you register a single error handler for the whole server. Every error a handler returns lands here, and this is the only code in the service that maps an error class to an HTTP status:
switch {
case lathos.IsNotAuthenticated(err):
writeClient(c, http.StatusUnauthorized, err)
case lathos.IsNotAuthorised(err):
writeClient(c, http.StatusForbidden, err)
case lathos.IsNotFound(err):
writeClient(c, http.StatusNotFound, err)
case lathos.IsDuplicate(err), lathos.IsConflict(err):
writeClient(c, http.StatusConflict, err)
case lathos.IsCannotProcess(err):
writeClient(c, http.StatusUnprocessableEntity, err)
case lathos.IsBadRequest(err):
writeClient(c, http.StatusBadRequest, err)
default:
writeInternal(c, req, err) // 500: log it, tell the client nothing useful
}writeClient pulls the id, code, title and detail straight off the error (they sit on the lathos.ClientError interface) and writes the JSON. writeInternal does the opposite: it logs the real error server-side under a fresh id and hands the client back only that id and “Internal server error”, so nothing internal leaks.
Why behaviour and not type
Three things fall out of it, and they are why I have reached for this on every service for years.
It is decoupled. No call site imports a project-specific error package to inspect errors: the service builds them with lathos’s small errs helpers, and the handler checks them through lathos’s behaviour interfaces. I can add a whole new error class without touching a single call site that only returns one.
It is wrapped-error safe. The lathos.Is* functions use errors.As under the bonnet, so they walk the %w chain. Wrapping an error with fmt.Errorf("load review: %w", err) on the way up for context does not break the behaviour check at the top, so you get to add context and still land the right status.
And it composes. One error can be both Retryable and Unavailable, and the handler can act on whichever it cares about. You are not boxed into one type per error.
The payoff is boring, which is the point. Adding a new error class is one new behaviour and one new case in that switch, and every handler in the codebase gets it for nothing, because none of them were deciding status to begin with.