Error Handling & Project Structure
By Phase 6 the tasks API works: create a task, list them, fetch one, update, delete. But look closely
and you'll notice the handlers have quietly turned into a mess. Each one writes errors in its own little
dialect — one returns gin.H{"error": "..."}, another gin.H{"message": "..."}, a third forgets the
status code and leans on Gin's default. The business logic — "does this task exist?", "is the title
empty?" — is tangled up with the HTTP plumbing. It runs, but you wouldn't want to add a tenth route to it.
This phase fixes both problems at once, because they're the same problem wearing two hats.
The mental model: a handler is a translator
Here's the idea to hold onto before any code:
💡 A handler translates between HTTP and your domain — and nothing more. It reads the request (params, JSON body), hands the meaning down to your logic, takes back a result or an error, and translates that into a status code and a JSON body. The decisions — "this title is invalid," "no task has that id" — happen below the handler, in plain Go that knows nothing about HTTP.
When you internalize that, two things follow naturally. First, handlers get short and boring (a good
thing). Second, errors stop being a per-handler improvisation: your logic raises a plain Go error, and
one place turns every error into one consistent JSON shape. A client should be able to write
response.error once and have it work for every endpoint in your API. That consistency is not a nicety —
it's the difference between an API people enjoy and one they reverse-engineer.
The rest of this phase builds toward that, in layers:
- The basics — returning errors from inside one handler (and the
returnyou must not forget). - Centralizing —
c.Errorplus a middleware that writes the single error shape. - Mapping meaning to status — sentinel errors in a service layer, matched with
errors.Is. - Structure — splitting the one big file so each layer has a home.
1. Per-handler errors, and the return that bites everyone
The simplest way to report an error is right where you find it. You write a JSON body with a status code and stop. Here's a handler that fetches one task by id from an in-memory store:
What just happened: if the id isn't in the map, we write a 404 with an error body — and then return.
That return is doing real work, and forgetting it is the single most common Gin bug.
⚠️
c.JSONdoes not stop your handler. Writing a response does not end the function — Go keeps running the next lines. Without thereturn, this handler would write the404body and then fall through toc.JSON(http.StatusOK, task), trying to write a second response. The status is already sent, so Gin logs aheaders already writtenwarning and the client gets a garbled body. Every error branch that writes a response must be followed byreturn.
There's a second helper that bundles "write and stop" into one call:
What just happened: AbortWithStatusJSON writes the body and sets the abort flag on the context, so
no later middleware in the chain runs its post-c.Next() code as if the request succeeded. Note the
return is still there — abort flips a flag, it doesn't perform a Go return for you. Use
AbortWithStatusJSON when you specifically want to halt the middleware chain (auth failures, rate limits);
plain c.JSON + return is fine for ordinary "not found" type errors inside the final handler.
So far so good — but if you write this in ten handlers, you've hand-rolled the error shape ten times. That's the problem the next two layers solve.
2. Centralized errors: c.Error + an error middleware
Gin gives the context a small superpower: every *gin.Context carries a slice of errors, c.Errors. You
can attach an error to it without writing any response:
What just happened: c.Error(err) appends the error onto c.Errors and returns — it does not touch
the response body. The handler's job shrinks to "decide the status, attach the error, get out." Something
else will turn that attached error into JSON. That "something else" is a middleware.
A middleware can run code after the handler by calling c.Next() first, then inspecting what the handler
left behind. So we write an ErrorHandler that, once the chain is done, checks c.Errors and — if there's
anything there — writes one consistent body:
.HandlerFunc
What just happened: c.Next() runs the rest of the chain (other middleware, then the handler). When it
returns, the handler has finished and may have attached errors. If c.Errors is non-empty we write the
single, canonical error shape — {"error": "..."} — using the last attached error. Now every endpoint
that calls c.Error produces identical JSON. Register it once, in front of everything:
gin
r
What just happened: r.Use installs the middleware globally, so it wraps every route. Because it's the
one writing error responses, changing the error format for your whole API is now a one-line edit in one
place — the dream we were chasing.
📝 The version above always writes
500, which is too blunt — a "not found" is a404, not a server error. The status needs to depend on what kind of error it is. That's exactly what the next layer adds.
3. Sentinel errors: mapping meaning to status
Right now the middleware can't tell a "not found" from a real crash, because errors.New("task not found") is just a string. The fix is to give errors an identity your code can test for. A sentinel
error is a package-level error value you compare against:
package store
What just happened: these are single, shared values. Your logic returns ErrNotFound, and anyone holding
the error can ask "is this that error?" with errors.Is(err, store.ErrNotFound) — even if the error has
been wrapped on the way up. They're the vocabulary your domain speaks in.
Your logic returns them instead of ad-hoc strings:
What just happened: Get knows nothing about HTTP status codes — it speaks pure domain. "I couldn't find
it" is ErrNotFound, full stop. That's the whole point: this code is testable and reusable without dragging
a *gin.Context through it.
Now the handler attaches whatever the store returns, and the middleware does the translation:
.HandlerFunc
.HandlerFunc
What just happened: the handler no longer decides status codes at all — it attaches the error and leaves.
The middleware uses errors.Is to recognize each sentinel and pick the right HTTP status: ErrNotFound →
404, ErrEmptyTitle → 400, anything unrecognized → a generic 500 (and notice it does not leak the
raw internal error string to the client for the unknown case — you log those, you don't expose them). All
the HTTP knowledge lives here; all the domain knowledge lives in the store. Each layer does one job.
💡 This is the payoff of the mental model. "What does this mean?" is decided once, in the store, as a typed value. "What HTTP status does that meaning deserve?" is decided once, in the middleware. Handlers stop carrying either decision and become the thin translators they were always supposed to be.
4. Project structure: splitting the one big file
A single main.go was fine when the whole app fit on a screen. With handlers, a store, models, sentinel
errors, and middleware, it's time to give each its own package. The goal isn't ceremony — it's that you
can open the right file by name and that the compiler enforces the layering.
Here's a layout that scales without being heavy:
tasks-api/
go.mod
main.go # wire the router + dependencies, then Run
models/
task.go # the Task struct, no logic
store/
store.go # in-memory store + the sentinel errors (business logic + data)
handlers/
tasks.go # HTTP in, HTTP out — thin translators
middleware/
errors.go # the ErrorHandler
What just happened: the dependency arrow points one way. handlers import store; store imports
models; nothing imports handlers. main is the only place that knows about all of them — it's the
wiring closet. If you ever feel tempted to import gin inside store, that's the structure telling you a
decision is in the wrong layer.
The piece that ties it together is dependency injection — main creates the store and hands it to
the handlers, rather than the handlers reaching for a global. That's why the handlers in step 3 were
written as func getTask(s *store.Store) gin.HandlerFunc — they're closures that capture the store:
// main.go
package main
What just happened: main builds the store once and passes s into each handler factory, so every
request shares the same data. The handlers never see a global — they only know the store they were handed,
which makes them trivial to test (in Phase 8 you'll hand them a store you control). The route group keeps
the URLs tidy, and the middleware is registered before the routes so it wraps them all.
📝 Config via the environment.
os.Getenv("PORT")reads the port from the environment, falling back to8080for local dev. This is the twelve-factor habit: anything that changes between your laptop and production — port, database URL, log level — comes from env vars or flags, never hard-coded. Phase 8 leans on this when you containerize and deploy.
That's the whole refactor. The app does exactly what it did at the end of Phase 6, but now a new endpoint
is a small handler in handlers/, a method on the store, and one line in main — and its errors come out
in the same shape as everything else, for free.
Recap
- A handler is a translator: read HTTP in, hand meaning to your logic, translate the result or error back out. Validation and business decisions live below it.
c.JSON/c.AbortWithStatusJSONwrite per-handler errors, but you mustreturnafter writing —c.JSONdoes not stop the handler, and falling through writes a second, broken response.c.Error(err)attaches an error toc.Errorswithout writing anything; an error middleware callsc.Next()then inspectsc.Errorsand writes one consistent JSON shape for the whole API.- Sentinel errors (
var ErrNotFound = errors.New(...)) in your service/store layer give errors an identity; the middleware maps them to status codes witherrors.Is. - Split the single file into
main(wiring),handlers(HTTP),store/service(logic + data), andmodels— withmaininjecting the store into handlers and config coming from env vars.
Quick check
[
{
"q": "After writing an error response with c.JSON inside a handler, why must you call return?",
"choices": ["c.JSON is asynchronous and return waits for it", "c.JSON does not stop the handler, so without return Go falls through and tries to write a second response", "return is what actually sends the body to the client", "It frees the gin.Context memory"],
"answer": 1,
"explain": "c.JSON only writes a response; the handler keeps running. Without return it falls through to later code and writes a second response, causing a 'headers already written' error."
},
{
"q": "What does c.Error(err) do?",
"choices": ["Immediately writes a 500 JSON response", "Appends the error to c.Errors and writes nothing, leaving the response to a later middleware", "Aborts the request and skips all remaining handlers", "Logs the error to stderr and returns"],
"answer": 1,
"explain": "c.Error attaches the error to the context's c.Errors slice without touching the response body. An error-handling middleware inspects c.Errors after c.Next() and writes the response."
},
{
"q": "How does the error middleware turn a store's ErrNotFound into a 404 instead of a 500?",
"choices": ["By string-matching err.Error() against 'not found'", "By checking the HTTP status the store already set", "By comparing the attached error with errors.Is(err, store.ErrNotFound) and choosing the status", "Gin maps sentinel errors to status codes automatically"],
"answer": 2,
"explain": "The store returns the sentinel value ErrNotFound; the middleware uses errors.Is to recognize it (even if wrapped) and picks 404. The HTTP status decision lives in one place."
}
]
← Phase 6: Building a REST API · Guide overview · Phase 8: Testing & Production →
Check your understanding
1. After writing an error response with c.JSON inside a handler, why must you call return?
2. What does c.Error(err) do?
3. How does the error middleware turn a store's ErrNotFound into a 404 instead of a 500?