Validation #
Validation is the first line of defense before data touches business logic and the database. But in practice, validation is often treated as an afterthought — added after a feature is done, or only covering the most obvious cases. As a result, bugs that should have been caught at the validation layer slip into the database as anomalous data, or worse, get exploited as security holes. This article covers validation thoroughly: the seven validation layers that should exist, why client-side validation is never enough, how to build validation that’s separate and testable, and which anti-patterns are often the root of the problems.
Why Validation Is More Than Just “Required Fields” #
When developers talk about validation, what often comes to mind is only checking whether required fields are filled or whether the email format is valid. But comprehensive validation covers seven different layers, each protecting something different.
flowchart TD
Input["Input from Client"]
L1["Structural Validation\nIs the data shape correct?\n(fields exist, types match)"]
L2["Format Validation\nDoes the format match?\n(email, UUID, dates)"]
L3["Length & Size Validation\nIs the size reasonable?\n(max length, max array size)"]
L4["Value Constraint Validation\nIs the value within allowed ranges?\n(enums, number ranges)"]
L5["Business / Semantic Validation\nDoes it make domain sense?\n(sufficient balance, no date overlap)"]
L6["Authorization Validation\nIs the user allowed to do this?\n(ownership, role checks)"]
L7["Contextual Validation\nIs it valid in this context?\n(conditional required fields)"]
BL["Business Logic"]
DB["Database"]
Input --> L1
L1 --> L2
L2 --> L3
L3 --> L4
L4 --> L5
L5 --> L6
L6 --> L7
L7 --> BL
BL --> DB
style L1 fill:#2980B9,color:#fff
style L2 fill:#27AE60,color:#fff
style L3 fill:#F39C12,color:#fff
style L4 fill:#E67E22,color:#fff
style L5 fill:#E74C3C,color:#fff
style L6 fill:#8E44AD,color:#fff
style L7 fill:#2C3E50,color:#fffLayer 1 — Structural Validation #
Structural validation is the most fundamental: does the incoming data have the correct shape? This includes the presence of required fields, matching data types, and valid JSON structure.
// ANTI-PATTERN: No structural validation
func CreateOrder(r *http.Request) {
var order Order
json.NewDecoder(r.Body).Decode(&order) // doesn't check whether decoding succeeded
// order.Items can be nil, order.UserID can be a zero value
db.CreateOrder(order) // incomplete data enters the DB
}
// CORRECT: Structural validation with strict decoding
type CreateOrderInput struct {
Items []OrderItem `json:"items"`
Address string `json:"address"`
}
func CreateOrder(r *http.Request) {
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields() // reject unknown fields
var input CreateOrderInput
if err := decoder.Decode(&input); err != nil {
respondError(w, 400, "FORMAT_ERROR", "Invalid request format")
return
}
// Continue to the next validation
}
DisallowUnknownFields() is one line that’s often forgotten but very valuable. It rejects requests containing unknown fields, which is the first defense against mass assignment attacks.
Layer 2 — Format Validation #
Format validation ensures given values follow the expected pattern. This isn’t about business correctness — [email protected] may be format-valid but have no owner. Format validation only ensures the structure makes sense.
// Examples of common format validation rules:
type RegisterInput struct {
Email string `json:"email" validate:"required,email"`
Phone string `json:"phone" validate:"required,e164"` // +628xxx
BirthDate string `json:"birthDate" validate:"required,datetime=2006-01-02"`
ProductID string `json:"productId" validate:"required,uuid4"`
Website string `json:"website" validate:"omitempty,url"`
}
Formats needing careful validation:
Email:
✓ Check the basic format (has @, has a domain)
✗ Don't use super complex regexes — many false positives
✗ Don't check whether the email is "truly valid" here — that's email verification's job
Phone:
✓ E.164 format: +628****6789
✓ Or an agreed local format
✗ Don't hardcode country-specific regexes — not scalable
UUID:
✓ UUID v4 format: 8-4-4-4-12 hex with the correct version byte
✗ Don't just check "is it 36 characters long"
Date/Time:
✓ ISO 8601: 2024-01-27 or 2024-01-27T14:32:00Z
✗ Don't accept ambiguous formats (01/02/03 — which is month/day/year?)
Layer 3 — Length and Size Validation #
Size validation protects the system from overly large data — both deliberate (abuse) and accidental (users pasting long articles into name fields).
Fields that must have size limits:
String fields:
Username: max 100 characters
Product description: max 2000 characters
Titles: max 200 characters
Messages/comments: max 5000 characters
Password: min 8, max 128 characters (max password matters — bcrypt is expensive for long strings)
Array fields:
Cart items: max 100 items
Tags: max 20 tags
Batch inserts: max 1000 items
Request bodies:
Total body size: match your needs, 1-10 MB by default
File uploads:
Per file: as needed (profile photos: 5 MB, documents: 20 MB)
Total per request: a reasonable limit
// ANTI-PATTERN: No size limits on arrays
func BulkCreateUsers(users []User) {
// users can contain 1 million items
// This will exhaust memory and crash the server
for _, u := range users {
db.CreateUser(u)
}
}
// CORRECT: Explicit limits
func BulkCreateUsers(users []User) error {
if len(users) == 0 {
return ErrEmptyInput
}
if len(users) > 500 {
return ErrBatchTooLarge // "Maximum 500 items per request"
}
// ...
}
Capping passwords at 128 characters is a best practice that often surprises people. Without a cap, attackers can send a 1-million-character password that makes bcrypt work for a very long time (bcrypt is O(n)), causing CPU spikes and DoS. A 128-character cap is more than enough for secure passwords.
Layer 4 — Value Constraint Validation #
Value constraints ensure values are within the ranges or sets allowed by the system.
// Enum validation — only defined values are accepted
type OrderStatus string
const (
OrderStatusPending OrderStatus = "pending"
OrderStatusConfirmed OrderStatus = "confirmed"
OrderStatusShipped OrderStatus = "shipped"
OrderStatusDelivered OrderStatus = "delivered"
OrderStatusCancelled OrderStatus = "cancelled"
)
func (s OrderStatus) IsValid() bool {
switch s {
case OrderStatusPending, OrderStatusConfirmed,
OrderStatusShipped, OrderStatusDelivered, OrderStatusCancelled:
return true
}
return false
}
// Range validation
type CreateProductInput struct {
Price float64 `validate:"required,min=0.01,max=999999999"`
Quantity int `validate:"required,min=0,max=100000"`
Discount float64 `validate:"min=0,max=100"` // percentage: 0-100
}
Examples of often-needed value constraints:
Price: min=0.01 (must not be zero or negative)
Percentage: min=0, max=100
Rating: min=1, max=5, must be an integer
Year: min=1900, max=2100
Latitude: min=-90, max=90
Longitude: min=-180, max=180
Status: a defined enum (not free-form strings)
Layer 5 — Business Validation #
Business validation can’t be completed just by looking at the input data — it needs domain context and the system’s current state.
Characteristics of business validation:
→ Requires database queries or calls to other services
→ Depends on existing state (balance, stock, schedules)
→ Reflects business domain rules
→ Can't be represented as stateless rules
Examples:
✓ "Email must not already be registered" → check the DB
✓ "Balance must not be less than the transfer total" → check the user's balance
✓ "Stock must be available" → check inventory
✓ "Schedules must not overlap" → check existing bookings
✓ "Coupon must still be valid" → check expiry and usage count
// Example of business validation separated from business logic
type CreateOrderValidator struct {
inventoryRepo InventoryRepository
userRepo UserRepository
}
func (v *CreateOrderValidator) Validate(input CreateOrderInput, userID string) error {
// Validate stock for every item
for _, item := range input.Items {
stock, err := v.inventoryRepo.GetStock(item.ProductID)
if err != nil {
return ErrInternal
}
if stock < item.Quantity {
return &ValidationError{
Field: "items." + item.ProductID,
Code: "INSUFFICIENT_STOCK",
Message: fmt.Sprintf("Insufficient stock for product %s", item.ProductID),
}
}
}
// Validate the shipping address
user, _ := v.userRepo.FindByID(userID)
if !user.HasShippingAddress() {
return &ValidationError{
Field: "address",
Code: "NO_SHIPPING_ADDRESS",
Message: "Add a shipping address first",
}
}
return nil
}
Layer 6 — Authorization Validation #
Authorization validation is often seen as separate from validation, but it’s one of the most critical layers. It asks: “Does the user sending this request have the right to perform this operation on this resource?”
// ANTI-PATTERN: No authorization validation
func UpdateOrder(r *http.Request) {
orderId := r.PathValue("id")
user := getAuthenticatedUser(r)
order, _ := db.GetOrder(orderId)
// Doesn't check whether the order belongs to the user!
order.Status = input.Status
db.UpdateOrder(order)
}
// User A can update User B's orders
// CORRECT: Explicit authorization validation
func UpdateOrder(r *http.Request) {
orderId := r.PathValue("id")
user := getAuthenticatedUser(r)
// Authorization validation: make sure the order belongs to this user
order, err := db.GetOrderByIDAndUserID(orderId, user.ID)
if err != nil || order == nil {
// Return 404, not 403 — don't confirm that the resource exists
respondError(w, 404, "NOT_FOUND", "Order not found")
return
}
// State validation: can the order be updated in this status?
if !order.CanBeUpdated() {
respondError(w, 422, "INVALID_STATE", "Order can't be updated in status " + order.Status)
return
}
// Continue with the update
}
Layer 7 — Contextual Validation #
Contextual validation depends on combinations of fields or the state of other requests. A field optional in one condition can become required in another.
// Example: reason is required when the status is "rejected"
type UpdateApplicationInput struct {
Status string `json:"status" validate:"required,oneof=approved rejected pending"`
Reason *string `json:"reason"` // generally optional
}
func (i *UpdateApplicationInput) Validate() error {
// Contextual validation: reason required if status is rejected
if i.Status == "rejected" && (i.Reason == nil || *i.Reason == "") {
return &ValidationError{
Field: "reason",
Code: "REQUIRED_WHEN_REJECTED",
Message: "A rejection reason is required",
}
}
// Contextual validation: reason is irrelevant if status is approved
if i.Status == "approved" && i.Reason != nil {
// May be ignored or returned as a warning
i.Reason = nil
}
return nil
}
Common contextual validation patterns:
→ Field A is required if Field B has value X
→ Field C must not exist if the status is Y
→ End date must be after the start date
→ If payment method = "bank_transfer", bank_account is required
→ If type = "recurring", frequency must be filled
Client-Side vs Server-Side Validation #
This is a very important and often misunderstood difference.
flowchart LR
subgraph Client["Client Side"]
CInput["User Input"]
CV["Client Validation\n(JavaScript, HTML5)"]
CInput --> CV
CV -->|"Invalid"| CError["UI errors\n(UX, no round-trip needed)"]
end
subgraph Server["Server Side"]
SInput["Request from Client"]
SV["Server Validation\n(Single Source of Truth)"]
SInput --> SV
SV -->|"Invalid"| SError["422 Response\n(Canonical, must exist)"]
SV -->|"Valid"| BL["Business Logic"]
end
CV -->|"Valid (via HTTP)"| SInputClient-side validation:
Purpose: UX — quick feedback without round-trips to the server
Pros: Instant, no network needed
Cons: Can be disabled, manipulated, bypassed
Status: NOT RELIABLE for security
Server-side validation:
Purpose: Security, data integrity, consistency
Pros: Can't be bypassed, single source of truth
Cons: Needs a round-trip to the server
Status: MANDATORY, not optional
Conclusion:
Use both — client-side for UX, server-side for security.
If you must choose one: server-side always matters more.
Validation Error Response Formats #
Error format consistency is critical — frontends and API consumers need a predictable format to display errors correctly.
// HTTP 422 Unprocessable Entity response for validation errors
// Example 1: Single field error
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input",
"details": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Invalid email format"
}
]
}
}
// Example 2: Multiple field errors (collect all, don't stop at the first error)
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input",
"details": [
{
"field": "email",
"code": "REQUIRED",
"message": "Email is required"
},
{
"field": "phone",
"code": "INVALID_FORMAT",
"message": "Invalid phone number format"
},
{
"field": "birthDate",
"code": "UNDERAGE",
"message": "Minimum age is 17 to register"
}
]
}
}
// Example 3: Nested field errors
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input",
"details": [
{
"field": "items[0].quantity",
"code": "MIN_VALUE",
"message": "The minimum quantity is 1"
},
{
"field": "items[2].productId",
"code": "NOT_FOUND",
"message": "Product not found"
}
]
}
}
Collect all errors before returning the response — don’t stop at the first error. Users should be able to fix all problems at once, not one by one after repeated submissions.
Separating Validation from Business Logic #
One of the most common anti-patterns is validation mixed together with business logic in a single function. This makes the code hard to test, hard to read, and hard to reuse.
// ANTI-PATTERN: Validation mixed with business logic
func (s *OrderService) CreateOrder(input CreateOrderInput, userID string) (*Order, error) {
// Validation
if input.Address == "" {
return nil, errors.New("address required")
}
if len(input.Items) == 0 {
return nil, errors.New("items required")
}
// ... 50 more validation lines ...
// Business logic
order := &Order{
UserID: userID,
// ...
}
// Database operation
return s.repo.Create(order)
}
// Hard to test, can't reuse the validator elsewhere
// CORRECT: A separate validator
type CreateOrderValidator struct{ /* dependencies */ }
func (v *CreateOrderValidator) Validate(input CreateOrderInput, userID string) []ValidationError {
var errors []ValidationError
if input.Address == "" {
errors = append(errors, ValidationError{Field: "address", Code: "REQUIRED"})
}
// ... validation rules ...
return errors
}
// The service uses the validator separately
func (s *OrderService) CreateOrder(input CreateOrderInput, userID string) (*Order, error) {
if errs := s.validator.Validate(input, userID); len(errs) > 0 {
return nil, &ValidationErrors{Errors: errs}
}
// Pure business logic, no validation here
// ...
}
The benefits of this separation: validators can be unit tested without running business logic, can be reused elsewhere, and are easier to maintain when rules change.
Effective Validation Testing #
Validation without tests is validation that isn’t trusted. Tests for validation must cover not just the happy path but also edge cases and negative scenarios.
// Example of a comprehensive validator test
func TestCreateOrderValidator(t *testing.T) {
validator := NewCreateOrderValidator(mockInventory, mockUserRepo)
tests := []struct {
name string
input CreateOrderInput
userID string
expectError bool
errorCode string
}{
// Happy path
{
name: "valid order",
input: validOrderInput(),
userID: "usr_123",
expectError: false,
},
// Structural validation
{
name: "empty items",
input: CreateOrderInput{Items: []OrderItem{}},
userID: "usr_123",
expectError: true,
errorCode: "ITEMS_REQUIRED",
},
// Business validation
{
name: "insufficient stock",
input: CreateOrderInput{
Items: []OrderItem{{ProductID: "p1", Quantity: 1000}},
},
userID: "usr_123",
expectError: true,
errorCode: "INSUFFICIENT_STOCK",
},
// Authorization validation
{
name: "user without shipping address",
input: validOrderInput(),
userID: "usr_no_address",
expectError: true,
errorCode: "NO_SHIPPING_ADDRESS",
},
// Edge cases
{
name: "quantity at max limit",
input: CreateOrderInput{
Items: []OrderItem{{ProductID: "p1", Quantity: 100}},
},
userID: "usr_123",
expectError: false,
},
{
name: "quantity exceeds max limit",
input: CreateOrderInput{
Items: []OrderItem{{ProductID: "p1", Quantity: 101}},
},
userID: "usr_123",
expectError: true,
errorCode: "QUANTITY_EXCEEDS_LIMIT",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.Validate(tt.input, tt.userID)
if tt.expectError && len(errs) == 0 {
t.Errorf("expected error %s but got none", tt.errorCode)
}
if !tt.expectError && len(errs) > 0 {
t.Errorf("expected no error but got: %v", errs)
}
})
}
}
One test case per validation rule is a good pattern to start with. But don’t forget to also test combinations that could produce unexpected behavior — for example, what happens when interdependent fields are both empty, or both filled with conflicting values.
Validation Anti-Patterns to Avoid #
Client-Side-Only Validation #
// ✗ Anti-pattern: Trusting JavaScript validation
"We already validate on the frontend, no need to do it on the backend"
→ Chrome dev mode: network requests can be sent directly
→ Postman or curl: client-side validation doesn't apply
→ XSS attacks can inject form submissions that bypass validation
// ✓ Solution: Server-side validation always exists, client-side is a UX addition
Stopping at the First Error #
// ✗ Anti-pattern: Returning one error, forcing users to submit repeatedly
POST /register { email: "invalid", phone: "", birthDate: "not a date" }
Response: { "error": "Invalid email" }
User fixes the email, submits again...
Response: { "error": "Phone is required" }
User fixes the phone, submits again...
Response: { "error": "Invalid birth date" }
→ Users must submit 3 times to learn everything that's wrong
// ✓ Solution: Collect all errors and return them at once
Response: {
"error": {
"code": "VALIDATION_ERROR",
"details": [
{ "field": "email", "code": "INVALID_FORMAT" },
{ "field": "phone", "code": "REQUIRED" },
{ "field": "birthDate", "code": "INVALID_FORMAT" }
]
}
}
Validation Logic Scattered Everywhere #
// ✗ Anti-pattern: Rules scattered across controller, service, model
// In the controller:
if input.Email == "" { return error }
// In the service:
if input.Email == "" { return error } // duplicate!
// Also other checks in the service
// In the model:
// There's validation in the model too
// A rule change = updating 3 places
// Easy to forget one → inconsistency
// ✓ Solution: One place for validation logic
type RegisterValidator struct{}
func (v *RegisterValidator) Validate(input RegisterInput) []ValidationError { /* all rules here */ }
Error Messages Exposing Internal Details #
// ✗ Anti-pattern: Errors leaking sensitive info
{
"error": "UNIQUE constraint failed: users.email"
}
// Attackers learn: the database engine, table name, column name
// ✓ Solution: Informative but safe errors
{
"error": {
"code": "EMAIL_ALREADY_REGISTERED",
"message": "This email is already registered. Use the forgot password feature if you've forgotten your password."
}
}
Validation Checklist #
STRUCTURAL VALIDATION:
□ All required fields checked for presence
□ All field data types validated
□ Unknown fields rejected (DisallowUnknownFields or equivalent)
□ Malformed JSON handled with graceful errors
FORMAT VALIDATION:
□ Email validated with a library, not custom regexes
□ Phone numbers validated against an agreed format
□ UUIDs validated for proper format
□ Date/time only accepts standard formats (ISO 8601)
□ URLs validated if URL fields exist
SIZE VALIDATION:
□ All string fields have explicit max lengths
□ Array fields have explicit max counts
□ Request body size limited at the server level
□ Password max length exists (to prevent bcrypt DoS)
VALUE CONSTRAINTS:
□ Enum fields only accept defined values
□ Numbers have domain-appropriate min/max
□ Percentages, ratings, coordinates have proper ranges
BUSINESS VALIDATION:
□ Uniqueness constraints checked before inserts (not relying on DB errors)
□ State transitions validated (is A → B allowed?)
□ Referenced resources checked for existence
AUTHORIZATION VALIDATION:
□ Ownership validated — does the resource belong to the requesting user?
□ Roles/permissions validated per operation
□ Does the resource state allow the requested operation?
ERROR RESPONSES:
□ All errors collected before returning (no stop at the first)
□ Error format consistent across all endpoints
□ Field errors use explicit paths (items[0].quantity)
□ No database details or stack traces in error messages
□ Error codes machine-readable (REQUIRED, INVALID_FORMAT, etc.)
TESTING:
□ Unit tests for every validation rule
□ Edge case tests (boundary values, field combinations)
□ Negative scenario tests (input that should be rejected)
□ Integration tests for DB-dependent business validation
Summary #
- Validation is seven layers, not one — structural, format, size, value constraint, business, authorization, and contextual. Each protects a different aspect and can’t replace the others.
- Client-side validation is UX, not security — it can be disabled and bypassed. Server-side validation is the only thing reliable for security and data integrity.
- Collect all errors before returning — don’t stop validation at the first error. Users should be able to fix all problems at once.
- Separate validators from business logic — standalone validators can be unit tested, reused, and are easier to maintain when rules change.
DisallowUnknownFields is the first defense against mass assignment — one line that prevents unknown fields from entering the system.- Error formats must be consistent and machine-readable — use machine-readable
codes (REQUIRED, INVALID_FORMAT) and human-readable messages. Frontends need both. - Informative error messages without leaking internal details — “Email already registered” is informative. “UNIQUE constraint failed: users.email” is information disclosure.
- Authorization validation is part of validation — checking resource ownership and permissions is validation, not just a middleware concern.
- Size limits on every field — string max lengths, array max counts, body size limits. Without these, the system is vulnerable to memory-exhausting payloads.
- Test every possibly bad combination — validation tests must cover happy paths, edge cases, negative scenarios, and field combinations that could produce unexpected behavior.
#
- Validation is seven layers, not one — structural, format, size, value constraint, business, authorization, and contextual. Each protects a different aspect and can’t replace the others.
- Client-side validation is UX, not security — it can be disabled and bypassed. Server-side validation is the only thing reliable for security and data integrity.
- Collect all errors before returning — don’t stop validation at the first error. Users should be able to fix all problems at once.
- Separate validators from business logic — standalone validators can be unit tested, reused, and are easier to maintain when rules change.
DisallowUnknownFieldsis the first defense against mass assignment — one line that prevents unknown fields from entering the system.- Error formats must be consistent and machine-readable — use machine-readable
codes (REQUIRED, INVALID_FORMAT) and human-readablemessages. Frontends need both. - Informative error messages without leaking internal details — “Email already registered” is informative. “UNIQUE constraint failed: users.email” is information disclosure.
- Authorization validation is part of validation — checking resource ownership and permissions is validation, not just a middleware concern.
- Size limits on every field — string max lengths, array max counts, body size limits. Without these, the system is vulnerable to memory-exhausting payloads.
- Test every possibly bad combination — validation tests must cover happy paths, edge cases, negative scenarios, and field combinations that could produce unexpected behavior.