Request Validation and HTTP Error Handling in Go

NestGo gives you a complete pipeline for request validation and HTTP error handling in Go: DTOs validate themselves automatically when extracted, and every error flows through one configurable handler that renders consistent JSON — or RFC 7807 problem details — for your Golang API.

How DTO validation works in NestGo

When you extract a request body with core.Body[T](c) (or the core.B[T]() extractor) or query parameters with core.QueryDTO[T](c), NestGo runs validation automatically in a fixed order:

  1. Bind — the raw request body or query string is decoded into your struct. A decode failure returns a 400 Bad Request.
  2. Struct-tag validation — if a global validate function is registered (see below), it runs against the bound DTO. This is where validate:"required,email" tags are checked.
  3. Custom validation — if the DTO implements the Validatable interface, its Validate() method runs last, for business rules that tags can’t express.

The first step that fails short-circuits the pipeline and returns its error to the framework, which passes it to the error handler.

The Validatable interface: self-validating DTOs

Any DTO can implement Validatable to validate itself. Body[T]() and QueryDTO[T]() detect the interface and call it automatically — no registration needed.

type Validatable interface {
    Validate() error
}
type CreateUserDTO struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

func (d *CreateUserDTO) Validate() error {
    if d.Name == "" {
        return core.ErrBadRequest("name is required")
    }
    if !strings.Contains(d.Email, "@") {
        return core.ErrBadRequest("invalid email format")
    }
    return nil
}

Use it in a handler:

func (ctrl *UserController) Create(c core.Context) error {
    dto, err := core.Body[CreateUserDTO](c)
    if err != nil {
        return err // already an *HTTPError — just return it
    }
    // dto is *CreateUserDTO, fully bound and validated
    return c.JSON(201, dto)
}

Or with the typed handler builders, where validation still runs automatically — note that HandleC1 passes the extracted DTO to your handler, so its signature takes the DTO as a second argument:

// func (ctrl *UserController) Create(c core.Context, dto *CreateUserDTO) error
r.POST("/users", core.HandleC1(core.B[CreateUserDTO](), ctrl.Create))

Struct-tag validation with nestgo-validator

Writing a Validate() method on every DTO gets repetitive. NestGo’s answer is a pluggable global validator: register one function, and every DTO extracted via Body[T]() / QueryDTO[T]() is validated against its struct tags. This bridges nestgo-validator — or any validation library — into the extraction pipeline, the same way NestJS’s app.useGlobalPipes(new ValidationPipe()) works.

Option 1: SetValidateFunc

Call once in main() before starting the server:

import (
    "github.com/ashrafAli23/nestgo/core"
    validator "github.com/ashrafAli23/nestgo-validator"
)

func main() {
    core.SetValidateFunc(validator.Validate)
    // ...
}

Option 2: Config.ValidateFunc

Set it on the config and NestGo registers it for you during app construction:

config := core.DefaultConfig()
config.ValidateFunc = validator.Validate

Both options accept the same signature: func(v interface{}) error. The function receives the bound DTO pointer and should return an error (typically a validation error or *core.HTTPError) on failure.

Struct tags plus custom logic

Once the global validator is registered, tags are checked automatically — and you can still implement Validatable on top for business rules. Tags run first, then Validate():

type CreateUserDTO struct {
    Name  string `json:"name"  validate:"required,min=3,max=50"`
    Email string `json:"email" validate:"required,email"`
    Plan  string `json:"plan"  validate:"required"`
}

// Optional extra layer — runs after struct tags pass.
func (d *CreateUserDTO) Validate() error {
    if d.Plan == "enterprise" && !strings.HasSuffix(d.Email, "@company.com") {
        return core.ErrBadRequest("enterprise plan requires a company email")
    }
    return nil
}

Validation helper functions

For hand-written Validate() methods, NestGo ships small helpers that return a ready-made 400 Bad Request with a clear message:

Helper Signature Checks
Required Required(field, name string) error String is not empty (after trimming whitespace)
MinLength MinLength(field, name string, min int) error String length is at least min
MaxLength MaxLength(field, name string, max int) error String length is at most max
InRange InRange(value int, name string, min, max int) error Int is between min and max inclusive
func (d *CreateUserDTO) Validate() error {
    if err := core.Required(d.Name, "name"); err != nil {
        return err
    }
    if err := core.MinLength(d.Name, "name", 3); err != nil {
        return err
    }
    if err := core.MaxLength(d.Name, "name", 50); err != nil {
        return err
    }
    return core.InRange(d.Age, "age", 18, 120)
}

HTTPError: typed HTTP errors in Go

core.HTTPError is the error type the whole framework understands. Return one from any handler, guard, or extractor and the error handler renders it with the right status code.

type HTTPError struct {
    Code    int          `json:"code"`
    Message string       `json:"message"`
    Details interface{}  `json:"details,omitempty"`
    Errors  []FieldError `json:"errors,omitempty"` // field-level errors
}

Its Error() method formats as HTTP <code>: <message>, so it also behaves as a normal Go error.

Error constructors reference

Constructor Status code Meaning
ErrBadRequest(msg) 400 Malformed request, failed binding, invalid input
ErrUnauthorized(msg) 401 Missing or invalid credentials
ErrForbidden(msg) 403 Authenticated but not allowed
ErrNotFound(msg) 404 Resource does not exist
ErrConflict(msg) 409 State conflict (duplicate, stale version)
ErrUnprocessable(msg) 422 Semantically invalid input
ErrInternalServer(msg) 500 Unexpected server-side failure

For any other status code, or to attach arbitrary details:

// Any status code:
core.NewHTTPError(http.StatusTooManyRequests, "rate limit exceeded")

// With extra structured details (rendered under "details"):
core.NewHTTPErrorWithDetails(http.StatusConflict, "email already registered",
    map[string]any{"email": dto.Email})

The built-in error handlers unwrap with errors.As, so an *HTTPError wrapped with fmt.Errorf("...: %w", err) keeps its status code and message. Non-HTTPError errors are logged server-side and answered with a generic 500 “Internal Server Error” — the real error message never reaches the client.

FieldError and NewValidationError

For validation failures that touch multiple fields, return a structured 422 with per-field errors:

type FieldError struct {
    Field   string `json:"field"`
    Message string `json:"message"`
    Tag     string `json:"tag,omitempty"`   // validation tag that failed, e.g. "required", "min"
    Value   string `json:"value,omitempty"` // the rejected value
}
return core.NewValidationError(
    core.FieldError{Field: "email", Message: "invalid format", Tag: "email"},
    core.FieldError{Field: "name", Message: "is required", Tag: "required"},
)

NewValidationError produces an *HTTPError with code 422, message "validation failed", and the field errors in Errors.

The default error response format

core.DefaultErrorHandler (the default in core.DefaultConfig()) renders every error as JSON under an "error" key:

{
  "error": {
    "code": 422,
    "message": "validation failed",
    "errors": [
      { "field": "email", "message": "invalid format", "tag": "email" },
      { "field": "name", "message": "is required", "tag": "required" }
    ]
  }
}

details and errors only appear when set. Wrapped *HTTPError values are resolved with errors.As; anything that is not an HTTPError renders as a generic 500 with message "Internal Server Error" (the underlying error is logged, never sent).

RFC 7807 problem details in Go

For APIs that follow RFC 7807 (application/problem+json), swap in the built-in handler — one line:

config := core.DefaultConfig()
config.ErrorHandler = core.ProblemDetailErrorHandler

Errors are then rendered as a ProblemDetail:

type ProblemDetail struct {
    Type     string       `json:"type"`
    Title    string       `json:"title"`
    Status   int          `json:"status"`
    Detail   string       `json:"detail,omitempty"`
    Instance string       `json:"instance,omitempty"`
    Errors   []FieldError `json:"errors,omitempty"`
}

The handler sets Content-Type: application/problem+json, uses "about:blank" as the type, and derives title from the status code (falling back to http.StatusText for uncommon codes). The same ErrNotFound("user not found") now produces:

{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "user not found"
}

Field errors from NewValidationError carry through into the errors array, so validation responses stay structured under RFC 7807 too.

How to write a custom error handler

Config.ErrorHandler accepts any function matching the ErrorHandler signature, so you control the response shape completely:

config.ErrorHandler = func(c core.Context, err error) {
    if err == nil {
        return
    }
    var httpErr *core.HTTPError
    if !errors.As(err, &httpErr) { // errors.As, so wrapped HTTPErrors keep their status
        core.Log().Error("unhandled error", core.F("error", err.Error()))
        httpErr = core.ErrInternalServer("Internal Server Error") // never echo internals to clients
    }
    _ = c.JSON(httpErr.Code, map[string]any{
        "success": false,
        "status":  httpErr.Code,
        "message": httpErr.Message,
        "errors":  httpErr.Errors,
    })
}

Typical additions: logging with a request ID, hiding internal messages in production, or mapping domain errors (e.g. sql.ErrNoRows) to HTTP errors in one place. For per-route or per-group error formatting instead of a global handler, use an exception filter such as core.HTTPErrorFilter with core.UseFilters(...).

Next steps