← Back to Blog
DevOps2026-05-13Β·72 min read

I built ginvalidator β€” middleware-based request validation for Gin, modeled on express-validator

By Gbubemi Attah

Declarative Request Validation for Gin: A Middleware-First Approach

Current Situation Analysis

Gin's default approach to HTTP request validation relies heavily on struct tags like binding:"required,email". This pattern works efficiently for straightforward CRUD endpoints where every field follows a simple presence or format rule. The framework handles deserialization and basic checks automatically, keeping handler code clean.

The model breaks down when business logic introduces conditional requirements, cross-field dependencies, or complex sanitization pipelines. Struct tags are declarative but rigid. They cannot express rules like "require this field only if that enum equals premium," or "strip HTML entities before checking length." Developers quickly pivot to manual extraction: calling ctx.ShouldBindJSON(), checking nil values, running regex patterns inline, and returning custom error responses. Within a few months, the same validation logic gets duplicated across endpoints, error shapes diverge, and handler functions bloat with defensive code.

This problem is frequently overlooked because struct tags are heavily promoted in official documentation and tutorials. Teams adopt them as the standard, only to discover later that they lack composability. In production APIs, validation logic typically consumes 15–20% of total handler code. When that logic is scattered across inline conditionals, it becomes difficult to test, version, or internationalize. The Node.js ecosystem addressed this exact friction with express-validator, which treats validation as a composable middleware chain. Porting that architectural pattern to Go and Gin resolves the rigidity of struct tags while preserving Gin's routing ergonomics.

WOW Moment: Key Findings

Shifting from struct-based binding to middleware chains fundamentally changes how validation scales. The table below compares the two approaches across dimensions that directly impact long-term maintainability and API reliability.

Approach Conditional Logic Support Sanitization Pipeline Error Granularity Handler Coupling
Struct Tags Low (requires custom validators or manual checks) None (requires post-bind transformation) Binary (pass/fail per struct) High (tied to handler signature)
Middleware Chains High (If, Skip, OneOf, Optional) Built-in (Trim, Escape, NormalizeEmail) Field-level with location & code Low (decoupled, reusable)

This finding matters because it shifts validation from a passive data-checking step to an active transformation layer. Middleware chains allow sanitization to flow forward into validation, ensure consistent error shaping across all routes, and enable batch validation without aborting on the first failure. The result is handlers that focus purely on business logic, while validation rules become testable, composable units that can be shared across services.

Core Solution

The middleware-chain approach treats each field as an independent validation pipeline. Chains are constructed per request location, composed as Gin middleware, and executed left-to-right before the handler runs. Errors are collected in the request context rather than immediately aborting, giving the handler full control over response shaping.

Architecture Decisions

  1. Location Abstraction: HTTP requests contain data in multiple places (body, query, params, headers, cookies). Each location requires different parsing strategies. The library abstracts this by providing dedicated constructors (NewBodyChain, NewQueryChain, etc.) that handle deserialization and path resolution internally.
  2. Error Collection Over Immediate Abort: Traditional validation fails fast. In API design, failing fast often frustrates clients who must fix one error, retry, and discover the next. Collecting all validation failures in a single pass improves developer experience and reduces round-trips.
  3. Sanitization Before Validation: Input normalization must occur before format checks. Chains execute sanitizers first, then validators, ensuring that whitespace, encoding, or type coercion doesn't trigger false negatives.
  4. Underlying Engine: The validation logic delegates to validatorgo, which implements the full validator.js API surface. This guarantees stable error codes, consistent regex patterns, and cross-language parity with Node.js ecosystems.

Implementation Example

The following example demonstrates a complete validation pipeline for an account creation endpoint. It uses CheckSchema for bulk field configuration, OneOf for conditional login alternatives, and custom error formatting.

package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
	gval "github.com/bube054/ginvalidator"
)

func setupRouter() *gin.Engine {
	r := gin.New()

	// Define validation schema for the request body
	accountSchema := gval.CheckSchema(gval.Schema{
		"contact_email": {
			In: gval.BodyLocation,
			Build: func(chain gval.ValidationChain) gval.ValidationChain {
				return chain.
					Not().Empty(nil).
					Bail().
					Email(nil).
					CustomValidator(func(r *http.Request, initial, sanitized string) bool {
						return !isEmailRegistered(sanitized)
					})
			},
		},
		"account_handle": {
			In: gval.BodyLocation,
			Build: func(chain gval.ValidationChain) gval.ValidationChain {
				return chain.
					Trim("").
					Not().Empty(nil).
					Alphanumeric(nil)
			},
		},
		"recovery_phone": {
			In:       gval.BodyLocation,
			Optional: true,
			Build: func(chain gval.ValidationChain) gval.ValidationChain {
				return chain.MobilePhone(nil, "")
			},
		},
	})

	// Define conditional validation for login alternatives
	loginAlternative := gval.OneOf(
		[]gval.ValidationChain{
			gval.NewBodyChain("contact_email", nil).Not().Empty(nil).Email(nil),
		},
		[]gval.ValidationChain{
			gval.NewBodyChain("recovery_phone", nil).Not().Empty(nil).MobilePhone(nil, ""),
		},
	)

	r.POST("/api/v1/accounts",
		accountSchema,
		loginAlternative,
		func(ctx *gin.Context) {
			// Extract validation results
			fieldErrors := gval.ErrorsByField(ctx)
			if len(fieldErrors) > 0 {
				ctx.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{
					"errors": fieldErrors,
				})
				return
			}

			// Retrieve sanitized, validated data
			matchedData, _ := gval.GetMatchedData(ctx)
			email, _ := matchedData.Get(gval.BodyLocation, "contact_email")
			handle, _ := matchedData.Get(gval.BodyLocation, "account_handle")

			ctx.JSON(http.StatusCreated, gin.H{
				"email": email,
				"handle": handle,
			})
		},
	)

	return r
}

func isEmailRegistered(email string) bool {
	// Placeholder for database lookup
	return false
}

Why This Structure Works

  • CheckSchema collapses repetitive chain declarations into a declarative map. This reduces middleware boilerplate and makes field rules scannable.
  • OneOf handles mutually exclusive requirements without branching logic in the handler. The middleware evaluates both groups, and only the passing group's data is stored.
  • ErrorsByField returns a map keyed by field name, which aligns directly with frontend form rendering libraries. This eliminates manual array-to-map transformations in the handler.
  • Custom Validators receive both the raw and sanitized values, allowing database lookups or cross-service checks without breaking the chain flow.

Pitfall Guide

1. Sanitization Order Misalignment

Explanation: Applying validators before sanitizers causes false failures. For example, validating an email with leading whitespace will fail even though the normalized value is valid. Fix: Always place sanitizers (Trim, Escape, NormalizeEmail) before validators in the chain, or rely on the library's built-in execution order which processes sanitizers first.

2. Overusing Bail() for Business Logic

Explanation: Bail() stops chain execution on the first validation failure. Developers sometimes use it to skip expensive checks, but it also skips subsequent format validations, leaving the field partially validated. Fix: Reserve Bail() for format-critical fields. Use If() or Skip() modifiers for conditional business rules, or split complex logic into separate chains.

3. Ignoring Nested JSON Path Syntax

Explanation: When validating deep objects like {"user": {"profile": {"email": "..."}}}, developers pass "user.profile.email" without realizing the underlying parser uses GJSON syntax. Incorrect path formatting returns nil and bypasses validation. Fix: Explicitly test nested paths with GJSON syntax. Use NewBodyChain("user.profile.email", nil) and verify with a test payload containing the exact structure.

4. Stale Error Context in Middleware Pipelines

Explanation: Calling multiple error readers (ValidationResult, FirstError, ErrorsByField) in sequence without clearing context can return overlapping or outdated data if middleware runs multiple times. Fix: Read validation results exactly once per request lifecycle. Store the extracted map in a local variable and reuse it for logging, metrics, and response shaping.

5. Hardcoding Error Messages for UI Consumption

Explanation: Returning raw validation messages directly to clients ties the API to a specific language and breaks internationalization. Messages also change across library versions. Fix: Rely on the code field in error responses. Map codes to localized strings on the client or via a dedicated i18n middleware. Use DefaultErrFmtFunc only for internal logging.

6. Skipping Optional Field Modifiers

Explanation: Validating an optional field without Optional() causes the chain to fail when the field is absent or empty, even though absence is acceptable. Fix: Always attach Optional() to fields that are not strictly required. The modifier bypasses the entire chain if the value is empty, preventing false negatives.

7. Mixing Request Locations Without Explicit Declaration

Explanation: Assuming all data comes from the body leads to failed lookups when clients send parameters via query strings or headers. Implicit location resolution causes silent validation bypasses. Fix: Explicitly declare the location for every chain. Use NewQueryChain, NewHeaderChain, or NewParamChain as appropriate. Never mix locations in a single chain constructor.

Production Bundle

Action Checklist

  • Audit existing handlers for duplicated validation logic and extract them into reusable schema definitions
  • Replace struct binding tags with middleware chains for endpoints containing conditional or cross-field rules
  • Configure DefaultErrFmtFunc to standardize error codes and messages across all services
  • Implement unit tests for each validation chain using mock HTTP requests with edge-case payloads
  • Map validation error codes to client-side i18n keys instead of returning raw messages
  • Add request validation metrics (failure rate, top failing fields) to observability dashboards
  • Review OneOf and If() logic to ensure mutually exclusive rules don't create ambiguous success states

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Simple CRUD with fixed fields Struct binding tags Low overhead, framework-native, sufficient for basic presence/format checks Minimal
Conditional fields, cross-field rules, or sanitization needs Middleware chains (CheckSchema, OneOf) Declarative, composable, handles complex logic without handler bloat Moderate (initial setup)
High-throughput public API Middleware chains + error code mapping Batch validation reduces client round-trips, stable codes enable caching & i18n Low long-term
Multi-tenant SaaS with dynamic schemas External schema validation (OpenAPI/JSON Schema) + middleware fallback Dynamic rules require runtime evaluation; middleware handles static core validation High (architecture complexity)

Configuration Template

Copy this template into your router initialization file. It demonstrates production-ready error formatting, schema composition, and middleware integration.

package config

import (
	"net/http"

	"github.com/gin-gonic/gin"
	gval "github.com/bube054/ginvalidator"
)

// InitializeValidationMiddleware sets up global error formatting and returns reusable schema builders
func InitializeValidationMiddleware() {
	// Standardize error responses across all routes
	gval.SetDefaultErrFmtFunc(func(location, field, value, message, code string) map[string]interface{} {
		return map[string]interface{}{
			"location": location,
			"field":    field,
			"code":     code,
			"message":  message,
		}
	})
}

// CreateUserSchema returns a validation middleware for user registration
func CreateUserSchema() gin.HandlerFunc {
	return gval.CheckSchema(gval.Schema{
		"email": {
			In: gval.BodyLocation,
			Build: func(c gval.ValidationChain) gval.ValidationChain {
				return c.Not().Empty(nil).Bail().Email(nil)
			},
		},
		"password": {
			In: gval.BodyLocation,
			Build: func(c gval.ValidationChain) gval.ValidationChain {
				return c.Not().Empty(nil).StrongPassword(nil)
			},
		},
		"display_name": {
			In:       gval.BodyLocation,
			Optional: true,
			Build: func(c gval.ValidationChain) gval.ValidationChain {
				return c.Trim("").Escape().MaxLength(nil, 50)
			},
		},
	})
}

Quick Start Guide

  1. Install the library: Run go get github.com/bube054/ginvalidator to add the package to your module.
  2. Initialize error formatting: Call gval.SetDefaultErrFmtFunc() during application startup to standardize error shapes.
  3. Define your schema: Use gval.CheckSchema() to map fields to validation chains. Apply Optional(), Bail(), or custom validators as needed.
  4. Attach to route: Insert the schema middleware before your handler. Extract results using gval.ErrorsByField(ctx) and sanitized data via gval.GetMatchedData(ctx).
  5. Test with edge cases: Send payloads with missing fields, invalid formats, and nested JSON paths. Verify that error codes remain stable and sanitization flows correctly into validation.