DEV Community

Ansu Jain
Ansu Jain

Posted on

Best Practices for Building a Validation Layer in Go

Validation is an essential part of any software system. It helps ensure that the data being processed or stored in the system is correct and meets the required constraints.

In this article, we will discuss how to implement a generic validation layer in Go that can be used to validate any object using a list of validations.

The Problem
Consider a scenario where we have a user registration system that accepts user details such as name, email, and password. Before storing the user data in the database, we need to validate it to ensure that the data meets the required constraints. For example, the name should not be empty, the email should be in a valid format, and the password should meet the minimum strength requirements.

One way to implement the validation layer is to define a validation function for each constraint and call them one by one for each object. However, this approach can be tedious and error-prone, especially when dealing with a large number of objects.

The Solution
To solve this problem, we can implement a generic validation layer that takes a list of validations and applies them to any object.

package validation

import (
    "errors"
    "fmt"
)

type Rule func(key string, value interface{}) error

type Rules []Rule

type Validator struct {
    rules Rules
}

func (v *Validator) Add(rule Rule) {
    v.rules = append(v.rules, rule)
}

func (v *Validator) Validate(data map[string]interface{}) []error {
    var errors []error
    for _, rule := range v.rules {
        for key, value := range data {
            if err := rule(key, value); err != nil {
                errors = append(errors, err)
            }
        }
    }
    return errors
}

func ValidateLength(maxLength int) Rule {
    return func(key string, value interface{}) error {
        str, ok := value.(string)
        if !ok {
            return fmt.Errorf("%s is not a string", key)
        }
        if len(str) > maxLength {
            return fmt.Errorf("%s must be less than or equal to %d characters", key, maxLength)
        }
        return nil
    }
}

func ValidatePresence(key string, value interface{}) error {
    if value == "" {
        return fmt.Errorf("%s can't be blank", key)
    }
    return nil
}

func ValidateRange(min, max int) Rule {
    return func(key string, value interface{}) error {
        num, ok := value.(int)
        if !ok {
            return fmt.Errorf("%s is not a number", key)
        }
        if num < min || num > max {
            return fmt.Errorf("%s must be between %d and %d", key, min, max)
        }
        return nil
    }
}

func ValidateEmail(key string, value interface{}) error {
    str, ok := value.(string)
    if !ok {
        return fmt.Errorf("%s is not a string", key)
    }
    // simplified email validation for example purposes
    if str == "" || str[:1] == "@" || str[len(str)-1:] == "@" {
        return fmt.Errorf("%s is not a valid email address", key)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The Rule type represents a single validation rule, which takes a key-value pair and returns an error if the value fails to meet the rule's criteria.

The Rules type is a slice of Rule types, representing a collection of validation rules.

The Validator type contains a list of rules, and has methods for adding new rules and validating data against all the rules in the list.

The Add method allows you to add a new validation rule to the validator.

The Validate method takes a map of key-value pairs and returns a list of errors found during the validation process.

The ValidateLength, ValidatePresence, ValidateRange, and ValidateEmail functions are examples of predefined rules that you can use. These functions return a Rule type, which you can add to a Validator instance using the Add method.

Now that we have our validation layer in place, let’s see how we can use it to validate the request body of an API endpoint. We’ll create a simple HTTP handler that accepts a JSON request body and validates it against a set of validation rules.

func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
    var user User
    err := json.NewDecoder(r.Body).Decode(&user)
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        fmt.Fprintf(w, "failed to decode request body: %v", err)
        return
    }

    // Define validation rules
    rules := []validation.Rule{
        validation.ValidateRequired("Name", user.Name),
        validation.ValidateLength("Name", user.Name, 3, 50),
        validation.ValidateEmail("Email", user.Email),
        validation.ValidateRequired("Password", user.Password),
        validation.ValidateLength("Password", user.Password, 8, 50),
    }

    // Execute validation rules and get errors
    errors := validation.Execute(rules)

    if len(errors) > 0 {
        w.WriteHeader(http.StatusBadRequest)
        for _, err := range errors {
            fmt.Fprintf(w, "%s\n", err.Error())
        }
        return
    }

    // Do something with the validated user object
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Here, we’re defining the validation rules as a slice of validation.Rule objects. We're then passing this slice to the validation.Execute function, which executes the validation rules and returns a slice of errors.

If any validation errors occur, we return a 400 Bad Request status code with the list of errors. Otherwise, we do something with the validated user object.

Conclusion
In this article, we’ve seen how to create a flexible and reusable validation layer in Go using functional options and closures. This approach allows us to define complex validation rules and reuse them across multiple parts of our application.

By separating the validation logic from the business logic of our application, we can make our code more modular and easier to test. We can also provide better feedback to the user when validation errors occur.

I hope this article has been helpful and provides you with a good starting point for creating your own validation layer in Go.

Top comments (3)

Collapse
 
napicella profile image
Nicola Apicella

Nice article. One thing that I do sometimes is to have validators returning a specific type. Eg. the validator for email returns a type Email which can only be constructed if the input is valid.
By doing that, the business logic is guaranteed to receive as input a valid email rather than just a string.

Collapse
 
rlgino profile image
Gino Luraschi

It looks like add ValueObjects, it's a good advantage, but you can consider that you are adding some over complex to the code as well

Collapse
 
birowo profile image
birowo