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

> Master request validation and error handling in Go controllers for clean architecture. Keep business logic pure and map domain failures to HTTP status codes.

- Repository: [manato/golang-clean-architecture](https://github.com/manakuro/golang-clean-architecture)
- Tags: how-to-guide
- Published: 2026-03-06

---

**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`](https://github.com/manakuro/golang-clean-architecture/blob/main/pkg/domain/model/user.go):

```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`](https://github.com/manakuro/golang-clean-architecture/blob/main/pkg/adapter/controller/user.go) to accept a validator in the constructor:

```go
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:

```go
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`](https://github.com/manakuro/golang-clean-architecture/blob/main/pkg/adapter/controller/app.go):

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

```go
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`](https://github.com/manakuro/golang-clean-architecture/blob/main/cmd/app/main.go) to initialise the validator and inject it into the controller:

```go
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`](https://github.com/manakuro/golang-clean-architecture/blob/main/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`](https://github.com/manakuro/golang-clean-architecture/blob/main/pkg/domain/model/user.go).
- **Create a custom AppError type** in [`pkg/adapter/controller/app.go`](https://github.com/manakuro/golang-clean-architecture/blob/main/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`](https://github.com/manakuro/golang-clean-architecture/blob/main/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.