Skip to content

400 or 404: the Right Error Status from a Go Service Layer

Adityo Guni Waluyo

A Create with an unknown category_slug returns 400, not 404: sentinel errors separate bad input values from missing resources.

TL;DR

HTTP 400 covers invalid requests. HTTP 404 describes missing resources. Error handling uses sentinels.

The first Create request against the new organisation service returned 400. The payload looked fine: name filled, status active, members an array with two entries, and a category_slug that had existed in the category table until it was renamed two days earlier.

The instinct was immediate and wrong. The category is not there, so this should be 404.

The reference is wrong, not the address

The request went to POST /organisations. That path exists, the router matched it, the body arrived intact. Nothing about the address of a resource was missing, so 404 had nothing to describe. What was missing was a value inside the payload: the client named a category the service cannot resolve. HTTP 400 covers that, because the request is unusable as sent [3]. HTTP 404 describes a different situation, where the URI points at something that does not exist [4].

A second example keeps the line clear. Fetching GET /organisations/<id> for a deleted row is a 404: valid address, absent resource. Creating an organisation with an unknown category is a 400: valid address, input referencing something unresolvable. The same word, missing, describes two different failures.

The distinction matters beyond tidiness. A client that receives 404 on a write request will usually retry against a different path, or conclude the collection endpoint was removed. A client that receives 400 knows the body needs fixing and that the endpoint is fine.

One sentinel, wrapped, checked with errors.Is

The service layer cannot return HTTP status codes; it returns errors, and the handler decides. The category check therefore needed an error value the handler could recognise without importing service internals or parsing message text.

Go 1.13 added error wrapping and the errors.Is and errors.As pair for exactly this [1][2]. Declaration and use:

var ErrValidation = errors.New("validation failed")

// service
func (s *OrgService) Create(ctx context.Context, in CreateInput) (*Org, error) {
    if !s.categoryExists(ctx, in.CategorySlug) {
        return nil, fmt.Errorf("category_slug %q: %w", in.CategorySlug, ErrValidation)
    }
    return s.repo.Insert(ctx, in)
}

// handler
switch {
case errors.Is(err, service.ErrValidation):
    writeStatus(w, http.StatusBadRequest)
case errors.Is(err, repository.ErrNotFound):
    writeStatus(w, http.StatusNotFound)
}

The %w verb wraps the sentinel while keeping the message readable in logs. The handler then branches on identity, not on text:

ErrValidation is declared once in the service package and reused by the name, category, status, and member rules. The validator returns it wrapped with the field name, so only the human-readable part differs. Repository misses keep their own ErrNotFound, which maps to 404. Two sentinels, two statuses, and no coupling between layers beyond those two names.

Alternatives that were rejected

The first alternative was a custom error type per rule: separate types for unknown category, bad status, duplicate member. It sounds more precise. In practice every one of them maps to the same 400, so the extra types produce no extra behaviour. The handler gains a branch per type, the package gains a file per rule, and the switch grows every time a validator is added. An error type pays for itself when different rules need different responses. Here they do not.

The second alternative was matching on the message: if strings.Contains(err.Error(), "unknown category"). It works until someone rewords the message during a cleanup pass. The handler then falls through to the default branch and returns 500 for a client mistake. Nothing in review catches that, and the symptom appears only as a rise in server errors. Wrapped sentinels take the message out of the contract: the message is for humans, the sentinel is for the machine.

What was kept, then, is two sentinels and one validator, with no per-rule types. The open question is what happens when the service starts validating references that live in another service. A dependency being unreachable is neither bad input nor a missing address, and 400 will read wrong for it. That decision waits for the first such call rather than being guessed now.

## <p><strong>Sources:</strong></p><ul><li><a href="https://go.dev/blog/go1.13-errors">go.dev/blog/go1.13-errors</a></li><li><a href="https://pkg.go.dev/errors">pkg.go.dev/errors</a></li><li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/400">developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/400</a></li><li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/404">developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/404</a></li></ul> ---

Slugs: EN /en/400-or-404-go-service-layer, ID /id/400-atau-404-status-error-dari-service-layer-go.

Skipped: no brand alias map supplied, so slugs derive from topic only. Send the map if a fictional brand must appear in them.

Related articles