DEV Community

Cover image for Why I Ditched Struct Tags for Schema-Driven JSON Validation in Go
Ashbeel Ghouri
Ashbeel Ghouri

Posted on • Originally published at jsonschematics.ashbeelghouri.com

Why I Ditched Struct Tags for Schema-Driven JSON Validation in Go

Why I Ditched Struct Tags for Schema-Driven JSON Validation in Go

Three years ago, I inherited a microservices platform handling millions of marketplace transactions. Everything worked fine until the day we had to accept third-party JSON payloads we didn't control.

That's when struct tags broke.

The Struct Tag Problem

When you're building an API that owns the request shape, struct tags are great:

type CreateUserRequest struct {
    Name     string `json:"name" validate:"required,min=2,max=100"`
    Email    string `json:"email" validate:"required,email"`
    Age      int    `json:"age" validate:"required,min=18,max=120"`
    Phone    string `json:"phone" validate:"omitempty,phone"`
}

func CreateUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    json.NewDecoder(r.Body).Decode(&req)

    // Validation happens via struct tags + reflection
    if err := validate.Struct(req); err != nil {
        w.WriteHeader(http.StatusBadRequest)
        json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
    }
    // ... continue
}
Enter fullscreen mode Exit fullscreen mode

But then you hit these walls:

1. Rules Locked at Compile Time

Want to change the minimum age from 18 to 21? Redeploy.

Want to make phone optional only on weekends? That's not a struct tag problem—that's an architecture problem.

// Can't do this dynamically
// Age validation is baked into the struct
type User struct {
    Age int `validate:"min=18"` // Hardcoded at compile time
}
Enter fullscreen mode Exit fullscreen mode

2. Every New Field = New Struct

We worked with a marketplace that sent different payloads for sellers vs. buyers. So we built:

type BuyerPayload struct { /* 15 fields */ }
type SellerPayload struct { /* 25 fields */ }
type AdminPayload struct { /* 40 fields */ }
// And when they change their API? Rebuild all three.
Enter fullscreen mode Exit fullscreen mode

Three structs. Three deploy cycles. Three reasons for a bug.

3. Nested Validation Gets Ugly

A seller payload with nested address, tax_info, and payout_methods:

type SellerPayload struct {
    Name string `validate:"required,min=2"`
    Address struct {
        Street string `validate:"required"`
        City   string `validate:"required"`
        Zip    string `validate:"required,regex=^\d{5}$"`
    } `validate:"required"`
    TaxInfo struct {
        SSN string `validate:"required,ssn"`
        // ... five more fields
    } `validate:"required"`
}
Enter fullscreen mode Exit fullscreen mode

The moment you have 3-4 levels of nesting, your struct becomes a wall of text. And when validation logic is conditional (validate SSN only if TaxInfo.Type == "individual"), struct tags can't express it.

4. Panics on Unexpected Types

The worst part: reflection-heavy validators panic on types they don't expect.

type Order struct {
    Items []Item `validate:"required"`
}

// If a malicious request sends {"items": "not an array"},
// some validators panic instead of returning a typed error.
// That takes down your entire handler.
Enter fullscreen mode Exit fullscreen mode

The Real Problem

Struct tags assume your JSON is well-behaved. They work if:

  • You control the shape
  • Rules are static
  • Structure is known at compile time
  • You can afford a redeploy to change a rule

None of that was true for us.

The Schema-Driven Alternative

Two years into shipping the marketplace platform, I realized: what if rules were data, not code?

Instead of embedding validation in a struct, write a schema file:

{
  "version": "2.0",
  "fields": [
    {
      "target": "user.profile.name",
      "type": "string",
      "required": true,
      "validate": [
        { "rule": "minLength", "args": { "min": 2 } },
        { "rule": "maxLength", "args": { "max": 100 } }
      ]
    },
    {
      "target": "user.profile.email",
      "required": true,
      "validate": [
        { "rule": "email" }
      ]
    },
    {
      "target": "user.profile.age",
      "validate": [
        { "rule": "min", "args": { "min": 18 } },
        { "rule": "max", "args": { "max": 120 } }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Now your Go code is dead simple:

func CreateUser(w http.ResponseWriter, r *http.Request) {
    var data map[string]any
    json.NewDecoder(r.Body).Decode(&data)

    if err := schema.Validate(data); err != nil {
        var ve *schematics.ValidationErrors
        if errors.As(err, &ve) {
            w.WriteHeader(http.StatusBadRequest)
            json.NewEncoder(w).Encode(map[string]any{
                "errors": ve.Strings("en", "%target: %message"),
            })
        }
    }
    // ... continue
}
Enter fullscreen mode Exit fullscreen mode

What changed:

1. Rules Are Now Data

Update the schema file, reload it at startup. No redeploy needed for rule changes.

# Monday: min age is 18
# Wednesday: client needs it to be 21
# Just edit the JSON, restart the service (or hot-reload via config system)
Enter fullscreen mode Exit fullscreen mode

2. One Schema, Many Shapes

Instead of BuyerPayload, SellerPayload, AdminPayload, one schema can validate all three. Use wildcards or regex to match different structures:

{
  "fields": [
    {
      "target": "*.profile.email",  // Match email at any nesting level
      "validate": [{ "rule": "email" }]
    },
    {
      "targetRegex": true,
      "target": "^address\\.(street|city|zip)$",  // Regex targeting
      "required": true
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

3. Conditional Validation

Validate SSN only if the payload declares type="individual":

{
  "target": "tax_info.ssn",
  "validate": [{ "rule": "ssn" }],
  "when": [
    {
      "condition": "fieldEquals",
      "args": { "field": "tax_info.type", "value": "individual" }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

4. Typed Errors, Never Panics

Every validation error is a ValidationError with the target, rule, value, and localized message:

var ve *schematics.ValidationErrors
if errors.As(err, &ve) {
    for _, e := range ve.Errors {
        fmt.Printf("Field: %s, Rule: %s, Value: %v\n", 
            e.Target, e.Rule, e.Value)
        fmt.Printf("Message: %s\n", e.Message("en"))
    }
}
Enter fullscreen mode Exit fullscreen mode

No panics. No reflection surprises. Ever.

How It Works: The Pipeline

Here's what happens under the hood:

Input JSON
    ↓
[Flatten] → user.profile.name, user.profile.age, tags.0, tags.1
    ↓
[Match] → Map each flattened key to schema fields (literal, wildcard, regex)
    ↓
[Validate] → Run validators in order. First failure stops that field.
    ↓
[Operate] → (Optional) Transform: trim, capitalize, math, reshape
    ↓
Output: Valid data or ValidationErrors
Enter fullscreen mode Exit fullscreen mode

Example:

data := map[string]any{
    "user": map[string]any{
        "profile": map[string]any{
            "name": "  ada  ",  // Extra spaces
            "age":  200,        // Invalid
            "email": "ada@rvbc.io",
        },
        "tags": []string{"go", "rust"},
    },
}

s := schematics.New()
s.LoadFile("schema.json")

if err := s.Validate(data); err != nil {
    var ve *schematics.ValidationErrors
    if errors.As(err, &ve) {
        for _, msg := range ve.Strings("en", "%target: %message") {
            fmt.Println(msg)
        }
        // Output:
        // user.profile.age: must be between 0 and 120
    }
}

// Optionally transform
transformed, _ := s.Operate(data)
// transformed["user"]["profile"]["name"] == "Ada" (trimmed + capitalized)
Enter fullscreen mode Exit fullscreen mode

Real-World Win: The CSV Importer

This is where schema-driven validation really shone for us.

We built a bulk CSV importer for seller onboarding. Each row is a seller record. Each row can have a different structure (some have tax info, some don't; some have multiple addresses, some one).

With struct tags, we'd need:

// Option 1: Loose struct that accepts everything
type SellerRow struct {
    Data map[string]any // Lose all type safety
}

// Option 2: Strict struct that mirrors CSV
type SellerRow struct {
    Name        string
    Email       string
    TaxInfoSSN  string // Sparse columns
    Address1    string
    Address2    string
    // ... 20 optional fields
}
Enter fullscreen mode Exit fullscreen mode

Neither works. We went with schema-driven, loaded a schema that could handle 20+ optional fields with conditional validation:

{
  "fields": [
    {
      "target": "name",
      "required": true,
      "validate": [{ "rule": "minLength", "args": { "min": 2 } }]
    },
    {
      "target": "email",
      "required": true,
      "validate": [{ "rule": "email" }]
    },
    {
      "target": "tax_info.ssn",
      "validate": [{ "rule": "ssn" }],
      "when": [
        {
          "condition": "fieldPresent",
          "args": { "field": "tax_info" }
        }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

One schema. Hundreds of rows. Partial data OK. Conditional rules. No structs needed.

The Trade-Offs

I won't pretend this is a silver bullet.

Struct tags are still better for:

  • Small, tightly-typed internal APIs (you control the shape, rules are stable)
  • When you need compile-time guarantees
  • Microservices with one consumer

Schema-driven is better for:

  • Third-party integrations (you don't control the shape)
  • CSV/bulk imports (sparse, variable structure)
  • Rules that change without redeploy
  • Multiple payload shapes
  • Marketplace/multi-tenant platforms

Getting Started

I open-sourced this as json-schematics-v2. Zero dependencies, standard library only, typed errors, never panics.

Try it now:

go get github.com/ashbeelghouri/json-schematics-v2@latest
Enter fullscreen mode Exit fullscreen mode

Interactive playground:
https://jsonschematics.ashbeelghouri.com/playground

Paste a JSON document, paste a schema, see validation errors live.

Documentation:
https://jsonschematics.ashbeelghouri.com/docs

Code example:

package main

import (
    "fmt"
    schematics "github.com/ashbeelghouri/json-schematics-v2"
)

func main() {
    s := schematics.New()
    s.LoadFile("schema.json")

    data := map[string]any{
        "user": map[string]any{
            "profile": map[string]any{"name": "ada", "age": 30},
        },
    }

    if err := s.Validate(data); err != nil {
        fmt.Println("Validation failed:", err)
    } else {
        fmt.Println("Valid!")
    }
}
Enter fullscreen mode Exit fullscreen mode

Why Zero Dependencies Matter

Most Go validators pull in external packages. json-schematics-v2 is standard library only:

  • No dependency hell (you control versions)
  • No surprise breaking changes in gojsonschema
  • Smaller binary
  • Auditable surface (read the source)
  • No type panics from reflection

What's Next

I've been shipping this in production for:

  • API validation (headers, query, body)
  • CSV imports
  • Data migrations
  • Form validation
  • Event payloads

The library is battle-tested. But I'm always looking for feedback: edge cases, performance issues, feature requests.

Call to Action

  1. Try the playground: https://jsonschematics.ashbeelghouri.com/playground
  2. Star the repo: https://github.com/ashbeelghouri/json-schematics-v2
  3. Share feedback: Open an issue or DM me on GitHub

If you're tired of struct tag limitations, or shipping APIs that need to handle shapes you don't control, this might be the tool you've been looking for.

Top comments (3)

Collapse
 
marcello_h profile image
Marcelloh

I like the idea but the thing I have against it (the same as with the json tags):
I think I will have a validation written in code, so you can't make any typos.
What happens if "target": "name" is typoed into "target": "mane",
or "tagret": "name"
does it find out during "compilation" ?

Collapse
 
ashbeel_ghouri_2b1ec3f8b6 profile image
Ashbeel Ghouri

Excellent catch. You've identified the exact weakness of schema-driven validation, and you're right to push back.

I've just added ValidateSchema() to address this. Here's how it works:

The Problem You Identified

{
  "target": "mane"  // Typo! Should be "name"
}
Enter fullscreen mode Exit fullscreen mode

With struct tags, the compiler catches this. With schemas, it silently doesn't validate anything. Your code keeps running with invalid data slipping through.

The Solution: Schema Validation at Load Time

I added a method that catches typos and misconfigurations before validation runs:

s := schematics.New()
s.LoadFile("schema.json")

// Check the schema itself
if err := s.ValidateSchema(sampleData); err != nil {
    log.Fatal(err)
}
Enter fullscreen mode Exit fullscreen mode

What ValidateSchema() Catches

1. Typos in target names

target "mane" doesn't match any key in sample data. Did you mean "name"?
Enter fullscreen mode Exit fullscreen mode

2. Unregistered rules

rule "emial" not found. Did you mean "email"?
Enter fullscreen mode Exit fullscreen mode

3. Broken regex patterns

targetRegex "^[invalid(regex" is invalid: error parsing regexp
Enter fullscreen mode Exit fullscreen mode

4. Unused fields in schema

field "deprecated_field" exists in schema but not in any sample payload
Enter fullscreen mode Exit fullscreen mode

How to Use It

In Your Tests (Recommended)

func TestSchemaIsValid(t *testing.T) {
    s := schematics.New()
    s.LoadFile("schema.json")

    // Load sample payloads that represent real data
    samplePayloads := []map[string]any{
        loadSampleBuyerPayload(),
        loadSampleSellerPayload(),
    }

    for i, sample := range samplePayloads {
        if err := s.ValidateSchema(sample); err != nil {
            t.Fatalf("Schema validation failed on sample %d: %v", i, err)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Run this in CI. If someone typos "target": "mane", this test fails immediately—before it goes to production.

In Your Startup Code (For Safety)

func init() {
    s := schematics.New()
    s.LoadFile("schema.json")

    // Validate schema on startup
    // Use at least one representative payload
    if err := s.ValidateSchema(loadSamplePayload()); err != nil {
        log.Fatal("Schema has issues:", err)
    }

    validator = s
}
Enter fullscreen mode Exit fullscreen mode

The Trade-Off (Honest Take)

Struct Tags:

  • ✅ Typos caught at compile time
  • ✅ IDE autocomplete
  • ❌ Rules locked in code
  • ❌ Can't change without redeploy

Schemas + ValidateSchema():

  • ✅ Rules as data (change without redeploy)
  • ✅ One schema, many shapes
  • ✅ Typos caught at load/test time (not runtime)
  • ❌ Requires sample payloads in tests
  • ❌ Not compile-time verification

The key difference: you shift left to test time instead of compile time. But it catches the same problems, just earlier in your development cycle instead of in production.

When ValidateSchema() Saves You

Scenario 1: You update your schema file to add a new rule

{
  "target": "seller.tax_id",
  "validate": [
    {"rule": "ssn"}  // Typo: should be "ssn_format"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Your tests catch it:

TestSchemaIsValid: rule "ssn" not found. Did you mean "ssn_format"?
Enter fullscreen mode Exit fullscreen mode

Scenario 2: Your API changes shape, schema becomes stale

{
  "target": "payment.method",  // This field was renamed to "payment_method"
  "validate": [...]
}
Enter fullscreen mode Exit fullscreen mode

ValidateSchema(sampleData) catches it:

field "payment.method" doesn't match any key. Missing from your sample data?
Enter fullscreen mode Exit fullscreen mode

Try It Now

Just updated the repo: github.com/ashbeelghouri/json-sche...

go get -u github.com/ashbeelghouri/json-schematics-v2@latest
Enter fullscreen mode Exit fullscreen mode

Add a test to your suite:

func TestSchemaConfiguration(t *testing.T) {
    s := schematics.New()
    s.LoadFile("testdata/schema.json")

    samplePayload := map[string]any{
        "user": map[string]any{
            "name": "ada",
            "email": "ada@example.com",
        },
    }

    if err := s.ValidateSchema(samplePayload); err != nil {
        t.Fatal(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Run it: go test -v


The Bigger Picture

Your comment actually highlights why schema-driven validation requires discipline:

  • ✅ Write tests with representative payloads
  • ✅ Run ValidateSchema() in CI
  • ✅ Treat schema files like code (reviews, testing)
  • ✅ Document your schema structure

Do this, and you get the benefits of schemas without losing the safety of struct tags.


Thank you for pushing on this. This was a genuine gap, and ValidateSchema() makes the library much safer. Community feedback like yours is why open source gets better.

Let me know if this solves your concern, or if there are other edge cases you'd like me to handle.

Collapse
 
marcello_h profile image
Marcelloh

I'm glad you did something about it immediately. Because now this makes much more sense. I have about the same idea in my own private solution ;-) I can see if I can swap it out with yours (after my holidays).