How to Compose JSON Schema
Compose JSON Schema definitions with allOf and oneOf to validate complex payloads, so the schema is reusable across services and languages.
On this page
Introduction
The hardest part of validate complex payloads is not writing the code — it is deciding what the code is allowed to reject. A JSON Schema setup that accepts everything is easy to ship and expensive to operate, because every malformed input becomes a production surprise. The discipline is to enumerate the shapes the system actually receives, encode them in JSON Schema, and let the library reject the rest loudly. This keeps the happy path fast and the failure path visible.
Most teams adopt JSON Schema for validate complex payloads after an incident teaches them that unvalidated input is load-bearing. The incident is the expensive way to learn the lesson; a quarterly review of the input shapes is the cheap way. Pin the shapes with JSON Schema, refuse the deploy that breaks the pin, and treat the pin as a contract the rest of the system can rely on. A contract that is not enforced is a wish.
The sections below walk through the concrete decisions: where to put the json schema check, how to keep it fast under load, what to do when the contract changes, and how to test the failure paths so they degrade on purpose instead of by accident.
Define base definitions
Define base definitions is the first decision because it determines what the rest of the JSON Schema pipeline can assume. A wrong choice here turns into validate complex payloads under load, and the invariant you thought JSON Schema guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Define base definitions is the one most teams skip: drive json schema to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
const schema = {
$ref: '#/defs/user',
defs: { user: { allOf: [
{ type: 'object', required: ['id'] },
{ properties: { id: { type: 'string', format: 'uuid' } } },
]}}},
};
The javascript above is the shape to ship for validate complex payloads. It is small enough to audit and specific enough that the failure behavior is obvious. Adapt the constants to your traffic and latency budget, and resist the urge to handle cases the system has not seen yet.
Compose with allOf
The mechanism behind Compose with allOf is straightforward in JSON Schema, but the consequences ripple. A wrong choice here turns into validate complex payloads under load, and the invariant you thought JSON Schema guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Compose with allOf is the one most teams skip: drive json schema to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Branch with oneOf
Branch with oneOf is where most JSON Schema implementations leak performance. A wrong choice here turns into validate complex payloads under load, and the invariant you thought JSON Schema guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Branch with oneOf is the one most teams skip: drive json schema to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
test('validates composed user', () => {
const v = new Ajv().compile(schema);
expect(v({ id: crypto.randomUUID() })).toBe(true);
});
Pin the behavior with this test. A JSON Schema setup that passes this test degrades on purpose instead of by accident, which is the difference between a managed failure and an incident.
Validate with Ajv
The hard part of Validate with Ajv is not the happy path — it is what happens when json schema fails halfway. A wrong choice here turns into validate complex payloads under load, and the invariant you thought JSON Schema guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Validate with Ajv is the one most teams skip: drive json schema to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Reuse across services
Reuse across services should be observable without a debugger. A wrong choice here turns into validate complex payloads under load, and the invariant you thought JSON Schema guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Reuse across services is the one most teams skip: drive json schema to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Test the composed schema
Test the composed schema is a trade, not a winner. A wrong choice here turns into validate complex payloads under load, and the invariant you thought JSON Schema guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Test the composed schema is the one most teams skip: drive json schema to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Common pitfalls
The common pitfalls in validate complex payloads all reduce to one mistake: treating JSON Schema as a formality instead of a contract. The contract is the thing the rest of the system relies on, and a weak contract propagates weakness everywhere.
- Skipping the json schema boundary so validate complex payloads enters unvalidated.
- Assuming JSON Schema is fast without measuring the tail latency under load.
- Letting validate complex payloads fail silently instead of failing loudly with a metric.
- Testing only the happy path so the json schema failure surfaces in production.
- Sharing mutable state across requests so one bad input poisons the next.
Conclusion
Do not conflate the conclusion with the implementation. They look similar from the outside but the failure modes differ: JSON Schema fails fast and the caller knows, while an unchecked path fails slow and the caller guesses. Choose the one whose failure mode your system can absorb, and document the choice at the call site.
Self-check: write a test that drives json schema to the failing condition and assert the system degrades gracefully. Then validate complex payloads is a property you maintain, not a feature you hope for.
Sources: JSON Schema, Ajv
FAQ
- allOf vs oneOf?
- allOf merges constraints (AND). oneOf requires exactly one to match (XOR).
- How share across services?
- Store the schema in a shared package and import it. Validate on both sides of the boundary.
Report a problem with this articleOpens a pre-filled issue on GitHub.