Go Series: Custom App Error
Introduction
In modern software engineering, particularly in the context of building robust and scalable APIs, error handling transcends being a mere feature; it stands as a foundational architectural pillar. A mature error handling strategy directly dictates a system's observability, the stability of client integrations, developer productivity, and overall resilience. An API that communicates failures clearly and consistently is one that developers can trust and build upon effectively.
This report addresses the need for a sophisticated error handling system within a Go application, specifically one that adheres to the principles of Clean Architecture. The objective is to produce a system that generates structured, multi-faceted error responses as requested: a standard HTTP status code for transport-level communication, a unique machine-readable error code for programmatic handling by clients, and a clear, human-readable message for debugging and logging. This three-part structure is recognized as a gold standard for modern, developer-friendly APIs.
The following sections will construct this system from the ground up. The analysis begins with the fundamental philosophy of Go's error handling mechanisms, establishing the "first principles" that guide idiomatic code. It then proceeds to design a bespoke, application-aware error framework tailored to the required response structure. Subsequently, this framework will be integrated into a layered Clean Architecture, demonstrating how to manage error propagation without violating architectural boundaries. The report culminates in a complete, production-grade code implementation, providing a tangible and reusable solution.
Part 1: The Philosophy and Mechanics of Go Error Handling
Before constructing an advanced error handling framework, it is essential to understand the core principles and tools that the Go language provides. Go's approach is unique and deliberate, and these foundational concepts inform every subsequent architectural decision.
1.1 Errors as First-Class Values: The Go Paradigm
Unlike many languages that use exceptions to manage failures, which introduces an invisible, alternative control flow, Go treats errors as first-class values.1 This is the most critical concept in Go's error philosophy. Functions that can fail typically return an error as their final return value, leading to the ubiquitous
(result, error)
function signature.3
This design choice has profound implications. It forces the developer to explicitly confront the possibility of failure at the point where a function is called.2 The common
if err!= nil
block is a direct consequence of this philosophy. While sometimes criticized for its verbosity, this pattern makes the control flow exceptionally clear and predictable; there are no hidden jumps that can interrupt the program's execution.5 Because errors are just values, they can be stored in variables, passed to other functions, and inspected, providing immense flexibility. However, this same flexibility and the need for explicit checks create the boilerplate that more advanced architectural patterns, such as centralized middleware, aim to manage and abstract away.
1.2 The Standard Library Toolkit: errors
and fmt
errors
and fmt
The Go standard library provides a powerful, albeit minimal, toolkit for working with these error values. Mastery of these tools is a prerequisite for building any robust error handling system.
Creating and Wrapping Errors
The simplest way to create an error is with the errors.New
function, which takes a static string, or fmt.Errorf
, which allows for formatted error messages.6
A pivotal advancement in Go 1.13 was the introduction of error wrapping via the %w
format verb in fmt.Errorf
.1 This is a non-negotiable best practice in modern Go development. When an error from a lower-level function is wrapped, it creates a chain of errors. This chain preserves the full narrative of what went wrong, from a low-level database driver error up through the business logic layers.6 Simply formatting the error's text using
%v
would destroy this chain, losing the underlying error's type and making programmatic inspection impossible.
Inspecting Error Chains: errors.Is
vs. errors.As
To work with these error chains, the standard library provides two primary inspection functions: errors.Is
and errors.As
.
errors.Is
: This function is used to determine if any error in the chain matches a specific, predefined error value, often called a sentinel error (e.g.,io.EOF
,sql.ErrNoRows
). It checks for error identity.1errors.As
: This function checks if any error in the chain is of a specific type. If a match is found, it assigns the error value to a variable of that type, allowing the caller to access its fields and methods.1 This mechanism is the key that enables our custom error framework, as it allows us to extract rich, structured error information from a genericerror
interface value.
The evolution of these standard library tools is telling. Early versions of Go provided only basic tools like errors.New
, which proved insufficient for complex applications where the context and type of an error are critical. The community responded with third-party packages like pkg/errors
that introduced stack traces and wrapping. The official integration of wrapping (%w
) and inspection (Is
/As
) in Go 1.13 was a formal acknowledgment of this need. It established a clear best practice: an error is not a single piece of data but a linked list of events that tells a story. The custom error framework detailed in this report is the logical next step in this evolution, creating a strongly-typed, application-specific node for that linked list.
1.3 Strategic Use of panic
and recover
panic
and recover
Go includes panic
and recover
mechanisms, but their use is strictly reserved for truly exceptional situations.8 The idiomatic rule is that
panic
should only be used for unrecoverable, programmer-error states—conditions that should be impossible if the code is correct.1 A canonical example is a failure to compile a regular expression from a hard-coded, developer-provided pattern; if this fails, it indicates a bug in the code itself, and the program cannot safely continue.11
Using panic
for expected, handleable failures, such as invalid user input or a network timeout, is an anti-pattern in Go.12 It subverts the explicit
error
return mechanism, makes it impossible to add context via wrapping, and hides potential failure paths from a function's signature, making the code harder to reason about.11 The
recover
function, used within a defer
statement, serves primarily as a last-resort mechanism to allow a goroutine to log a fatal error or perform cleanup before terminating, preventing a single failure from crashing the entire application.1
Part 2: Designing a Custom, Application-Aware Error Framework
With a firm grasp of Go's error handling fundamentals, the next step is to design a framework that can carry the specific, structured information our API requires. Standard errors are insufficient for this task, necessitating the creation of a custom error type.
2.1 The Case for Custom Error Types
The built-in error
type is an interface with a single method: Error() string
. While errors.New
and fmt.Errorf
produce values that satisfy this interface, they can only hold a string message. They lack the structure to carry the multi-faceted data our API contract demands: an HTTP status code, a machine-readable error code, and the underlying error for logging.6
The solution is to define a custom struct that implements the error
interface. This approach provides the flexibility to attach any metadata needed for our application while ensuring the custom type can be used anywhere a standard Go error
is expected.1
2.2 Defining the AppError
: A Rich, Structured Error Type
AppError
: A Rich, Structured Error TypeThe core of the framework is the AppError
struct. This type will encapsulate all the necessary information for handling errors consistently across the application.
Go
// Package apperror provides the custom error types for the application.
package apperror
import (
"fmt"
"net/http"
)
// ErrorCode is a machine-readable code that uniquely identifies an error.
type ErrorCode string
// AppError is a custom error type that includes application-specific information.
type AppError struct {
Code ErrorCode // Machine-readable error code
Message string // Human-readable message for developers/logs
Status int // HTTP status code to be returned to the client
Err error // The underlying wrapped error, for context
}
// Error satisfies the standard error interface.
func (e *AppError) Error() string {
return fmt.Sprintf("code: %s, message: %s", e.Code, e.Message)
}
// Unwrap provides compatibility with errors.Is and errors.As,
// allowing inspection of the underlying error chain.
func (e *AppError) Unwrap() error {
return e.Err
}
This definition includes two critical methods. The Error()
method ensures that *AppError
satisfies the error
interface. The Unwrap()
method is equally important; it makes our custom type a fully-participating citizen in Go's error handling ecosystem by allowing functions like errors.Is
and errors.As
to inspect the chain of wrapped errors.10
2.3 Establishing a Centralized Error Catalog
To ensure consistency and prevent developers from inventing error codes and messages on the fly, all known application errors should be defined in a central catalog.7 This catalog will consist of exported variables that serve as templates for our known errors.
Go
// in package apperror
var (
// ErrInternal represents a generic 500 internal server error.
ErrInternal = &AppError{Code: "GEN-500", Message: "An internal server error occurred", Status: http.StatusInternalServerError}
// ErrNotFound represents a 404 not found error.
ErrNotFound = &AppError{Code: "GEN-404", Message: "The requested resource was not found", Status: http.StatusNotFound}
// ErrForbidden represents a 403 forbidden error.
ErrForbidden = &AppError{Code: "GEN-403", Message: "You are not authorized to perform this action", Status: http.StatusForbidden}
// ErrUnauthorized represents a 401 unauthorized error.
ErrUnauthorized = &AppError{Code: "GEN-401", Message: "Authentication is required and has failed or has not yet been provided", Status: http.StatusUnauthorized}
// ErrValidation represents a 422 unprocessable entity error for validation failures.
ErrValidation = &AppError{Code: "GEN-422", Message: "The request could not be processed due to validation errors", Status: http.StatusUnprocessableEntity}
// ErrConflict represents a 409 conflict error.
ErrConflict = &AppError{Code: "GEN-409", Message: "A conflict occurred with the current state of the resource", Status: http.StatusConflict}
// ErrServiceUnavailable represents a 503 service unavailable error.
ErrServiceUnavailable = &AppError{Code: "GEN-503", Message: "The service is temporarily unavailable", Status: http.StatusServiceUnavailable}
// Example of a specific business error from the user query
ErrInvalidSortBy = &AppError{Code: "ANL-PRD-TRD-01", Message: "Invalid enum value for SORT_BY", Status: http.StatusUnprocessableEntity}
)
2.4 Error Constructor Functions for Dynamic Context
The error constants defined above are excellent for static cases, but often an error needs to include dynamic information (e.g., the ID of a resource that was not found) or wrap an underlying error from a lower layer. To facilitate this, constructor-like methods can be added to the AppError
type.
Go
// in package apperror
// Wrap adds an underlying error and a new message to an AppError.
// It creates a copy of the AppError to avoid modifying the original template.
func (e *AppError) Wrap(err error, messages...string) *AppError {
newErr := *e
newErr.Err = err
if len(messages) > 0 {
newErr.Message = messages
}
return &newErr
}
// WithMessage returns a copy of the AppError with a new message.
func (e *AppError) WithMessage(message string) *AppError {
newErr := *e
newErr.Message = message
return &newErr
}
These methods allow for clean and expressive error creation at the point of failure. For instance, a repository can return a detailed error like return apperror.ErrNotFound.Wrap(err, "user with id 123 not found")
. This preserves the original error err
for logging while providing a clear, contextual message for the API response.
Table: Foundational Error Catalog
The following table summarizes the base error types defined in the apperror
package. This catalog serves as a clear, discoverable contract for the errors the application can produce, promoting consistency across a development team.
Error Category
apperror
Constant
Default ErrorCode
Default HTTP Status
Generic Not Found
ErrNotFound
GEN-404
404 Not Found
Generic Unauthorized
ErrUnauthorized
GEN-401
401 Unauthorized
Generic Forbidden
ErrForbidden
GEN-403
403 Forbidden
Generic Validation
ErrValidation
GEN-422
422 Unprocessable Entity
Generic Conflict
ErrConflict
GEN-409
409 Conflict
Generic Internal Error
ErrInternal
GEN-500
500 Internal Server Error
Service Unavailable
ErrServiceUnavailable
GEN-503
503 Service Unavailable
Part 3: Integrating the Error Framework into a Clean Architecture
Having a well-designed error framework is only half the battle. Integrating it into a layered architecture without creating tight coupling or violating architectural boundaries is paramount. Clean Architecture provides the principles to achieve this separation of concerns.
3.1 A Pragmatic View of Clean Architecture in Go
For this report, a pragmatic three-layer architecture will be used:
Delivery Layer: Adapters that handle communication with the outside world (e.g., HTTP handlers, gRPC servers).
Usecase Layer: Contains the application-specific business rules and orchestrates the data flow. Often called the Service layer.
Repository Layer: Manages data persistence and retrieval, abstracting the underlying data source.
The fundamental principle is the Dependency Rule: source code dependencies can only point inwards. The Delivery layer depends on the Usecase layer, which depends on the Repository layer's interface. Inner layers must have no knowledge of outer layers.14
The apperror
package fits into this model as a core, cross-cutting concern. It defines fundamental application-level error contracts. As such, it resides at the center of the architecture, alongside domain entities. All other layers can depend on the apperror
package without violating the dependency rule. This architectural decision cleanly resolves the common debate over where to define error types.14
3.2 Error Handling Across Layers: A Chain of Responsibility
Each layer has a distinct responsibility in the error handling chain. This clear division of labor is essential for maintaining architectural integrity.
Repository Layer
The repository's sole error-handling duty is to translate low-level, driver-specific errors into application-aware errors. It must never leak implementation details, like a pgx.ErrNoRows
or mongo.ErrNoDocuments
, to the usecase layer. This abstraction allows the underlying database technology to be swapped without affecting business logic.
Go
// in repository layer
package postgres
import (
"database/sql"
"errors"
"example.com/internal/apperror"
)
// A repository-specific sentinel error can be useful for callers within the same layer.
var ErrRecordNotFound = errors.New("record not found")
func (r *UserRepo) GetByID(...) (*User, error) {
//... database query logic...
err := r.db.QueryRowContext(...).Scan(...)
if err!= nil {
if errors.Is(err, sql.ErrNoRows) {
// Translate the driver error into our application error.
// We wrap the repository's sentinel error for potential internal checks,
// but the core information is in the AppError.
return nil, apperror.ErrNotFound.Wrap(ErrRecordNotFound)
}
// For all other unknown database errors, return a generic internal error.
return nil, apperror.ErrInternal.Wrap(err, "database query failed")
}
return user, nil
}
Usecase (Service) Layer
This layer orchestrates repositories to enforce business rules. It is the primary source of business-level errors (e.g., "email already exists," "insufficient funds"). It receives errors from the repository and either propagates them upwards or translates a technical failure into a more specific business context error.
Go
// in usecase layer
package user
import (
"errors"
"example.com/internal/apperror"
"example.com/internal/platform/postgres" // Depending on the repository's error
)
func (uc *CreateUserUsecase) Execute(...) (*User, error) {
// Business rule: check if user with this email already exists
_, err := uc.userRepo.GetByEmail(email)
if err!= nil {
// We only care if the error is NOT a 'not found' error.
// If it's something else (e.g., DB connection issue), it's an internal error.
if!errors.Is(err, postgres.ErrRecordNotFound) {
return nil, apperror.ErrInternal.Wrap(err, "failed to check for existing user")
}
} else {
// If err was nil, a user was found. This is a business rule violation.
return nil, apperror.ErrConflict.WithMessage("a user with this email already exists")
}
//... continue with user creation logic...
return newUser, nil
}
Handler (Delivery) Layer
The handler's role is to be a simple adapter. It decodes the incoming request, calls the appropriate usecase, and handles the success case by encoding a response. Crucially, if the usecase returns an error, the handler's only job is to return that error without inspection. The translation of the error into an HTTP response is not its responsibility; that task is delegated to a centralized middleware.5 This keeps handlers lean, focused on their primary task, and easy to test.
Go
// in handler layer
package user
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) error {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err!= nil {
// Handle decoding/validation failure immediately.
return apperror.ErrValidation.Wrap(err, "invalid request body")
}
// Call the usecase
user, err := h.createUserUsecase.Execute(r.Context(), req.Email, req.Password)
if err!= nil {
// *** Simply return the error. Do not inspect it or write a response. ***
return err
}
// Handle the success case
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
return nil
}
Table: Error Handling Responsibilities per Architectural Layer
This table provides a prescriptive guide for developers, defining the error handling contract for each architectural layer. Adhering to these responsibilities prevents tight coupling and ensures a clean separation of concerns.
Layer
Primary Responsibility
Input Error Types
Output Error Types
Anti-Patterns
Repository
Translate driver/external errors into application errors.
sql.Error
, redis.Error
, etc.
*apperror.AppError
(e.g., ErrNotFound
, ErrInternal
).
Leaking driver-specific errors. Returning HTTP status codes.
Usecase
Enforce business rules. Map technical failures to business outcomes.
*apperror.AppError
from repositories.
Specific *apperror.AppError
types (ErrConflict
, ErrForbidden
, etc.).
Having knowledge of HTTP/gRPC. Handling request decoding.
Handler
Decode requests, call usecases, encode success responses.
error
(which is an *apperror.AppError
) from usecase.
error
(propagated directly from usecase).
Inspecting errors. Writing error responses. Logging errors.
Middleware
Centralized error handling. Convert any error
into a final response.
error
from the handler chain.
Final HTTP/gRPC response sent to the client.
Containing any business logic. Calling repositories directly.
Part 4: Centralized Response Generation via HTTP Middleware
The final piece of the architecture is the centralized component that intercepts errors returned from the handlers and translates them into the final API response. This is achieved through a dedicated HTTP middleware.
4.1 The Error-Returning Handler Pattern
As demonstrated in the previous section, handlers become significantly cleaner when they are relieved of error response duties. To enable this, a custom handler signature is defined that allows a handler to return an error
.5
Go
// A custom handler type that returns an error, allowing for centralized handling.
type HandlerFuncWithError func(w http.ResponseWriter, r *http.Request) error
This simple type definition is the cornerstone of the entire pattern. It changes the contract of an HTTP handler from a function that must handle all outcomes to one that can delegate failure cases.
4.2 Implementing the Master Error-Handling Middleware
The error-handling middleware is a function that wraps our custom HandlerFuncWithError
. It executes the handler, inspects the returned error, and takes full responsibility for crafting and writing the final HTTP response.17
The logic of this middleware is as follows:
It accepts a
HandlerFuncWithError
as its input.It returns a standard
http.HandlerFunc
, making it compatible with Go's standard HTTP routers.Inside the returned function, it calls the input handler:
err := next(w, r)
.If
err
isnil
, the handler has already successfully written a response, so the middleware does nothing.If
err
is notnil
, the middleware begins its inspection process. It useserrors.As
to check if the error is an*apperror.AppError
.If it is, the middleware extracts the
Status
,Code
, andMessage
from theAppError
to construct the JSON error response and sets the HTTP status header accordingly.If it is not an
AppError
, it signifies an unexpected or improperly wrapped error. In this case, the middleware defaults to a generic 500 Internal Server Error, using theapperror.ErrInternal
template. This provides a critical fallback, ensuring that no error ever results in a malformed or empty response to the client.
Finally, the middleware logs the complete error details, including the full wrapped error chain (
err
), to a structured logger for observability and debugging.
This middleware pattern establishes a powerful "application boundary." Inside this boundary (handlers, usecases, repositories), the application works with rich, structured apperror
values. The middleware acts as the gatekeeper at this boundary, translating these internal error types into a specific external contract—in this case, a JSON HTTP response. This decoupling is a primary goal of Clean Architecture. If the application later needs to expose a gRPC endpoint, a new gRPC interceptor can be written to perform a similar translation from apperror
to a gRPC status error, without changing a single line of business logic.19
4.3 Applying the Middleware to the Application Router
The middleware adapter and the error handler itself can be applied to any Go router that works with the standard http.Handler
interface, such as http.ServeMux
, chi
, or gorilla/mux
.
Go
// Adapter function to make HandlerFuncWithError compatible with standard http.Handler.
func (h HandlerFuncWithError) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// The error handler middleware logic goes here.
err := h(w, r)
if err!= nil {
// Log the error
log.Printf("An error occurred: %+v\n", err)
var appErr *apperror.AppError
if errors.As(err, &appErr) {
// It's a known application error.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(appErr.Status)
json.NewEncoder(w).Encode(map[string]any{
"errorCode": appErr.Code,
"errorMessage": appErr.Message,
})
return
}
// It's an unknown error, default to a 500.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]any{
"errorCode": apperror.ErrInternal.Code,
"errorMessage": apperror.ErrInternal.Message,
})
}
}
This adapter can then be used when defining routes, ensuring all errors are handled centrally and consistently.
Part 5: A Complete, End-to-End Implementation Walkthrough
This final section provides the complete, runnable source code for a sample application, demonstrating how all the preceding concepts and patterns fit together.
5.1 Project Directory Structure
A well-organized directory structure reinforces the architectural separation.
/go-error-handling-example
├── go.mod
├── go.sum
├── cmd/server/
│ └── main.go
└── internal/
├── apperror/
│ └── error.go
├── user/
│ ├── handler.go
│ ├── repository.go
│ ├── service.go
│ └── domain.go
└── platform/
└── http/
└── middleware.go
5.2 Full Code Listing: The apperror
Package
apperror
Packageinternal/apperror/error.go
:
Go
package apperror
import (
"fmt"
"net/http"
)
type ErrorCode string
type AppError struct {
Code ErrorCode
Message string
Status int
Err error
}
func (e *AppError) Error() string {
if e.Err!= nil {
return fmt.Sprintf("code: %s, message: %s, underlying_error: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("code: %s, message: %s", e.Code, e.Message)
}
func (e *AppError) Unwrap() error {
return e.Err
}
func (e *AppError) Wrap(err error, messages...string) *AppError {
newErr := *e
newErr.Err = err
if len(messages) > 0 {
newErr.Message = messages
}
return &newErr
}
func (e *AppError) WithMessage(message string) *AppError {
newErr := *e
newErr.Message = message
return &newErr
}
var (
ErrInternal = &AppError{Code: "GEN-500", Message: "An internal server error occurred", Status: http.StatusInternalServerError}
ErrNotFound = &AppError{Code: "GEN-404", Message: "The requested resource was not found", Status: http.StatusNotFound}
ErrForbidden = &AppError{Code: "GEN-403", Message: "You are not authorized to perform this action", Status: http.StatusForbidden}
ErrValidation = &AppError{Code: "GEN-422", Message: "The request could not be processed due to validation errors", Status: http.StatusUnprocessableEntity}
ErrConflict = &AppError{Code: "GEN-409", Message: "A conflict occurred with the current state of the resource", Status: http.StatusConflict}
)
5.3 Full Code Listing: A Sample Feature ("Create User")
internal/user/domain.go
:
Go
package user
import "sync"
// User represents a user in the system.
type User struct {
ID int
Email string
}
// In-memory store for demonstration purposes.
var (
users = make(map[string]User)
nextID = 1
mu sync.Mutex
)
internal/user/repository.go
:
Go
package user
import (
"errors"
"example.com/internal/apperror"
)
var ErrUserNotFound = errors.New("user not found")
type Repository interface {
FindByEmail(email string) (User, error)
Create(email string) (User, error)
}
type inMemoryRepository struct{}
func NewRepository() Repository {
return &inMemoryRepository{}
}
func (r *inMemoryRepository) FindByEmail(email string) (User, error) {
mu.Lock()
defer mu.Unlock()
if user, ok := users[email]; ok {
return user, nil
}
return User{}, apperror.ErrNotFound.Wrap(ErrUserNotFound)
}
func (r *inMemoryRepository) Create(email string) (User, error) {
mu.Lock()
defer mu.Unlock()
newUser := User{ID: nextID, Email: email}
users[email] = newUser
nextID++
return newUser, nil
}
internal/user/service.go
:
Go
package user
import (
"errors"
"example.com/internal/apperror"
)
type Service interface {
CreateUser(email string) (User, error)
}
type service struct {
repo Repository
}
func NewService(repo Repository) Service {
return &service{repo: repo}
}
func (s *service) CreateUser(email string) (User, error) {
_, err := s.repo.FindByEmail(email)
if err!= nil {
if!errors.Is(err, ErrUserNotFound) {
return User{}, apperror.ErrInternal.Wrap(err, "failed to check for existing user")
}
} else {
return User{}, apperror.ErrConflict.WithMessage("a user with this email already exists")
}
return s.repo.Create(email)
}
internal/user/handler.go
:
Go
package user
import (
"encoding/json"
"example.com/internal/apperror"
"example.com/internal/platform/http"
"net/http"
)
type Handler struct {
service Service
}
func NewHandler(service Service) *Handler {
return &Handler{service: service}
}
type createUserRequest struct {
Email string `json:"email"`
}
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) error {
var req createUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err!= nil {
return apperror.ErrValidation.Wrap(err, "invalid request body")
}
if req.Email == "" {
return apperror.ErrValidation.WithMessage("email is required")
}
user, err := h.service.CreateUser(req.Email)
if err!= nil {
return err
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
return nil
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.Handle("/users", platform.HandlerFuncWithError(h.CreateUser))
}
5.4 Full Code Listing: main.go
with Router and Middleware Setup
main.go
with Router and Middleware Setupinternal/platform/http/middleware.go
:
Go
package platform
import (
"encoding/json"
"errors"
"example.com/internal/apperror"
"log"
"net/http"
)
type HandlerFuncWithError func(w http.ResponseWriter, r *http.Request) error
func (h HandlerFuncWithError) ServeHTTP(w http.ResponseWriter, r *http.Request) {
err := h(w, r)
if err == nil {
return
}
log.Printf("Error occurred: %+v", err)
var appErr *apperror.AppError
if errors.As(err, &appErr) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(appErr.Status)
json.NewEncoder(w).Encode(map[string]any{
"errorCode": appErr.Code,
"errorMessage": appErr.Message,
})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]any{
"errorCode": apperror.ErrInternal.Code,
"errorMessage": apperror.ErrInternal.Message,
})
}
cmd/server/main.go
:
Go
package main
import (
"example.com/internal/user"
"log"
"net/http"
)
func main() {
userRepo := user.NewRepository()
userService := user.NewService(userRepo)
userHandler := user.NewHandler(userService)
mux := http.NewServeMux()
userHandler.RegisterRoutes(mux)
log.Println("Server starting on :8080")
if err := http.ListenAndServe(":8080", mux); err!= nil {
log.Fatalf("Could not start server: %s\n", err)
}
}
5.5 Verification: Example API Calls and Error Responses
With the server running, the following curl
commands demonstrate the system's behavior.
Success Case:
Bash
curl -i -X POST -H "Content-Type: application/json" -d '{"email":"test@example.com"}' http://localhost:8080/users
Expected Output:
HTTP/1.1 201 Created
Content-Type: application/json
...
{"ID":1,"Email":"test@example.com"}
Business Logic Error (Conflict):
Bash
# Run the same command again
curl -i -X POST -H "Content-Type: application/json" -d '{"email":"test@example.com"}' http://localhost:8080/users
Expected Output:
HTTP/1.1 409 Conflict
Content-Type: application/json
...
{"errorCode":"GEN-409","errorMessage":"a user with this email already exists"}
Validation Error:
Bash
curl -i -X POST -H "Content-Type: application/json" -d '{"email":""}' http://localhost:8080/users
Expected Output:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
...
{"errorCode":"GEN-422","errorMessage":"email is required"}
This end-to-end example confirms that the architecture successfully meets all requirements, providing structured, consistent, and meaningful error responses for various failure scenarios.
Conclusion
A robust error handling system is not an incidental feature but the product of deliberate architectural design. This report has demonstrated that by combining Go's idiomatic error-as-value philosophy with a layered Clean Architecture, it is possible to build a system that is maintainable, highly observable, and exceptionally stable.
The key principles established are:
Centralize Error Definitions: A custom
AppError
type, coupled with a catalog of predefined error templates, provides a single source of truth and enforces consistency across the application.Strictly Separate Concerns: Each architectural layer has a clear and distinct responsibility in the error handling chain. Repositories translate driver errors, usecases handle business logic failures, and handlers remain simple adapters.
Delegate Response Generation: The use of an error-returning handler pattern and a master error-handling middleware cleanly decouples business logic from the transport layer. This centralized middleware becomes the sole authority for converting internal application errors into protocol-specific responses.
The resulting architecture not only satisfies the immediate need for structured API error responses but also yields significant long-term benefits. It enhances developer productivity by providing clear patterns to follow, improves system observability through structured logging of rich error data, and increases resilience by ensuring all failure paths are handled gracefully and predictably. Ultimately, this approach elevates error handling from a tactical necessity to a strategic asset of the software system.
Last updated