A REST API with Error Handling
This is the phase where everything from the last five clicks into place. You've got routing, groups, binding, validation, responses, and middleware. Now we assemble them into a real REST resource - and lean hard on a feature that's been quietly waiting since Phase 3: Echo's centralized error handling.
Here's the mental model to carry through this whole phase, two ideas held together:
- A REST resource is five handlers over one collection. For
books, that's: list them all, get one, create one, update one, delete one. Five verbs, one slice of the world. Every CRUD API you'll ever write is this same shape repeated. - In Echo, every failure path is "return an error." A handler doesn't write a 404 by hand. It
returns an error, and one place - theHTTPErrorHandler- decides how that error looks to the client. Your handlers describe what went wrong; the central handler decides how it's rendered.
Hold both at once and the code almost writes itself: five small functions, each doing its work and either returning JSON on success or returning an error on failure. No handler ever hand-rolls an error response.
We'll keep building the books API, where a book is Book{id, title, author}.
The in-memory store
Before the handlers, we need somewhere to keep books. We'll use a plain map for now - no database, no file, just memory. That keeps the focus on Echo instead of SQL.
type Book struct
type Store struct
func NewStore() *Store
What just happened: Book is the resource as the client sees it - note the json:"..." tags so it
serializes with lowercase keys. Store holds a map[int]Book keyed by ID, a nextID counter for
assigning new IDs, and - the important part - a sync.RWMutex. NewStore hands back a ready-to-use,
empty store with IDs starting at 1.
⚠️ That mutex is not optional, and this is the trap that bites people who skip it. Echo serves
requests concurrently - each request runs in its own goroutine. If two requests touch books at the
same moment (one reading while another writes), Go's map will panic with fatal error: concurrent map read and map write, unrecoverable - not even the Recover middleware from Phase 5 catches it. The fix
is to guard every access: a read lock (RLock) when only reading, a full write lock (Lock) when
modifying. We'll do exactly that in each handler below.
The five handlers
Now the heart of it. Each handler hangs off a versioned group - g := e.Group("/api/v1") - so all five
live under /api/v1/books. Each one does its work and ends one of two ways: return c.JSON(...) on
success, or return echo.NewHTTPError(...) on failure. Watch how that single rule plays out five times.
List - GET /api/v1/books
func (c echo.Context) error
What just happened: we take a read lock (RLock) because we're only reading - multiple list requests
can run at once without blocking each other, the whole point of RWMutex. We build a slice from the
map's values and return it as JSON with 200 OK. make([]Book, 0, ...) matters: it guarantees an
empty store serializes to [], not null.
Get one - GET /api/v1/books/:id
func (c echo.Context) error
What just happened: we pull :id from the path (always a string) and convert it with strconv.Atoi.
A non-numeric id is the client's mistake, so we return a 400. Then we look the book up under a read
lock. The comma-ok idiom (book, ok := s.books[id]) tells us whether it existed - if not, we
return echo.NewHTTPError(http.StatusNotFound, "book not found") rather than writing the 404 ourselves.
On a hit, 200 with the book.
Create - POST /api/v1/books
This is where Phase 3 comes back. Bind, validate, then act.
type CreateBook struct
func (c echo.Context) error
What just happened: same two-step gate from Phase 3 - c.Bind decodes the JSON, c.Validate runs
the validate:"..." rules, and either failure returns a 400 describing what broke. Only once both
pass do we take a full write lock (Lock, not RLock - we're mutating the map and the counter),
build the Book with the next ID, store it, bump the counter, and unlock. Success returns 201 Created
with the new resource so the client learns its assigned ID.
Update - PUT /api/v1/books/:id
func (c echo.Context) error
What just happened: update is get-and-create fused. We parse the id, bind+validate the new values
(reusing the same CreateBook struct - no second type needed), then take a write lock. We check the
book exists first; missing means 404. If it's there, we overwrite it - keeping the original id so
it stays addressable - and return 200 with the updated record. defer s.mu.Unlock() is safe here
even on an early return; defer runs on every exit path, including the 404.
Delete - DELETE /api/v1/books/:id
func (c echo.Context) error
What just happened: parse the id, take a write lock, confirm the book exists (404 if not), then
delete(s.books, id). The success response is c.NoContent(http.StatusNoContent) - a 204 with an
empty body, the conventional answer to a successful delete.
Wiring them up
All five mount on the /api/v1 group in main:
func main()
What just happened: one group, five routes, each pointing at a method on the shared Store. Because
the handlers are methods on *Store, they all close over the same map and mutex - that's how five
independent functions cooperate on one collection. We register the Phase 3 validator so c.Validate
works. Notice what's not here yet: any error-handling code. Echo already turns returned HTTPErrors
into JSON by default - but the default shape isn't quite what we want, so let's take control.
The payoff: a centralized HTTPErrorHandler
This is Echo's signature feature, the thing that makes all that return echo.NewHTTPError(...)
discipline pay off. Every error your handlers return - plus any error Echo itself raises (a 404 for an
unknown route, a 405 for the wrong method) - funnels through one function: e.HTTPErrorHandler.
Change that one function and you've changed how every error in the entire API looks.
By default, Echo's handler renders an *echo.HTTPError as {"message": "..."} with the right status,
and turns any other error into a generic 500. That's fine, but say your API has standardized on
{"error": "..."} instead. Rather than touch thirty handlers, you write the shape once:
e.HTTPErrorHandler = func(err error, c echo.Context)
What just happened: this function receives every error a handler returns. We start with safe defaults
500and a vague"internal error", because an unexpected error (a nil-pointer deref, a failed DB call later) should never leak its guts to the client. Then we type-assert: if the error is an*echo.HTTPError(whatecho.NewHTTPErrorproduces), we trust itsCodeandMessage, because we chose those deliberately. Finally we render one consistent JSON body:{"error": "..."}, every time. Abook not found, a malformedid, and a deep500all come out the same shape - only status and message differ. Register it inmainalongside the validator:
e.HTTPErrorHandler = customErrorHandler // the function above
💡 Across five handlers we wrote return echo.NewHTTPError(...) maybe eight times, and zero lines of
response-formatting code in any of them. The handlers stayed pure business logic - look up, check, act.
All the "what does an error look like on the wire" logic lives in one ten-line function. That's the
difference between an API that stays clean at fifty endpoints and one that rots into copy-pasted
c.JSON(400, ...) calls everywhere.
Taking it for a spin
Start the server, then exercise the books API with a few curl calls:
# Create a book → 201
# {"id":1,"title":"Dune","author":"Herbert"}
# List them → 200
# [{"id":1,"title":"Dune","author":"Herbert"}]
# Get one that doesn't exist → 404, your custom shape
# {"error":"book not found"}
# Create with an empty title → 400, validator's message
# {"error":"Key: 'CreateBook.Title' Error:Field validation for 'Title' failed on the 'required' tag"}
# Delete it → 204, empty body
|
# HTTP/1.1 204 No Content
What just happened: the happy paths return the resource as JSON with the right status. The two failure
paths - a missing book and a validation miss - both come back in your {"error": ...} shape, even
though one originated in a handler's NewHTTPError and the other in the validator. That uniformity is
the centralized handler doing its job.
💡 Notice the store was the only part tied to memory. Every handler talks to Store, never to a map
directly - so when you outgrow in-memory storage, you swap Store's guts for a database and the five
handlers don't change a line. GORM From Zero shows how to back this exact API
with a real SQL table. Same handlers, same error handling, real persistence underneath.
Recap
- A REST resource is five handlers over one collection: list (
200), get (200/404), create (201), update (200/404), delete (204). The books API is this shape, mounted on ag := e.Group("/api/v1"). - The in-memory
Storeis amap[int]Bookplus async.RWMutexand anextID. ⚠️ Echo serves requests concurrently - guard reads withRLockand writes withLock, or an unguarded map will panic unrecoverably. - Handlers stay clean by following one rule: bind → validate → do work →
return c.JSON(...)orreturn echo.NewHTTPError(...). They never hand-roll an error response. - The centralized
HTTPErrorHandleris Echo's signature feature: one function turns every returned error into one consistent shape (here{"error": ...}), with safe500defaults for unexpected errors and trusted codes/messages for*echo.HTTPError. - Because every handler talks to
Storeand never to a map directly, you can swap the store for a real database (GORM From Zero) without touching a single handler.
Quick check
[
{
"q": "Why does the in-memory Store need a sync.RWMutex?",
"choices": ["To make the JSON serialize faster", "Because Echo serves requests concurrently, and an unguarded map read+write panics", "Because echo.NewHTTPError requires a locked store", "It's optional; maps are already concurrency-safe in Go"],
"answer": 1,
"explain": "Echo runs each request in its own goroutine. Concurrent read and write on a plain Go map causes an unrecoverable fatal error, so shared state must be guarded with a mutex - RLock for reads, Lock for writes."
},
{
"q": "In an Echo handler, how should you report that a book wasn't found?",
"choices": ["Call c.JSON(404, ...) with a hand-built error body", "panic(\"not found\") and let Recover handle it", "return echo.NewHTTPError(http.StatusNotFound, \"book not found\")", "Return nil and set the status separately"],
"answer": 2,
"explain": "The Echo style is to return an HTTPError. Handlers describe what went wrong; the centralized HTTPErrorHandler decides how it's rendered - so handlers never hand-roll error responses."
},
{
"q": "What does customizing e.HTTPErrorHandler give you?",
"choices": ["One place that turns every returned error into one consistent response shape for the whole API", "Automatic validation of every request body", "Per-route error formatting that each handler configures itself", "Faster routing for the /api/v1 group"],
"answer": 0,
"explain": "The HTTPErrorHandler is a single function every error funnels through. Change it once and every error in the API - from handlers and from Echo itself - comes out in the same shape, with safe 500 defaults for unexpected errors."
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. Why does the in-memory Store need a sync.RWMutex?
2. In an Echo handler, how should you report that a book wasn't found?
3. What does customizing e.HTTPErrorHandler give you?