Error handling in Go is a feature, not a flaw. The explicitness forces you to think about failure modes at every callsite. But at scale, you need more than if err != nil { return err }.
Here’s what our error architecture looks like after three years of production evolution.
The domain error pattern
The core idea: errors carry both a code (for programmatic handling) and a message (for humans/logs). Never use raw errors.New in domain logic.
type Code string
const (
CodeNotFound Code = "NOT_FOUND"
CodeUnauthorized Code = "UNAUTHORIZED"
CodeInvalidInput Code = "INVALID_INPUT"
CodeConflict Code = "CONFLICT"
CodeInternal Code = "INTERNAL"
)
type DomainError struct {
Code Code
Message string
Op string // operation where error occurred
Err error // underlying cause
}
func (e *DomainError) Error() string {
if e.Op != "" {
return fmt.Sprintf("%s: %s", e.Op, e.Message)
}
return e.Message
}
func (e *DomainError) Unwrap() error { return e.Err }
func (e *DomainError) Is(target error) bool {
t, ok := target.(*DomainError)
if !ok {
return false
}
return e.Code == t.Code
}
Usage in domain logic:
func (s *UserService) GetByEmail(ctx context.Context, email string) (*User, error) {
user, err := s.repo.FindByEmail(ctx, email)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, &DomainError{
Code: CodeNotFound,
Message: "no user with that email",
Op: "UserService.GetByEmail",
Err: err,
}
}
return nil, &DomainError{
Code: CodeInternal,
Message: "database error",
Op: "UserService.GetByEmail",
Err: err,
}
}
return user, nil
}
Translating domain errors to HTTP
A single function converts domain errors to HTTP responses. The handler stays clean:
func HTTPStatusFromError(err error) int {
var de *DomainError
if !errors.As(err, &de) {
return http.StatusInternalServerError
}
switch de.Code {
case CodeNotFound:
return http.StatusNotFound
case CodeUnauthorized:
return http.StatusUnauthorized
case CodeInvalidInput:
return http.StatusBadRequest
case CodeConflict:
return http.StatusConflict
default:
return http.StatusInternalServerError
}
}
// In your handler:
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
user, err := h.svc.GetByEmail(r.Context(), r.URL.Query().Get("email"))
if err != nil {
status := HTTPStatusFromError(err)
h.respond(w, status, map[string]string{"error": err.Error()})
return
}
h.respond(w, http.StatusOK, user)
}
Structured logging with slog (Go 1.21+)
Stop logging raw error strings. Log structured fields so you can query them:
func (s *OrderService) Process(ctx context.Context, orderID string) error {
order, err := s.repo.Get(ctx, orderID)
if err != nil {
slog.ErrorContext(ctx, "failed to fetch order",
slog.String("order_id", orderID),
slog.String("op", "OrderService.Process"),
slog.Any("error", err),
)
return fmt.Errorf("OrderService.Process: %w", err)
}
// ...
return nil
}
This logs as JSON in production (slog.NewJSONHandler) and as human text locally (slog.NewTextHandler). Switch with an env var:
func newLogger(env string) *slog.Logger {
if env == "production" {
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
a.Value = slog.StringValue(a.Value.Time().UTC().Format(time.RFC3339))
}
return a
},
}))
}
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
}
Panic recovery as middleware
Services die from panics in unexpected places. A recovery middleware keeps them alive and gives you a stack trace:
func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
slog.Error("panic recovered",
slog.Any("panic", rec),
slog.String("stack", string(buf[:n])),
slog.String("url", r.URL.String()),
)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
The rule I follow
Wrap at every boundary crossing. When control crosses from one package to another, return a wrapped error with the operation name. This gives you a call chain in the error message without needing a stack trace.
// storage/user.go
func (r *UserRepo) FindByID(ctx context.Context, id string) (*User, error) {
// ...
if err != nil {
return nil, fmt.Errorf("storage.UserRepo.FindByID id=%s: %w", id, err)
}
}
// service/user.go
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
u, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("service.UserService.GetUser: %w", err)
}
return u, nil
}
The resulting error message reads like a call stack:
service.UserService.GetUser: storage.UserRepo.FindByID id=abc123: sql: no rows in result set
No surprises when something breaks at 3am.