package order
type CreateOrderRequest struct {
Reference string `json:"reference" binding:"required,min=3,max=40"`
Currency string `json:"currency" binding:"required,currency"`
Customer Customer `json:"customer" binding:"required"`
Items []OrderItem `json:"items" binding:"required,min=1,dive"`
Note *string `json:"note" binding:"omitempty,max=200"`
}
type Customer struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Phone string `json:"phone" binding:"omitempty,e164"`
}
type OrderItem struct {
SKU string `json:"sku" binding:"required,sku"`
Quantity int `json:"quantity" binding:"required,min=1,max=999"`
UnitPrice float64 `json:"unit_price" binding:"required,gt=0"`
}
package order
import (
"regexp"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
)
var (
allowedCurrencies = map[string]struct{}{
"USD": {}, "EUR": {}, "GBP": {}, "JPY": {},
}
skuPattern = regexp.MustCompile(`^[A-Z]{2,4}-[0-9]{4,8}$`)
)
func validateCurrency(fl validator.FieldLevel) bool {
_, ok := allowedCurrencies[fl.Field().String()]
return ok
}
func validateSKU(fl validator.FieldLevel) bool {
return skuPattern.MatchString(fl.Field().String())
}
func RegisterCustomValidators() error {
v, ok := binding.Validator.Engine().(*validator.Validate)
if !ok {
return nil
}
if err := v.RegisterValidation("currency", validateCurrency); err != nil {
return err
}
return v.RegisterValidation("sku", validateSKU)
}
package order
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
)
func CreateOrder(c *gin.Context) {
var req CreateOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
if verrs, ok := err.(validator.ValidationErrors); ok {
c.JSON(http.StatusUnprocessableEntity, gin.H{
"errors": fieldErrors(verrs),
})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"reference": req.Reference,
"items": len(req.Items),
})
}
func fieldErrors(verrs validator.ValidationErrors) map[string]string {
out := make(map[string]string, len(verrs))
for _, fe := range verrs {
out[fe.Namespace()] = fe.Tag()
}
return out
}
package main
import (
"log"
"github.com/gin-gonic/gin"
"example.com/app/order"
)
func main() {
if err := order.RegisterCustomValidators(); err != nil {
log.Fatalf("validator setup: %v", err)
}
r := gin.Default()
r.POST("/orders", order.CreateOrder)
if err := r.Run(":8080"); err != nil {
log.Fatal(err)
}
}
This snippet shows how a Gin HTTP API binds a nested JSON payload into Go structs and validates it using go-playground/validator with both built-in tags and a custom rule registered against the validator engine. The example models an order-creation endpoint where the request has nested objects (Customer) and a slice of Items, each with their own constraints.
In order_dto.go, the request types are pure data-transfer objects decorated with struct tags. The binding tag is what Gin reads: required, email, gte, dive, and the custom currency rule all live there. The dive tag on Items []OrderItem is the key to nested validation — it tells the validator to descend into each element of the slice and apply the element's own tags, so min=1 on Quantity and sku on SKU are enforced per item. Nested structs like Customer are validated automatically because the validator recurses into struct fields by default.
In validators.go, two custom validators are registered on Gin's shared *validator.Validate instance obtained through binding.Validator.Engine(). validateCurrency restricts a string to a small allow-list of ISO currency codes, and validateSKU enforces a format with a regular expression. Registering them once at startup via RegisterCustomValidators makes the currency and sku tags usable across every struct in the app. Doing the type assertion to *validator.Validate is necessary because Engine() returns any.
In order_handler.go, CreateOrder calls c.ShouldBindJSON, which both decodes the JSON and runs validation in one step. On failure the error is a validator.ValidationErrors, which the handler unpacks into a field-keyed map so the client gets actionable messages instead of an opaque string. This separation keeps binding declarative in the DTO while translation of errors stays in the handler.
A pitfall worth noting: custom tags must be registered before any request is bound, and dive only works on slices/maps — forgetting it silently skips per-element checks. The trade-off is that validation logic is spread across tags and registered functions, which is concise but can be harder to unit test than explicit imperative checks.
Related snips
package api
import (
"net/http"
"runtime/debug"
)
Expose build metadata for debugging deploys
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
package files
import (
"context"
"time"
Presigned S3 upload URLs (AWS SDK v2)
package deps
import (
"net"
"net/http"
"time"
HTTP client tuned for production: timeouts, transport, and connection reuse
Share this code
Here's the card — post it anywhere.