Stage 7 · Master
Phase 2 — Organization Service
Error Handling
Collapse six duplicated respondError call sites into one middleware — and delete the function this phase has been quietly repeating since the Handler Layer lesson.
Naming the Debt the Last Two Lessons Deliberately Left
By the end of the Validation lesson, all six OrganizationHandler methods, plus two new validation-failure branches, call respondError(c, err) explicitly at every failure point — eight call sites in total, every one identical in shape. This lesson does not add a new capability; it removes duplication that was left in place on purpose so its cost would be visible before being paid down.
c.Error Collection Instead of a Shared Function Call
Gin's *gin.Context carries its own Errors slice, populated by calling c.Error(err) instead of writing a JSON response directly. A single middleware, registered once and running after every handler via c.Next(), inspects that slice and writes exactly one response — the same respondError logic, but invoked once per request instead of once per call site.
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/hoa-platform/backend/pkg/apperr"
)
// ErrorHandler must be registered before any route handler runs (it calls
// c.Next() to let the handler execute first) and is the only place in the
// entire service that maps an apperr.Code to an HTTP status.
func ErrorHandler() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
if len(c.Errors) == 0 {
return
}
err := c.Errors.Last().Err
appErr, ok := apperr.As(err)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{"code": "INTERNAL", "message": "unexpected error"},
})
return
}
status := http.StatusInternalServerError
switch appErr.Code {
case apperr.CodeNotFound:
status = http.StatusNotFound
case apperr.CodeConflict:
status = http.StatusConflict
case apperr.CodeInvalidInput:
status = http.StatusUnprocessableEntity
}
c.JSON(status, gin.H{
"error": gin.H{
"code": appErr.Code,
"message": appErr.Message,
"details": appErr.Details,
},
})
}
}
This is line-for-line the same Code-to-status switch respondError has used since the Handler Layer lesson. Nothing about the mapping changes here — only where it runs, and how many times its source is written down.
func New(orgHandler *handler.OrganizationHandler) *gin.Engine {
engine := gin.New()
engine.Use(gin.Recovery())
engine.Use(middleware.ErrorHandler())
// unchanged below this line
middleware.ErrorHandler runs after gin.Recovery, so a panic recovered by Recovery still short-circuits to Recovery's own response — this middleware only ever sees errors a handler attached deliberately via c.Error.
Deleting respondError and Every One of Its Eight Call Sites
if err := c.ShouldBindJSON(&req); err != nil {
- respondError(c, apperr.InvalidInput("invalid request body", nil))
+ c.Error(apperr.InvalidInput("invalid request body", nil))
return
}
org, err := h.svc.Create(c.Request.Context(), req)
if err != nil {
- respondError(c, err)
+ c.Error(err)
return
}
-func respondError(c *gin.Context, err error) {
- // ... the entire apperr.Code-to-status switch, deleted
-}This is the whole change, repeated eight times and then followed by one deletion. Every return statement stays exactly where it was, which is what makes the refactor safe to review: the control flow is untouched, only the destination of the error changed. After this edit the file has zero references to respondError and no longer imports the status-mapping switch at all.
Calling c.Error(err) does not stop the current handler method from continuing to execute — it only records the error. Every call site above still needs its own explicit return immediately after c.Error, exactly as it needed one after respondError before. Forgetting a return after c.Error is a real, easy-to-introduce bug: the handler would proceed to also call c.JSON with a partial or nil result, and Gin would attempt to write a second response for the same request.
Confirming the Response Shape Is Byte-for-Byte Unchanged
This refactor is invisible to any client — the goal is that a request that returned a given status and body before this lesson returns the identical status and body after it. The verification below re-runs exactly the duplicate-slug scenario from the REST API Design lesson and confirms the JSON shape hasn't shifted.
Applied exercise
Prove the forgotten-return bug the callout warns about, then fix it
The callout claims a missing return after c.Error causes a real failure. Reproduce it deliberately in a disposable copy, then discard the change.
- In a throwaway local copy of the Get method, remove the return statement immediately following c.Error(err) in its not-found branch, leaving the c.JSON(http.StatusOK, ...) call reachable afterward.
- Request a non-existent organization ID and observe Gin's actual behavior (check both the response body and any server-side log/warning about superfluous response writes).
- Restore the return statement and confirm the same request now correctly returns a clean 404 with no warning.
Deliverable
A short note contrasting the broken behavior (with the warning or malformed response observed) against the correct behavior after restoring return.
Completion checks
- The note identifies a concrete symptom of the missing return (either a logged warning about multiple WriteHeader calls, or a response body that doesn't match a clean 404).
- The throwaway change is fully reverted afterward.
After this lesson's refactor, what happens if a handler method calls c.Error(err) but forgets to return immediately afterward?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.