Overview

In our Go architecture, we use go-playground/validator for data validation, which is similar to Zod in TypeScript. Both provide declarative, type-safe validation with excellent error messages.

📊 Comparison: Zod vs Go Validator

FeatureZod (TypeScript)go-playground/validator (Go)
SyntaxFluent APIStruct tags
Type Safety✅ Yes✅ Yes (compile-time)
ValidationRuntimeRuntime
Error MessagesCustomizableCustomizable
Ecosystemnpm packageGo module
PerformanceGoodExcellent (compiled)

🎯 How It Works in Our Architecture

1. Define Request DTOs with Validation Tags

// TypeScript (Zod)
const RegisterSchema = z.object({
    email: z.string().email(),
    name: z.string().min(1),
    password: z.string().min(8),
    roles: z.array(z.string()).min(1)
})

// Go (Validator Tags)
type RegisterRequest struct {
    Email    string   `json:"email" validate:"required,email"`
    Name     string   `json:"name" validate:"required"`
    Password string   `json:"password" validate:"required,min=8"`
    Roles    []string `json:"roles" validate:"required,min=1"`
}

2. Validation Flow in HTTP Handler

func (h *AuthHandler) Register(c echo.Context) error {
    var req RegisterRequest

    // Step 1: Bind JSON to struct
    if err := c.Bind(&req); err != nil {
        return err // 400 Bad Request (malformed JSON)
    }

    // Step 2: Validate struct
    if err := c.Validate(&req); err != nil {
        return err // 400 Bad Request (validation failed)
    }

    // Step 3: Use validated data
    user, err := h.userService.Create(ctx, ports.CreateUserParams{
        Email:    req.Email,
        Name:     req.Name,
        Password: req.Password,
        Roles:    req.Roles,
    }, "system")

    return c.JSON(http.StatusCreated, user)
}

🏗️ Architecture Layers

Data validation flow

📝 Common Validation Tags

Basic Validations

type UserDTO struct {
    // Required field
    Email string `validate:"required"`

    // Email format
    Email string `validate:"required,email"`

    // String length
    Name string `validate:"required,min=2,max=100"`

    // Number range
    Age int `validate:"required,min=18,max=120"`

    // Array/Slice
    Roles []string `validate:"required,min=1,max=5"`

    // Enum (one of)
    Status string `validate:"required,oneof=active inactive pending"`

    // URL
    Website string `validate:"omitempty,url"`

    // UUID
    ID string `validate:"required,uuid"`

    // Custom regex
    Phone string `validate:"required,e164"` // E.164 phone format
}

Advanced Validations

type ProductDTO struct {
    // Conditional validation
    Price float64 `validate:"required_if=IsPaid true"`

    // Cross-field validation
    Password string `validate:"required,min=8"`
    ConfirmPassword string `validate:"required,eqfield=Password"`

    // Nested struct validation
    Address AddressDTO `validate:"required"`

    // Array of structs
    Items []ItemDTO `validate:"required,dive,required"`
}

type AddressDTO struct {
    Street  string `validate:"required"`
    City    string `validate:"required"`
    ZipCode string `validate:"required,numeric,len=5"`
}

🎨 Custom Validation

1. Register Custom Validator

// pkg/validator/validator.go
func New() *CustomValidator {
    v := validator.New()

    // Register custom validation
    v.RegisterValidation("strong_password", validateStrongPassword)

    return &CustomValidator{validator: v}
}

func validateStrongPassword(fl validator.FieldLevel) bool {
    password := fl.Field().String()

    // Must contain: uppercase, lowercase, number, special char
    hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
    hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
    hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
    hasSpecial := regexp.MustCompile(`[!@#$%^&*]`).MatchString(password)

    return hasUpper && hasLower && hasNumber && hasSpecial
}

2. Use Custom Validator

type RegisterRequest struct {
    Password string `validate:"required,min=8,strong_password"`
}

🚨 Error Handling

Current Implementation

// pkg/validator/validator.go
func (cv *CustomValidator) Validate(i interface{}) error {
    if err := cv.validator.Struct(i); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, err.Error())
    }
    return nil
}
func (cv *CustomValidator) Validate(i interface{}) error {
    if err := cv.validator.Struct(i); err != nil {
        // Parse validation errors
        validationErrors := err.(validator.ValidationErrors)

        // Build user-friendly error messages
        errors := make(map[string]string)
        for _, e := range validationErrors {
            field := e.Field()
            switch e.Tag() {
            case "required":
                errors[field] = fmt.Sprintf("%s is required", field)
            case "email":
                errors[field] = fmt.Sprintf("%s must be a valid email", field)
            case "min":
                errors[field] = fmt.Sprintf("%s must be at least %s characters", field, e.Param())
            default:
                errors[field] = fmt.Sprintf("%s is invalid", field)
            }
        }

        return echo.NewHTTPError(http.StatusBadRequest, errors)
    }
    return nil
}
Error Response:
{
  "message": {
    "Email": "Email must be a valid email",
    "Password": "Password must be at least 8 characters"
  }
}

🔄 Comparison with Your TypeScript Code

TypeScript (Hono + Zod)

// Define schema
const registerSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1),
  password: z.string().min(8),
  roles: z.array(z.string()).min(1),
});

// Validate in route
app.post("/register", async (c) => {
  const body = await c.req.json();
  const validated = registerSchema.parse(body); // Throws if invalid

  const user = await userService.create(validated);
  return c.json(user, 201);
});

Go (Echo + Validator)

// Define DTO
type RegisterRequest struct {
    Email    string   `json:"email" validate:"required,email"`
    Name     string   `json:"name" validate:"required"`
    Password string   `json:"password" validate:"required,min=8"`
    Roles    []string `json:"roles" validate:"required,min=1"`
}

// Validate in handler
func (h *AuthHandler) Register(c echo.Context) error {
    var req RegisterRequest
    if err := c.Bind(&req); err != nil {
        return err
    }
    if err := c.Validate(&req); err != nil {
        return err // Returns 400 if invalid
    }

    user, err := h.userService.Create(ctx, ports.CreateUserParams{
        Email:    req.Email,
        Name:     req.Name,
        Password: req.Password,
        Roles:    req.Roles,
    }, "system")

    return c.JSON(http.StatusCreated, user)
}

🎯 Best Practices

1. Separate DTOs from Domain Models

// ❌ Bad: Using domain model directly
func (h *Handler) Create(c echo.Context) error {
    var user domain.User // Domain model
    c.Bind(&user)
    c.Validate(&user)
}

// ✅ Good: Use separate DTO
func (h *Handler) Create(c echo.Context) error {
    var req CreateUserRequest // DTO
    c.Bind(&req)
    c.Validate(&req)

    // Map DTO to domain
    user := domain.NewUser(req.Email, req.Name, ...)
}

2. Layer-Specific Validation

// HTTP Layer: Format validation
type CreateProductRequest struct {
    Name  string  `validate:"required,min=3"`
    Price float64 `validate:"required,gt=0"`
}

// Service Layer: Business logic validation
func (s *ProductService) Create(params CreateProductParams) error {
    // Check if product name already exists
    existing, _ := s.repo.FindByName(params.Name)
    if existing != nil {
        return ErrProductNameExists
    }

    // Check if price is within allowed range
    if params.Price > 10000 {
        return ErrPriceTooHigh
    }
}

3. Validation Groups (Optional)

type User struct {
    Email    string `validate:"required,email"`
    Password string `validate:"required,min=8"` // Only for create
}

// For update, password is optional
type UpdateUserRequest struct {
    Email    string `validate:"omitempty,email"`
    Password string `validate:"omitempty,min=8"`
}

📚 Available Validation Tags

TagDescriptionExample
requiredField must be presentvalidate:"required"
emailValid email formatvalidate:"email"
minMinimum length/valuevalidate:"min=8"
maxMaximum length/valuevalidate:"max=100"
lenExact lengthvalidate:"len=10"
gtGreater thanvalidate:"gt=0"
gteGreater than or equalvalidate:"gte=18"
ltLess thanvalidate:"lt=100"
lteLess than or equalvalidate:"lte=120"
oneofOne of valuesvalidate:"oneof=red blue green"
urlValid URLvalidate:"url"
uuidValid UUIDvalidate:"uuid"
numericNumeric stringvalidate:"numeric"
alphaAlphabetic onlyvalidate:"alpha"
alphanumAlphanumeric onlyvalidate:"alphanum"
e164E.164 phone formatvalidate:"e164"
omitemptySkip if emptyvalidate:"omitempty,email"
Full list: https://pkg.go.dev/github.com/go-playground/validator/v10