Application errors in Go: turning noise into a feature
I’ve been writing Go professionally for a while, and like most developers, I started with the usual way of handling errors: return error
, maybe wrap it with fmt.Errorf
, and hope for the best.
At first, it was fine. The code worked, tests passed, and errors showed up somewhere in the logs. But as projects got bigger—and especially when domain logic became more complex—I started to feel the cracks. Error messages were inconsistent, impossible to trace, and—most of all—utterly useless to both users and developers.
A few years ago, I came across a series of articles by gobeyond.dev that profoundly influenced how I think about building clean, maintainable Go projects — not just how to handle errors, but how to structure your entire web application in a pragmatic, scalable way. If you want to adopt a mindset where errors are first-class citizens and your project architecture stays sane, I highly recommend reading their work. It offers a solid philosophy that changed the way I approach both failure handling and overall code organization.
This article isn’t about those pieces specifically (I’ll write something separate on that), but they sparked the change in how I handle errors across all my Go projects.
What I used to do (and what went wrong)#
Here’s what my error handling used to look like, in practice:
return fmt.Errorf("could not create user: %w", err)
Or worse:
return errors.New("something went wrong")
Sometimes I’d add context, sometimes not. Sometimes I’d wrap errors two or three times, hoping that one of them had the information I needed. It was chaotic.
The main issues:
-
❌ All errors looked the same I couldn’t tell if an error was from the domain, the database, or just a bad request.
-
❌ No structured metadata No way to attach a code, category, or extra context—everything was just a message string.
-
❌ No traceability When I got a bug report, I couldn’t reproduce it without searching logs or guessing.
-
❌ Messages were a mess Some were meant for users, some for logs, some were inside panics.
-
❌ Logging was noisy and unhelpful I couldn’t group errors, correlate them with requests, or alert meaningfully in production.
At some point, I realized I wasn’t handling errors—I was just returning strings and hoping for the best.
What I do now (and why it works)#
At some point, I decided to treat errors as first-class values—just like any other part of my domain logic. I wanted them to be:
- predictable
- structured
- traceable
- and actually useful in production.
So I introduced a custom Error
type. Nothing fancy, no framework, no third-party library. Just a struct that encapsulates what I care about when something goes wrong:
type Error struct {
UniqueID uuid.UUID
Code string
Format string
Args []any
OriginFile string
OriginFn string
wrapped error
}
Here’s why each field exists:
-
Code
is a machine-readable identifier:not_found
,unauthorized
,internal
, etc. It tells the system (and the developer) what kind of error happened. -
Format
+Args
allow for flexible, deferred formatting. Useful for localization, clean logs, and consistent messages. -
OriginFile
andOriginFn
help trace exactly where the error was created. No more grep-ing through logs guessing where “something went wrong”. -
wrapped
holds the underlying error, if any. Wrapping doesn’t destroy context—it enriches it. -
UniqueID
is a UUID generated per error instance. When something breaks in production, the user can send me the ID and I can find everything I need in the logs or monitoring system.
This setup means that errors travel through my system like proper values—not strings. They carry structure, origin, and meaning, and they behave consistently across layers.
Errors are part of the domain, not exceptions#
At some point I stopped thinking of errors as “something went wrong.” In most cases, nothing went wrong—the application simply encountered a known, valid outcome: the user doesn’t exist, the request is invalid, the resource is locked, the user is not allowed.
These are not exceptions. They’re part of the domain logic. And like any domain behavior, they deserve to be structured, predictable, and easy to reason about.
So instead of treating them as accidents, I model them as values:
return Errorf(ENOTFOUND, "user not found")
or:
return WrapErrorf(err, EINVALID, "invalid request payload")
I only wrap errors when it adds value. If it’s already a well-formed domain error, I return it directly. But when errors come from the outside—like from a DB, a file system, or an HTTP call—I always wrap them.
For example, a database error might be handled like this:
return core.WrapErrorf(err, core.EINTERNAL, "error fetching data from users table: %v", err)
This does three things:
- Preserves the original error — I don’t lose the underlying issue.
- Adds domain-level meaning — I declare what kind of failure this is (
EINTERNAL
,EUNAVAILABLE
, etc.). - Provides structured logs — The message is consistent and traceable.
By treating errors this way, I make them part of the normal flow of the application—not something exceptional. No panics or unexpected crashes exposed to users; just meaningful, structured results from a system that understands its own failure modes.
From application errors to user responses#
Handling errors doesn’t stop at creating structured error types. It’s equally important to translate those errors into clear, consistent responses for the users of your API.
In my setup, each application error comes with a code that maps internally to an HTTP status code. This mapping happens centrally, so the system always knows which HTTP status to return for each error type. For example:
var codes = map[string]int{
core.ECONFLICT: http.StatusConflict,
core.EFORBIDDEN: http.StatusForbidden,
core.EINVALID: http.StatusBadRequest,
core.ENOTFOUND: http.StatusNotFound,
core.ENOTIMPLEMENTED: http.StatusNotImplemented,
core.EUNAUTHORIZED: http.StatusUnauthorized,
core.EINTERNAL: http.StatusInternalServerError,
core.EUNAVAILABLE: http.StatusServiceUnavailable,
core.EREQUESTLIMIT: http.StatusTooManyRequests,
core.EDEVICENOTOWNED: http.StatusForbidden,
}
Messages returned to users are carefully selected:
- Internal or unknown errors return a generic message like
"An error occurred. Please try again later."
to avoid leaking sensitive details. - Application errors provide clear messages translated or contextualized for the end user.
When an error reaches the HTTP handler, it is wrapped into a JSON response containing:
- the appropriate HTTP status code,
- a machine-readable error code,
- a user-friendly message,
- and optionally some details.
Here’s a simplified example of how an error is transformed into a JSON response:
func (s *Server) ErrorResponseJSON(c echo.Context, err error, details ...interface{}) error {
if trace := core.TraceFromContext(c.Request().Context()); trace != nil {
trace.SetError(err)
}
return c.JSON(codes[core.ErrorCode(err)], NewErrorAPI(c.Request().Context(), err, details))
}
This approach keeps error handling consistent and user-friendly:
- Clients receive structured JSON with a predictable format.
- Logs and monitoring tools get enriched context for faster debugging.
By centralizing this translation, I avoid scattering HTTP logic across handlers and keep error responses maintainable and clear.
Logging application errors#
Errors happen. What matters is what you do with them.
Every incoming HTTP request passes through a middleware that:
- Creates a
trace
object capturing request metadata and start time. - Injects this
trace
into the request context, so downstream code can access it. - Calls the next handler in the chain.
- After the handler returns, checks if an error was recorded in the trace.
- Logs either the error (with full context) or a normal request info message.
Here’s a simplified example of how this looks:
func (s *Server) RequestTracingMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
trace := core.NewTrace()
trace.HTTP.StartTime = time.Now().UTC()
c.SetRequest(
c.Request().WithContext(core.NewContextWithTrace(c.Request().Context(), trace)),
)
err := next(c)
if err != nil {
trace.SetError(err)
}
trace.HTTP.ExecTime = time.Since(trace.HTTP.StartTime)
if trace.Error != nil {
s.Logger.Err(c.Request().Context(), trace.Error)
} else {
s.Logger.Info(c.Request().Context(), fmt.Sprintf("%s %s", trace.HTTP.Method, trace.HTTP.URL))
}
return err
}
}
Thanks to this design:
- Logging is centralized — no scattered log calls.
- Errors carry full context — unique IDs, origin, request details.
- You get a single log per request, error or success.
- Tracing and monitoring integration is seamless.
This keeps your logs clean, consistent, and easy to debug. And that’s a feature you want in production.
Benefits I didn’t expect#
I initially introduced this error structure to bring consistency and structure to my application. But over time, it started solving problems I didn’t even know I had.
Here are some real-world improvements I saw:
-
✅ Production debugging became faster When a user reports an issue, they send me the
UniqueID
. I can immediately trace the full error in the logs or monitoring system—no guesswork, no “can you try again and screenshot it?” -
✅ Logging finally made sense Errors are logged with their code, origin, and message format. I can group by
Code
, filter by file, or search byUniqueID
. It’s not just a pile of stack traces anymore. -
✅ Alerting became smarter I use tools like Sentry, and instead of flooding it with random panic messages, I only capture structured domain errors with context. No noise, just signal.
-
✅ Handlers became cleaner They don’t need to guess what happened—they inspect the
Error
, and decide how to respond (e.g. return 400, 404, 500). -
✅ Everything feels more intentional Errors aren’t just “what blew up.” They’re part of the API, part of the system contract. And they get treated as such.
Conclusion#
Error handling doesn’t need to be a mess. It doesn’t need to be bloated, or wrapped in magic, or buried under abstractions.
It just needs to be intentional.
Think about your errors the way you think about your data: structure them, name them, track them, treat them with respect. They’re not the enemy. They’re just part of the system.
Stay tuned folks.
This article was written and reviewed in collaboration with AI assistance.