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

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.1

  • errors.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 generic

    error 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

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

The 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:

  1. Delivery Layer: Adapters that handle communication with the outside world (e.g., HTTP handlers, gRPC servers).

  2. Usecase Layer: Contains the application-specific business rules and orchestrates the data flow. Often called the Service layer.

  3. 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:

  1. It accepts a HandlerFuncWithError as its input.

  2. It returns a standard http.HandlerFunc, making it compatible with Go's standard HTTP routers.

  3. Inside the returned function, it calls the input handler: err := next(w, r).

  4. If err is nil, the handler has already successfully written a response, so the middleware does nothing.

  5. If err is not nil, the middleware begins its inspection process. It uses errors.As to check if the error is an *apperror.AppError.

    • If it is, the middleware extracts the Status, Code, and Message from the AppError 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 the apperror.ErrInternal template. This provides a critical fallback, ensuring that no error ever results in a malformed or empty response to the client.

  6. 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

internal/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

internal/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:

  1. 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.

  2. 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.

  3. 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