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(¶ms); err != nil {
return newAppError(http.StatusBadRequest, "invalid JSON payload", err)
}
if err := uc.validator.Struct(¶ms); err != nil {
msgs := collectValidationErrors(err)
return newAppError(http.StatusBadRequest, msgs, err)
}
u, err := uc.userUsecase.Create(¶ms)
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/v10to declaratively define validation rules in your domain models atpkg/domain/model/user.go. - Create a custom AppError type in
pkg/adapter/controller/app.goto map domain failures to appropriate HTTP status codes while keeping error details out of the use-case layer. - Wire dependencies in
cmd/app/main.goby 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →