Request Validation and Error Handling in Go Controllers: A Clean Architecture Guide

In clean architecture Go applications, request validation belongs in the controller layer to keep business logic pure, while structured error handling maps domain failures to appropriate HTTP status codes using custom error types.

The manakuro/golang-clean-architecture repository demonstrates how to implement request validation and error handling in Go controllers while maintaining strict separation between presentation and business logic. This guide shows you how to validate incoming HTTP requests before they reach your use-case layer and translate errors into consistent JSON responses.

Why Validation Belongs in the Controller Layer

Clean architecture divides applications into three distinct layers. The controller acts as an adapter that translates HTTP requests into domain-level calls, while the use-case layer contains pure business rules without HTTP-specific knowledge.

Performing request validation in the controller ensures:

  • Early failure: Malformed requests are rejected before consuming database or business resources
  • Separation of concerns: The use-case layer remains focused on domain invariants rather than input sanitization
  • HTTP-specific error mapping: Validation failures translate naturally to 400 Bad Request responses

The repository defines a minimal controller interface (controller.Context) that exposes Bind and JSON methods, allowing the controller to remain testable and framework-agnostic while still handling HTTP-specific concerns like validation.

Implementing Request Validation in golang-clean-architecture

The repository uses github.com/go-playground/validator/v10 for struct validation. Follow these steps to add comprehensive request validation to your controllers.

Step 1: Add Validation Tags to the Domain Model

First, annotate your domain models with validation tags in pkg/domain/model/user.go:

type User struct {
    ID   uint   `gorm:"primary_key" json:"id"`
    Name string `json:"name" validate:"required"`        // Required field
    Age  string `json:"age"  validate:"required,numeric"` // Required numeric string
}

Step 2: Extend the Controller with a Validator Instance

Modify pkg/adapter/controller/user.go to accept a validator in the constructor:

type userController struct {
    userUsecase usecase.User
    validator   *validator.Validate
}

func NewUserController(us usecase.User, v *validator.Validate) User {
    return &userController{us, v}
}

Step 3: Validate Before Calling the Use-Case

Inside the CreateUser method, validate the bound struct before delegating to the use-case:

func (uc *userController) CreateUser(ctx Context) error {
    var params model.User
    
    if err := ctx.Bind(&params); err != nil {
        return newAppError(http.StatusBadRequest, "invalid JSON payload", err)
    }
    
    if err := uc.validator.Struct(&params); err != nil {
        msgs := collectValidationErrors(err)
        return newAppError(http.StatusBadRequest, msgs, err)
    }
    
    u, err := uc.userUsecase.Create(&params)
    if err != nil {
        return newAppError(http.StatusInternalServerError, "failed to create user", err)
    }
    
    return ctx.JSON(http.StatusCreated, u)
}

Structured Error Handling for Go Controllers

Raw error propagation conflates binding failures, validation errors, and domain failures. Implement a custom error type to map these to appropriate HTTP responses.

Creating a Custom AppError Type

Add this to pkg/adapter/controller/app.go:

type AppError struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
    Err     error  `json:"-"`
}

func (e *AppError) Error() string { return e.Message }

func newAppError(code int, msg string, err error) *AppError {
    return &AppError{Code: code, Message: msg, Err: err}
}

func collectValidationErrors(err error) string {
    ve, ok := err.(validator.ValidationErrors)
    if !ok { return "validation failed" }
    msgs := make([]string, 0, len(ve))
    for _, fe := range ve {
        msgs = append(msgs, fmt.Sprintf("%s: %s", fe.Field(), fe.Tag()))
    }
    return strings.Join(msgs, ", ")
}

Mapping Domain Errors to HTTP Status Codes

Extend the error handling logic to recognize specific use-case errors:

if err != nil {
    if errors.Is(err, usecase.ErrUserAlreadyExists) {
        return newAppError(http.StatusConflict, "user already exists", err)
    }
    return newAppError(http.StatusInternalServerError, "internal server error", err)
}

Wiring Validation into the Application Bootstrap

Update cmd/app/main.go to initialise the validator and inject it into the controller:

func main() {
    e := echo.New()
    v := validator.New()
    
    userRepo := repository.NewUserRepository(db)
    dbRepo   := repository.NewDBRepository(db)
    us       := usecase.NewUserUsecase(userRepo, dbRepo)
    uc       := controller.NewUserController(us, v)
    
    appCtrl := controller.NewAppController(uc)
    router.NewRouter(e, appCtrl)
    
    e.Start(":8080")
}

Summary

  • Request validation belongs in the controller layer to maintain clean architecture boundaries and reject invalid input before it reaches business logic in pkg/usecase/usecase/user.go.
  • Use struct tags with github.com/go-playground/validator/v10 to declaratively define validation rules in your domain models at pkg/domain/model/user.go.
  • Create a custom AppError type in pkg/adapter/controller/app.go to map domain failures to appropriate HTTP status codes while keeping error details out of the use-case layer.
  • Wire dependencies in cmd/app/main.go by injecting the validator instance into controllers during application bootstrap.

Frequently Asked Questions

Why should validation happen in the controller instead of the use-case?

Validation is a presentation-layer concern that deals with HTTP-specific details like JSON binding and request format. Keeping it in the controller ensures the use-case layer remains pure business logic and framework-agnostic, allowing you to swap HTTP frameworks or reuse use-cases in CLI tools without modification.

How do I handle validation errors for nested structs or slices?

The validator package supports nested validation using the validate tag on nested structs and dive for slices. For complex scenarios, implement custom validators by registering them with validator.New().RegisterValidation(), then use the custom tag in your struct definitions to enforce domain-specific rules.

What is the best way to return multiple validation errors to the client?

Instead of returning a single concatenated string, modify the AppError struct to include a Details field of type map[string]string or []ValidationError. Update collectValidationErrors to return a structured slice where each entry contains the field name, tag, and value, enabling clients to display per-field error messages in their UI.

Can I reuse the same validation logic for both HTTP requests and database constraints?

While some rules overlap, HTTP validation focuses on format and presence while database constraints ensure referential integrity and uniqueness. Share common rules like regex patterns or numeric ranges by defining them as constants in your domain package, but keep the validation calls separate: use validator for HTTP input and database transactions for persistence constraints.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →