Our CRUD API works, but it trusts the client completely: send an empty title and the server saves it, and when something goes wrong it replies with a bare line of plain text. This chapter adds validation to reject bad input and gives every error response one consistent JSON shape.
If you send a POST /tasks request with an empty title today, the server stores it without complaining. If you request GET /tasks/999, the server returns a 404 status with Content-Type: text/plain and the text task not found. There are no JSON braces in sight. We are going to fix both problems. We will reject bad input before it touches the database, and we will answer every failure with the exact same small JSON object.

Here is what changes in this chapter:
create and update call a new Validate() method on Task before storing anything. A bad title becomes a 400 error.http.Error(...) call is replaced by a writeError helper that writes {"error":"..."} as JSON.writeStoreError helper maps the store's ErrNotFound to a 404 status and anything else to a generic 500 status, all in one place.Validation: Checking that data meets your rules before you act on it. In our case, we want to ensure a task has a non-empty title under 200 characters. If validation fails, the program stops and reports the problem instead of proceeding.
Error envelope: A fixed JSON shape used for every error response. We will use {"error":"<message>"}. A client only has to learn this one shape to handle every failure your API can produce.
4xx vs 5xx: HTTP splits failures based on who is at fault. A 4xx status means the client sent something wrong, like bad JSON or a missing title. A 5xx status means the server failed unexpectedly, even though the request itself was fine.
An empty title is not a valid task. If we store it anyway, every reader of that data inherits a value that never should have existed. This includes the list endpoint, a future search feature, or a human looking at the database. Garbage in, garbage out. Checking the data before it touches the store is the cheapest way to catch mistakes. It also means we never trust a network client with data we can validate for free on our own machine.
Consistency matters just as much as correctness. Before this chapter, a 404 error came back as text/plain, while a successful response came back as application/json. A client written against this API would need a special case for every error path. It would have to read the status code and then guess whether the body is JSON or plain text. Using one {"error":"..."} shape for every failure removes that guesswork. The client can always call response.json() and read the .error field if the status is not 2xx.
Finally, we want to avoid leaking internal details. A database timeout, a nil pointer, or a bug in our code should never turn into a raw Go error string in an HTTP response. That string might mention a table name, a file path, or stack details. This information is none of the client's business, and the client cannot act on it anyway. Instead, we log the real cause on the server and send back a flat, generic message to the user.
We will update internal/task/task.go with two sentinel errors, a constant, and a Validate method:
go// internal/task/task.goconst MaxTitleLength = 200var (ErrTitleRequired = errors.New("title is required")ErrTitleTooLong = errors.New("title must be 200 characters or fewer"))func (t *Task) Validate() error {t.Title = strings.TrimSpace(t.Title)t.Description = strings.TrimSpace(t.Description)if t.Title == "" {return ErrTitleRequired}if len(t.Title) > MaxTitleLength {return ErrTitleTooLong}return nil}
The Validate method uses a pointer receiver, *Task. The strings.TrimSpace function does not just check the title; it rewrites it directly on the receiver. This ensures that " Buy milk " gets stored neatly as "Buy milk". If we used a value receiver, Go would trim a copy of the task and throw the result away.
The method checks two rules in order. An empty title after trimming returns ErrTitleRequired. Anything over MaxTitleLength returns ErrTitleTooLong. Both are ordinary sentinel errors using the errors.New pattern from Chapter 12. In this case, the handler only reads their .Error() string rather than comparing them with errors.Is.
Validation lives on the Task struct itself, not inside the handler. This allows create and update to share one set of rules instead of duplicating the checks. We are writing this by hand so you can see exactly how it works. A larger project would likely use a struct-tag-driven library like go-playground/validator. We skip that here to keep the mechanics visible.
Next, we add a small error type and two helper functions to internal/task/handler.go. Every handler will now route its errors through these helpers:
go// internal/task/handler.gotype errorResponse struct {Error string `json:"error"`}func writeError(w http.ResponseWriter, status int, message string) {writeJSON(w, status, errorResponse{Error: message})}func writeStoreError(w http.ResponseWriter, err error) {if errors.Is(err, ErrNotFound) {writeError(w, http.StatusNotFound, "task not found")return}log.Printf("unexpected store error: %v", err)writeError(w, http.StatusInternalServerError, "something went wrong")}
The writeError function is very thin. It wraps a message in an errorResponse struct and hands it to the writeJSON helper from Chapter 17. This guarantees the Content-Type: application/json header is set in exactly one place.
The writeStoreError function maps the store's sentinel errors to HTTP status codes. errors.Is(err, ErrNotFound) becomes a 404. Anything else falls through to a generic 500. We log the real error with log.Printf before hiding it from the client.
Now we update the create handler. It calls Validate() right after decoding the request, before the store ever sees the task:
go// internal/task/handler.gofunc (h *Handler) create(w http.ResponseWriter, r *http.Request) {var t Taskif err := json.NewDecoder(r.Body).Decode(&t); err != nil {writeError(w, http.StatusBadRequest, "invalid request body")return}if err := t.Validate(); err != nil {writeError(w, http.StatusBadRequest, err.Error())return}created, err := h.store.Create(r.Context(), t)if err != nil {writeStoreError(w, err)return}writeJSON(w, http.StatusCreated, created)}
The flow is simple: decode, validate, then store. If either of the first two steps fails, the function returns before store.Create is ever called. The update handler follows the exact same shape: parse the {id}, decode the body, validate, and then call store.Update. Every failure in both handlers, as well as in get and delete, now goes through writeError or writeStoreError instead of http.Error. There is not a single bare-text error response left in the package.
Boot the server the same way as before:
bashmake run
Open a new terminal and try sending an empty title, a valid title, a malformed body, and a request for a task that does not exist:
bashcurl -i -X POST http://localhost:8080/tasks -d '{"title":""}'curl -i -X POST http://localhost:8080/tasks -d '{"title":"Buy milk"}'curl -i -X POST http://localhost:8080/tasks -d 'not json'curl -i http://localhost:8080/tasks/999

Every failing request now comes back with Content-Type: application/json and a body shaped like {"error":"..."}. The empty title, the malformed body, and the missing task all share the same response format. They just carry different messages and status codes. The valid POST still returns a 201 status with the full task, unchanged from Chapter 20.
err.Error() on a database or JSON error can expose file paths, types, or internal details. A client has no business seeing these. Our writeStoreError function logs the real error and sends back a safe, generic "something went wrong" for anything that is not ErrNotFound.{"error": "..."} and another returns {"message": "..."}, the client code becomes messy with special cases. Route every failure through the same writeError helper to keep things uniform.store.Create has already written it means you have to undo a write you never should have made. Always validate first, then persist.In Chapter 23, we will split this single task package into distinct handler, service, and storage layers behind an interface. This will allow the storage mechanism to change without touching the HTTP code at all.