I built ginvalidator β middleware-based request validation for Gin, modeled on express-validator
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
- 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. - 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.
- 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.
- Underlying Engine: The validation logic delegates to
validatorgo, which implements the fullvalidator.jsAPI 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
CheckSchemacollapses repetitive chain declarations into a declarative map. This reduces middleware boilerplate and makes field rules scannable.OneOfhandles mutually exclusive requirements without branching logic in the handler. The middleware evaluates both groups, and only the passing group's data is stored.ErrorsByFieldreturns 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
DefaultErrFmtFuncto 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
OneOfandIf()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
- Install the library: Run
go get github.com/bube054/ginvalidatorto add the package to your module. - Initialize error formatting: Call
gval.SetDefaultErrFmtFunc()during application startup to standardize error shapes. - Define your schema: Use
gval.CheckSchema()to map fields to validation chains. ApplyOptional(),Bail(), or custom validators as needed. - Attach to route: Insert the schema middleware before your handler. Extract results using
gval.ErrorsByField(ctx)and sanitized data viagval.GetMatchedData(ctx). - 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.
