Parse, Decode, Validate: A Clean Pattern for Form Handling in Go

Go Web Development

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:

  1. Initialize and extract context values
  2. Parse the form data
  3. Decode into our DTO
  4. Validate the DTO with detailed error handling
  5. 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:

  1. Consistency across the codebase - Every handler follows the same pattern, making it easy to understand and modify any endpoint, even months later.

  2. Clear separation of concerns - Validation happens before business logic, preventing invalid data from corrupting your application state.

  3. Self-documenting code - The DTO at the top of each handler serves as documentation for what that endpoint expects.

  4. Scalability - This pattern works just as well for a simple login form as it does for complex multi-step workflows with dozens of fields.

  5. 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.

Imprint

This website is created and run by

Daniel Benner

Zur Deutschen Einheit 2

81929 München

Germany

hello(a)danielbenner.de