A schema is only useful if it actually catches bad payloads before they reach your handler. Most teams write the schema, then forget it during local development, or only enforce it in CI on a handful of fixtures. That gap is where the 2 a.m. incident starts. This walkthrough is about the boring, repeatable discipline of validating JSON at the boundary — in your editor, in a pre-commit hook, and in a staging smoke test — using a schema as the single source of truth.
I'll focus on the practical workflow: where validation belongs, what to do when a payload is "almost right," and how to keep the schema from rotting as the service evolves. The example payload is a customer order, because it has the usual suspects — optional fields, nested objects, arrays, and one enum that someone will eventually forget to update.
Why Boundary Validation Is a Different Problem From Unit Tests
A unit test with five hand-written fixtures tells you your code parses the happy path. It does not tell you what happens when the upstream system sends customer.country as null because their form crashed, or when a new required field ships and the old client still hits your endpoint.
Schema validation lives at a different layer than unit tests. It asks: given any string that looks like JSON, is this something I'm willing to process? That question gets asked three places:
- In the editor or a manual check, when you're reasoning about a sample payload you pulled from logs.
- In CI, against a corpus of real-ish fixtures.
- At runtime, when the service exposes an endpoint that takes arbitrary JSON.
The first two are cheap insurance. The third is where most teams either over-invest (re-validating inside hot paths) or under-invest (skipping it entirely because "the schema is in the OpenAPI spec, surely something checks it"). Neither extreme holds up.
For the manual step, a quick formatter with a validation mode is enough to catch typos and shape errors before you paste a payload anywhere. The deeper walkthrough covers a readable, validator-friendly formatting approach in How to Format JSON for Readability and Debugging — useful when you're trying to eyeball a payload and spot the field that's missing a closing quote.
The Schema: One Source of Truth, Kept Boring
Start with a JSON Schema, not a hand-rolled validator. The JSON Schema 2020-12 specification gives you a vocabulary that lints, IDEs, and CI tools all speak. A small order schema looks like this:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["orderId", "customer", "lines", "currency"],
"properties": {
"orderId": { "type": "string", "pattern": "^ord_[A-Za-z0-9]{8,}$" },
"customer": { "$ref": "#/$defs/customer" },
"lines": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/line" } },
"currency": { "type": "string", "enum": ["USD", "EUR", "GBP", "JPY"] },
"placedAt": { "type": "string", "format": "date-time" }
},
"$defs": {
"customer": {
"type": "object",
"required": ["id", "email"],
"properties": {
"id": { "type": "string" },
"email": { "type": "string", "format": "email" },
"country": { "type": "string", "minLength": 2, "maxLength": 2 }
}
},
"line": {
"type": "object",
"required": ["sku", "qty"],
"properties": {
"sku": { "type": "string" },
"qty": { "type": "integer", "minimum": 1, "maximum": 1000 },
"note": { "type": "string" }
}
}
}
}
A few decisions in there are deliberate. The orderId regex forces upstream to use the new prefix. The currency enum is closed: adding a code is a schema change, which forces a PR. country is bounded to two characters because past data has shown three-letter codes slipping in from a CSV importer.
Once the schema exists, every other step is mechanical.
A Validation Workflow You Can Actually Maintain
Four steps, ordered from cheapest to most expensive.
1. Validate the fixture you have in front of you
When a payload comes out of a log snippet or a support ticket, paste it through a formatter that runs validation. The goal isn't to replace CI — it's to fail fast while you're still looking at the input. A typo in a field name, a trailing comma, or a string where an integer is expected should be obvious in seconds, not after you redeploy.
If you're working through a batch of older payloads to see whether they're still valid (the "did the schema drift past our stored data?" question), the same step scales: paste each file in, fix what breaks, commit the corrected version. This is how you turn a one-off audit into a regenerable fixture set.
2. Add a pre-commit hook for schema-bearing files
If your schema lives in the repo, contributors can break it without noticing. A hook that validates any modified .json file against the project's schema runs in under a second and blocks the obvious mistakes: required field deleted, enum value added without realizing it would fail validation, type widened to something the schema doesn't accept.
The hook should be lenient about files it can't classify. The cost of a hook that complains about an unrelated package-lock.json is that people delete the hook.
3. Run the schema against your fixture corpus in CI
This is where drift gets caught on a schedule rather than at deploy time. A test suite of "every payload we know about, validated against current schema" is a small investment with an outsized payoff. Include:
- A handful of golden payloads that should always pass.
- A handful of deliberately-broken payloads that should always fail with specific error paths — these are your regression tests for the validator itself.
- One fuzzed payload per merge, generated by mutating a golden file, to catch assumptions nobody wrote down.
The CI step should fail loudly and produce the exact JSON Pointer to the offending node. Engineers ignore green checkmarks but fix red ones with paths.
4. Validate at the boundary, not in the handler
For services that accept arbitrary JSON from outside your trust boundary — webhooks, partner APIs, anything a customer POSTs — re-validate the parsed payload one more time before it reaches business logic. The cost is microseconds; the benefit is that a malformed payload produces a 400 with a useful message instead of a 500 from a downstream nil dereference.
Critically, don't validate inside hot paths your own code already produces. If Service A calls Service B over HTTP with a payload it serialized itself, you've already validated it once. Validating again is cargo-culting — and worse, it tempts teams to "fix" the schema to match the producer, which is how schemas rot.
For the parsing edge case, the JSON specification (RFC 8259) is the arbiter when something disagrees about what counts as valid JSON in the first place.
Common Pitfalls, and Why Teams Accept Them Anyway
- "We'll get to it next sprint." Validation gets pushed behind feature work because broken payloads are rare — until they aren't. A 2 a.m. page about a single bad row from a partner costs more than a quarter of schema discipline.
- Schema sprawl. One schema per service is fine. One schema per endpoint per environment is a smell — it means nobody trusts the contract.
- Validator drift between editor and CI. If your formatter/validator disagrees with the CI validator on whether something is valid, fix the disagreement in one commit. Two validators that give different answers on Tuesday will give different answers in production.
-
additionalProperties: trueeverywhere. This is the "I don't want to deal with new fields" setting. It defeats the schema's purpose. Default tofalse; allow specific extras by name when you have a real reason. -
Format keywords treated as validators.
format: "email"andformat: "date-time"are intentionally hints in 2020-12, not hard validators. If you need actual email or date validation, do it in code against a specific parser and document it. Relying onformatfor security is a common bug source (MDN's overview of JSON and JSON Schema quirks is a useful starting point for what JSON itself does and does not promise).
When to Drop the Discipline and Reach for Something Else
Schema validation is not a substitute for semantic validation. A payload can be perfectly shaped and still be nonsense — qty: 1 on an out-of-stock SKU, a currency that doesn't match the customer's country, a placedAt in the future. That belongs in domain code, not in JSON Schema.
Also drop the discipline when the upstream is genuinely untyped — a legacy form that posts whatever the browser serialized. You'll spend more time arguing about the schema than you will fixing the inputs. In that case, accept the payload as bytes, persist it raw, and validate downstream against whatever schema you eventually write.
Finally, if validation has become a bottleneck — say, you're validating multi-megabyte payloads on every request — the problem isn't the schema, it's the architecture. Move to streaming validation or push the contract upstream.
Frequently asked questions
Should I use JSON Schema or just write validation in code?
JSON Schema when the contract is shared — between services, between teams, between you and a third party. Code-level validation when the rules are internal and you don't need to communicate them to anyone outside the file. Mixing is fine; just be honest about which contract is for whom.
How strict should additionalProperties be?
Default to false and let exceptions be explicit. Forgiving schemas look kind until a typo in customre silently produces a customer-less order.
What goes in CI versus at runtime?
CI catches schema and fixture drift on every PR. Runtime catches payloads no fixture anticipated. You want both, and you want the runtime validator to return the same error shape CI does so on-call engineers have one vocabulary to read.
Where do I keep the schema if the service generates it from OpenAPI?
Treat the generated file as the canonical schema and check it in. The risk of regenerating on every build is that a toolchain change silently rewrites your contract. The risk of checking it in is that someone edits it directly and the two sources drift. Pick the risk your team will actually review; document the choice in a CONTRIBUTING note.
A useful pre-commit checklist:
- Schema changed? Update fixtures and CI snapshots in the same commit.
- New required field? Verify at least one fixture exercises it before merging.
- Removed a property? Grep production logs first; removal is the dangerous direction.
- Enum widened? Confirm downstream consumers handle the new values.
-
formatkeyword added or changed? Note that it's a hint, not a hard rule, in the PR description.
This article was drafted with AI assistance and reviewed for technical accuracy before publishing.
Top comments (0)