Validating Request Data | Golang Monolith Mastery (Episode 22)
Request validation is a non-negotiable step in building production-ready HTTP microservices and monolithic architectures. While Data Transfer Objects (DTOs) structure incoming JSON payloads, validation ensures that incoming data strictly adheres to business rules before reaching database operations. This chapter explores how to build decoupling mechanisms for request validation in Go, create custom error types that satisfy Go's native error interface, handle HTTP error states cleanly, and weigh hand-crafted validation against third-party reflection libraries.
1. Why Do We Need Request Validation?
When building endpoints like POST /listings, receiving payload data is only half the battle. Without validation, omitting required payload attributes leads directly to database corruption.
The Problem: Silent Database Corruption
Consider a request payload missing a mandatory title attribute:
{
"description": "Handcrafted wooden table",
"price": 250.00
}
If the handler unmarshals this JSON directly into a DTO and passes it to the persistent storage layer without checking required fields, Go will assign zero-values (e.g., empty string "" for strings) to missing fields.
In production, an unvalidated record containing empty critical fields corrupts database integrity, breaks search indices, and creates inconsistent frontend displays. Validation acts as a gatekeeper at the boundary of your application layer.
2. Naive Validation vs. DTO-Bound Methods
When implementing validation, developers typically choose between inline checks inside handlers or attaching validation behavior directly to the DTO struct.
The Naive Approach: Handler-Level Inline Checks
A straightforward approach is inserting conditional checks directly inside the HTTP controller/handler right after decoding the JSON body:
// Naive Handler Validation (Not Scalable)
func CreateListingHandler(w http.ResponseWriter, r *http.Request) {
var req CreateListingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Handle decode error
return
}
// Inline validation logic directly in the handler
if req.Title == "" {
http.Error(w, "Title is required", http.StatusBadRequest)
return
}
if req.Price <= 0 {
http.Error(w, "Price must be greater than zero", http.StatusBadRequest)
return
}
// Business logic proceeds...
}
Disadvantages of Inline Handler Validation
- Handler Pollution: HTTP handlers become cluttered with structural validation rules rather than focusing on request orchestration.
- Code Duplication: If
CreateListingRequestis reused across background workers, gRPC endpoints, or administrative handlers, validation logic must be manually duplicated. - Lack of Maintainability: Adding complex trimming or string sanitization multiplies procedural logic across endpoint functions.
The Scalable Approach: DTO Method Attachment
A far cleaner pattern is attaching self-contained Validate() receiver methods directly onto the DTO struct.
type CreateListingRequest struct {
Title string `json:"title"`
Description string `json:"description"`
Price float64 `json:"price"`
}
// DTO encapsulates its own validation rules
func (req *CreateListingRequest) Validate() error {
// Structural rules defined directly on the data model
return nil
}
By placing validation responsibilities onto the DTO itself, the DTO becomes self-validating, preserving single-responsibility domain boundaries.
3. Implementing DTO Validation with String Sanitization
When checking string inputs, testing purely for "" is insufficient because payloads containing whitespace (" ") can bypass naive string checks. We must sanitize input values using Go's standard strings package before checking field constraints.
package dto
import (
"strings"
)
func (req *CreateListingRequest) Validate() error {
// Trim whitespace to prevent whitespace-only string insertions
if strings.TrimSpace(req.Title) == "" {
return &ValidationError{
Field: "title",
Message: "must not be empty",
}
}
// If all validation rules pass, return nil
return nil
}
4. Custom Error Typings & Satisfying Go's error Interface
To present well-structured validation errors to downstream clients, we should avoid returning unstructured flat strings. Instead, define a dedicated ValidationError struct.
Defining the ValidationError Struct
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}
Implementing Go's Built-in error Interface
In Go, any type satisfies the built-in error interface as long as it implements an Error() string method:
type error interface {
Error() string
}
To make ValidationError compliant with Go's error interface, attach the Error() method to it:
// Pointer receiver implementing the error interface
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
Note on Pointer Receivers vs. Value Receivers: If you attach
Error()to a pointer receiver(e *ValidationError), returning a literal struct valuereturn ValidationError{...}will cause a compiler failure:Cannot use ValidationError as error value in return statement: ValidationError does not implement error (Error method has pointer receiver). Always return a pointer referencereturn &ValidationError{...}to stay consistent with pointer receiver semantics.
5. Handler Integration & HTTP Status Codes (400 vs. 422)
Once the DTO exposes Validate(), the HTTP handler invokes validation cleanly right after JSON unmarshalling.
func CreateListing(w http.ResponseWriter, r *http.Request) {
var req dto.CreateListingRequest
// Step 1: Decode incoming JSON
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.Error(w, "Malformed request body", http.StatusBadRequest)
return
}
// Step 2: Validate DTO state
if err := req.Validate(); err != nil {
httpx.ValidationError(w, err, http.StatusUnprocessableEntity)
return
}
// Step 3: Execute Business Logic...
}
Protocol Standards: HTTP Status 400 vs. 422
Selecting the correct HTTP status code depends on the specific nature of the client failure:
6. Full Execution Flow Walkthrough
The following execution sequence illustrates how data flows through structural decoding, validation checks, custom error wrapping, and client response formatting:
7. Extracting Typed Errors with errors.As
When an error is passed inside a standard Go error interface variable, handler code cannot directly read non-interface fields (such as Field or Message on ValidationError).
To inspect custom concrete types tucked inside Go error interfaces safely, use Go's standard library method errors.As.
The errors.As Extraction Pattern
package httpx
import (
"errors"
"net/http"
)
type ErrorPayload struct {
Code string `json:"code"`
Message string `json:"message"`
Field string `json:"field,omitempty"`
}
func ValidationError(w http.ResponseWriter, err error, statusCode int) {
var valErr *dto.ValidationError
payload := ErrorPayload{
Code: "VALIDATION_FAILED",
Message: err.Error(),
}
// errors.As unwraps interface bindings and targets custom underlying struct pointers
if errors.As(err, &valErr) {
payload.Field = valErr.Field
}
renderJSON(w, statusCode, payload)
}
How errors.As Works:
- It inspects the input
errinterface. - It checks if any error in the call chain matches the target type provided in the second argument (
&valErr). - If a match is found, it automatically sets the target variable to point directly to the underlying concrete
ValidationErrorinstance and returnstrue.
8. Payload Optimization with omitempty JSON Tags
When standard system errors occur (e.g., 400 Bad Request or 500 Internal Server Error), the Field key in ErrorPayload remains empty (""). Returning empty fields like "field": "" degrades client API experience.
The Solution: Go's omitempty Directive
Adding omitempty to the JSON tag instructs Go's standard encoding/json package to omit the field entirely from the marshaled JSON response whenever it holds its zero-value ("", 0, false, nil).
type ErrorPayload struct {
Code string `json:"code"`
Message string `json:"message"`
Field string `json:"field,omitempty"` // Excluded if Field == ""
}
JSON Response Output Comparison
1. Validation Error Response (422 Unprocessable Entity):
{
"code": "VALIDATION_FAILED",
"message": "title: must not be empty",
"field": "title"
}
2. Standard Parsing Error Response (400 Bad Request):
{
"code": "MALFORMED_JSON",
"message": "Invalid character 'a' looking for beginning of value"
}
(Notice that "field" is cleanly omitted without breaking client contractual expectations).
9. Custom Validation vs. Third-Party Libraries
While custom DTO methods work exceptionally well for standard applications, developers often evaluate whether to hand-craft validation logic or use reflection-driven third-party libraries like go-playground/validator.
Industry Comparison Matrix
Example Struct Tag Representation (go-playground/validator)
// Alternative Tag-Based Third-Party Library Syntax
type CreateListingRequest struct {
Title string `json:"title" validate:"required"`
Description string `json:"description" validate:"required,min=10"`
Price float64 `json:"price" validate:"required,gt=0"`
}
Architectural Choice Rubric
- Use Custom Method Validation when building core enterprise software requiring zero-dependency boundaries, full compile-time safety, dynamic business checks, and transparent debugging workflows.
- Use Third-Party Tag Libraries when quickly prototyping large applications containing hundreds of standard multi-attribute DTO structs with repetitive structural limits.
10. Summary and Key Takeaways
- Validation Prevents Silent Database Corruption: Always validate input DTOs before reaching persistent storage layers to avoid storing zero-valued or empty field states.
- DTO Receiver Methods Encapsulate Logic: Attach
Validate() errorfunctions directly onto DTO structs to preserve domain boundaries and avoid polluting HTTP handlers. - Sanitize String Inputs: Use
strings.TrimSpace()when validating mandatory text fields to prevent space-only strings from bypassing empty checks. - Satisfy the
errorInterface: Custom structs become native Go error types by implementing theError() stringsignature. - Maintain Receiver Consistency: Use pointer receivers (
*ValidationError) consistently when satisfying interface methods and returning error values (&ValidationError{...}). - Differentiate HTTP
400and422: Use HTTP status400 Bad Requestfor raw payload decoding failures and status422 Unprocessable Entityfor semantic DTO validation failures. - Unpack Custom Errors with
errors.As: Useerrors.As()to safely inspect specific custom error types and fields wrapped inside standard Goerrorinterfaces. - Omit Zero-Value Fields cleanly with
omitempty: Addomitemptyto JSON response tags so optional contextual attributes (such asfield) are cleanly suppressed during generic system error states.