Stop Hand-Rolling Validation: Use JSON Schema at Your API Boundary
Replace scattered inline validation with a single declarative contract. A practical look at JSON Schema at the API boundary, with a working ajv example and the additionalProperties trade-off.
05 Sept 2026, 09:25 UTC

Every API codebase accumulates the same quiet mess: a controller that checks if (!body.email), another that checks typeof body.age !== 'number', and a third that silently accepts whatever arrives and breaks three layers down. The checks drift out of sync with the documentation, and nobody is sure which fields are actually required anymore.
The useful takeaway: JSON Schema lets you describe the shape of a payload once, in a declarative document, and enforce it at the edge of your application — before any business logic runs. This post covers how that works, a concrete example, and the trade-offs that decide how strict you should be.
The problem with inline validation
Hand-written validation has three failure modes. First, it scatters: the rules for a "create order" payload live partly in middleware, partly in the controller, partly in the database constraints. Second, it rots: when you add a field, you update the code path you remember, not the three you forgot. Third, it can't be shared: your frontend team, your API consumers, and your docs site all need the same contract, and prose documentation is not a contract.
JSON Schema addresses all three by making the contract a standalone artifact — a JSON document that both humans and validators can read.
The core vocabulary
A schema is built from a small set of keywords. The ones that do most of the work:
type— declares the JSON type:object,array,string,number,integer,boolean,null.properties— maps field names to sub-schemas for objects.required— an array of field names that must be present.enum— restricts a value to a fixed list, ideal for status fields.additionalProperties— controls whether unknown fields are allowed. This one deserves its own discussion below.
One caveat before you start: JSON Schema has multiple drafts (Draft 7 and Draft 2020-12 are the common ones), and library support varies slightly between them. Check which draft your validator defaults to and pin it explicitly in your schema with the $schema keyword.
A worked example: validating an order payload
Here's a schema for a simple order-creation endpoint:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["customerId", "items"],
"properties": {
"customerId": { "type": "string", "minLength": 1 },
"items": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["sku", "quantity"],
"properties": {
"sku": { "type": "string" },
"quantity": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
}
},
"priority": { "enum": ["standard", "express"] }
},
"additionalProperties": true
}To enforce it in a Node.js API, the standard tool is ajv (Another JSON Schema Validator). Run this in your project directory with a Node runtime; it requires no special permissions beyond installing the package:
npm install ajvThen validate in middleware, before the request reaches your handler:
import Ajv from "ajv";
import orderSchema from "./schemas/order.json" assert { type: "json" };
const ajv = new Ajv({ allErrors: true });
const validateOrder = ajv.compile(orderSchema);
export function validateOrderBody(req, res, next) {
if (!validateOrder(req.body)) {
return res.status(400).json({ errors: validateOrder.errors });
}
next();
}Two things to note. First, allErrors: true collects every violation instead of stopping at the first — much friendlier for API consumers. Second, compile the schema once at startup, not per request; compilation is the expensive part, validation is cheap.
To verify your setup, send a deliberately broken payload — for example, an order with quantity: 0 or a missing customerId — and confirm you get a 400 with a useful error list rather than a 500 from deeper in the stack. A request with an unexpected extra field should succeed (because of additionalProperties: true at the top level); flip it to false and confirm the same request now fails. That pair of checks confirms the validator is actually wired in and behaving as configured.
The additionalProperties decision
This is the most consequential choice in API schema design, and it's a genuine trade-off rather than a best practice.
additionalProperties: false gives you strictness: typos like "custmerId" are rejected instead of silently ignored, and your schema is a complete description of the payload. The cost is rigidity: if you later add an optional field, old clients with the strict schema embedded (in generated SDKs, for example) may reject responses containing it.
additionalProperties: true gives you forward compatibility: you can add optional fields without breaking anyone. The cost is that misspelled or unexpected fields pass through silently.
A pragmatic pattern, shown in the example above: be strict (false) on nested objects you fully control, and permissive (true) at the top level of request bodies where evolution is most likely. Pick deliberately; the default in most validators is permissive.
Limitations worth knowing
Schema validation is not free. Deeply nested schemas, recursive references ($ref pointing back into itself), and heavy use of patternProperties can make per-request validation measurably slower under load. If your payloads are large or your traffic is high, benchmark validation in a staging environment before rolling it out, and consider validating only at trust boundaries rather than between every internal service.
Also, JSON Schema validates structure, not semantics. It can confirm quantity is a positive integer; it cannot confirm the SKU exists in your inventory. Keep business rules in your application layer — the schema's job is to guarantee that by the time a request reaches that layer, it's well-formed.
Where to start
Pick your single most painful endpoint — the one with the most defensive if statements — and write a schema for it. Validate it with a library like ajv (JavaScript) or jsonschema (Python), wire it into middleware, and delete the inline checks it replaces. Once one endpoint proves the pattern, the schema files become your living API contract: versionable, testable, and shareable with everyone who consumes your API.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.