The Importance of Consistent Form Validation
Have you ever opened up a file you wrote just a few months ago and wondered, „What was I thinking here?“ That’s exactly what happened to me while developing Announcable, a platform for in-product communication. As the codebase grew, I found myself implementing form handling in slightly different ways across various handlers. Some validated input immediately, others after business logic had already started executing, and a few brave ones barely validated at all (we’ve all been there in a rush, right?).
I needed a pattern – something clean, predictable, and scalable that would work for everything from a simple contact form to complex multi-step workflows. The solution I landed on was surprisingly straightforward: a consistent Parse → Decode → Validate pattern that sits at the beginning of every handler.
In this post, I’ll walk you through this pattern using real code examples from my Go applications. Whether you’re building a small side project or a complex web application, this approach scales beautifully while keeping your code clean and your future self much happier.
Implementation: A Practical Walkthrough
DTOs as the Foundation
Before diving into the validation flow, let’s talk about Data Transfer Objects (DTOs). If you’re not familiar with the term, DTOs are simply structures that define the shape of data moving into and out of your application. In our case, they represent the expected form data from HTTP requests.
What makes DTOs powerful is their clarity - they explicitly define what your handler expects, making your code self-documenting. I’ve found that placing these definitions at the top of each handler file provides an immediate overview of what that endpoint accepts.
Here’s a simple example from my codebase, with validation tags added:
type lpBaseUrlUpdateForm struct {
UseCustomUrl bool `schema:“use_custom_url“`
CustomUrl string `schema:“custom_url“ validate:“omitempty,url“`
}
This DTO is straightforward - it expects a boolean flag indicating whether to use a custom URL and the custom URL string itself. The schema
tags tell our decoder how to map form field names to our struct fields. I’ve added a validate
tag that ensures the CustomUrl field contains a valid URL format when present. (More on tags later)
The Validation Workflow
Now let’s break down the three-step process that forms the backbone of our validation pattern:
Step 1: Parse - Extracting Form Data
The first step is simple - we need to parse the incoming form data from the HTTP request:
if err := r.ParseForm(); err != nil {
h.log.Error().Err(err).Msg(„Error parsing form“)
http.Error(w, „Error updating release note“, http.StatusBadRequest)
return
}
This extracts the form data from the request and makes it available for the next step. It’s a basic operation, but handling the error case properly is crucial - we immediately log the error and return an appropriate HTTP response.
Step 2: Decode - Converting to Structured Data
Once we have the raw form data, we need to transform it into our DTO structure:
var decoder := schema.NewDecoder()
var updateDTO lpBaseUrlUpdateForm
if err := decoder.Decode(&updateDTO, r.PostForm); err != nil {
h.log.Error().Err(err).Msg(„Error decoding form“)
http.Error(w, „Error updating release note“, http.StatusBadRequest)
return
}
Here’s where the Gorilla Schema library shines. It handles all the mapping between form fields and struct fields, including type conversions.
Step 3: Validate - Ensuring Data Integrity
With our form data now converted to a typed struct, we can validate it:
validate := validator.New()
if err := validate.Struct(updateDTO); err != nil {
h.log.Error().Err(err).Msg(„Validation error“)
// Return validation errors to the client
validationErrors := err.(validator.ValidationErrors)
errorMessages := make([]string, 0, len(validationErrors))
for _, e := range validationErrors {
switch e.Tag() {
case „url“:
errorMessages = append(errorMessages, „Please enter a valid URL“)
default:
errorMessages = append(errorMessages, fmt.Sprintf(„Invalid value for %s“, e.Field()))
}
}
http.Error(w, strings.Join(errorMessages, „, „), http.StatusBadRequest)
return
}
The Go-Playground Validator package makes validation declarative and extensible. In this enhanced example, we’re not just detecting validation errors but also extracting specific error details to provide meaningful feedback to the user.
When the URL format is invalid, we return a user-friendly error message rather than a generic response. This makes debugging easier for developers and provides clear guidance for end users.
From Theory to Practice
Let’s see how this all comes together in a complete handler:
func (h *Handler) HandleReleasePageBaseUrlUpdate(w http.ResponseWriter, r *http.Request) {
h.log.Trace().Msg(„HandleReleasePageBaseUrlUpdate“)
ctx := r.Context()
orgId := ctx.Value(mw.OrgIDKey).(string)
if orgId == „“ {
h.log.Error().Msg(„Organisation ID not found in context“)
http.Error(w, „Error updating release note“, http.StatusInternalServerError)
}
// parse form
if err := r.ParseForm(); err != nil {
h.log.Error().Err(err).Msg(„Error parsing form“)
http.Error(w, „Error updating release note“, http.StatusBadRequest)
return
}
// decode form
var updateDTO lpBaseUrlUpdateForm
if err := h.decoder.Decode(&updateDTO, r.PostForm); err != nil {
h.log.Error().Err(err).Msg(„Error decoding form“)
http.Error(w, „Error updating release note“, http.StatusBadRequest)
return
}
// validate form
validate := validator.New()
if err := validate.Struct(updateDTO); err != nil {
h.log.Error().Err(err).Msg(„Validation error“)
// Return validation errors to the client
validationErrors := err.(validator.ValidationErrors)
errorMessages := make([]string, 0, len(validationErrors))
for _, e := range validationErrors {
switch e.Tag() {
case „url“:
errorMessages = append(errorMessages, „Please enter a valid URL“)
default:
errorMessages = append(errorMessages, fmt.Sprintf(„Invalid value for %s“, e.Field()))
}
}
http.Error(w, strings.Join(errorMessages, „, „), http.StatusBadRequest)
return
}
var newBaseUrl *string
if updateDTO.UseCustomUrl {
newBaseUrl = &updateDTO.CustomUrl
} else {
newBaseUrl = nil
}
widgetService := widgetconfigs.NewService(*widgetconfigs.NewRepository(h.DB))
widgetService.UpdateBaseUrl(uuid.MustParse(orgId), newBaseUrl)
w.Header().Set(„HX-Trigger“, „custom:submit-success“)
w.WriteHeader(http.StatusOK)
return
}
Notice how clean this flow is:
- Initialize and extract context values
- Parse the form data
- Decode into our DTO
- Validate the DTO with detailed error handling
- Only then proceed with business logic
What I love about this pattern is how it scales. For a simple form like this one, it might seem like overkill. But when you’re dealing with complex forms with dozens of fields, nested structures, and intricate validation rules, this pattern keeps your code organized and maintainable.
The enhanced validation error handling also means that your API provides meaningful feedback that can be directly presented to users or used by frontend developers to show appropriate error messages in the UI.
Conclusion: Simplicity That Scales
There’s a certain elegance in finding patterns that are both simple and powerful. The Parse → Decode → Validate workflow I’ve described isn’t groundbreaking computer science, but it’s transformed how I build web applications in Go.
What started as a solution to my own frustration with inconsistent handlers in Announcable has become a cornerstone of how I approach form handling across all my projects. The benefits have been substantial:
-
Consistency across the codebase - Every handler follows the same pattern, making it easy to understand and modify any endpoint, even months later.
-
Clear separation of concerns - Validation happens before business logic, preventing invalid data from corrupting your application state.
-
Self-documenting code - The DTO at the top of each handler serves as documentation for what that endpoint expects.
-
Scalability - This pattern works just as well for a simple login form as it does for complex multi-step workflows with dozens of fields.
-
Easier testing - With clear boundaries between validation and business logic, unit testing becomes more straightforward.
If you’re building web applications in Go, I encourage you to give this pattern a try. Start with a simple endpoint, define your DTO at the top, follow the Parse → Decode → Validate steps, and see how it feels. I suspect you’ll find, as I did, that this small bit of structure brings a surprising amount of clarity to your codebase.
The goal here isn’t to add complexity – it’s to add just enough structure to make your future development work smoother and more predictable. Codebases tend to grow more complex over time, and sometimes the simplest patterns are the ones that save us.