Node.js / Intermediate: application boundaries / Lesson 7
API contracts and validation
Define a small API boundary that distinguishes transport syntax, data validation, and domain rules before changing state.
On this page
Lesson goals
- Explain the concepts and trade-offs behind api contracts and validation.
- Predict and verify both examples, including their outputs and cleanup behavior.
- Diagnose the three common failures and apply the worked solutions to related inputs.
A contract is more than a route
An API contract defines accepted methods, paths, headers, field types, limits, and response semantics. Decide which unknown fields are rejected and whether missing values differ from null. A valid JSON object is not automatically a valid command. Keep transport decoding separate from validation and from the domain operation. That order makes it possible to reject an invalid request before allocating expensive resources or modifying data. Document examples of both accepted and rejected input so clients do not infer the contract from incidental implementation behavior.
Bound input before retaining it
Network data arrives in chunks whose boundaries do not match JSON tokens. A production reader must count actual bytes as chunks arrive, enforce a maximum, apply a read deadline, and choose a safe policy for unread data. Content-Length is an early hint, not sufficient enforcement against every transfer mode. Decode text only after the byte limit is satisfied, then parse and validate. The first example starts from an already bounded Buffer to demonstrate those later stages; its caller remains responsible for enforcing a limit while receiving the network body.
Validate structure without coercion
The parser accepts a non-array object with one name field. It checks the byte budget before parsing, rejects unknown fields, trims the name, and checks its length. A text field that looks like a number remains text unless conversion is an explicit contract choice. Return a new validated object rather than passing all user properties downstream. This prevents unexpected fields from becoming persistence attributes or authorization flags. Validation messages should identify a usable correction while avoiding disclosure of implementation details, filesystem paths, or credentials.
function parseCreate(bytes) {
if (bytes.length > 128) throw new Error('413 body too large');
let value;
try { value = JSON.parse(bytes.toString('utf8')); }
catch { throw new Error('400 malformed JSON'); }
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('422 expected object');
if (Object.keys(value).some(key => key !== 'name')) throw new Error('422 unknown field');
if (typeof value.name !== 'string' || value.name.trim().length < 1 || value.name.trim().length > 40) throw new Error('422 invalid name');
return { name: value.name.trim() };
}
console.log(JSON.stringify(parseCreate(Buffer.from('{"name":" Ada "}'))));
try { parseCreate(Buffer.from('{"name":"Ada","admin":true}')); }
catch (error) { console.log(error.message); }
{"name":"Ada"}
422 unknown field
Only the allowed normalized field crosses the boundary. This parser receives a complete bounded buffer; it is not a network body receiver.
Choose stable HTTP semantics
Use 400 for malformed request syntax, 413 for a body above the supported limit, and 415 for an unsupported media type. A project can use 422 for structurally decoded but invalid commands if that distinction is documented consistently. Authentication and authorization have separate meanings, and a not-found policy may deliberately conceal a protected resource. The second example validates a limit query parameter with an exact decimal format and a range. It produces a stable error object instead of allowing parser exceptions to leak into arbitrary server responses.
function pageLimit(target) {
const url = new URL(target, 'http://local');
const values = url.searchParams.getAll('limit');
if (values.length === 0) return { limit: 20 };
if (values.length !== 1 || !/^\d+$/.test(values[0])) return { error: 'invalid_limit' };
const limit = Number(values[0]);
return Number.isInteger(limit) && limit >= 1 && limit <= 100 ? { limit } : { error: 'invalid_limit' };
}
console.log(JSON.stringify(pageLimit('/items?limit=25')));
console.log(JSON.stringify(pageLimit('/items?limit=1&limit=2')));
{"limit":25}
{"error":"invalid_limit"}
Repeated parameters are rejected deliberately so different proxy and application interpretations cannot disagree.
Protect state-changing operations
Validation does not replace authorization, transactions, or idempotency. A valid transfer amount can still exceed a balance, and a valid resource identifier can still belong to another user. Perform domain checks close to the state change so concurrent requests cannot invalidate an earlier decision. For retryable create operations, define an idempotency key scoped to the authenticated caller and the intended operation. Store the key and outcome with the change when atomicity matters. A consistent contract helps clients retry safely and gives tests observable behavior to assert.
Common errors and fixes
A numeric string passes an integer schema
- Why it happens
- Implicit coercion replaced validation.
- How to fix it
- Require the documented type or explicitly document conversion.
- Prevent it next time
- Include wrong-type cases in contract tests.
Memory grows before a 413 response
- Why it happens
- The body was fully buffered before checking size.
- How to fix it
- Enforce bytes during reception and stop retaining excess data.
- Prevent it next time
- Test oversized chunked bodies and deadlines.
A client sets an admin property
- Why it happens
- All decoded fields were copied into a trusted model.
- How to fix it
- Construct an allowlisted command object.
- Prevent it next time
- Keep privilege decisions outside user-controlled fields.
Check your understanding
1. Classify malformed JSON, an oversized body, and a well-formed object missing its required name.
Show worked solution
Under this lesson’s contract they become 400, 413, and 422 respectively. Check the byte limit first, parse second, and validate fields third. A single generic 500 would misclassify client-correctable failures and obscure useful monitoring.
2. A valid update contains another user’s resource ID. Which additional check is required?
Show worked solution
Authorize the authenticated actor against the target resource and operation before changing state. Validation only establishes the identifier’s shape. Perform the ownership check in the same transactional decision as the update when concurrent ownership changes are possible.
Key takeaways
- An API contract defines accepted methods, paths, headers, field types, limits, and response semantics.
- Network data arrives in chunks whose boundaries do not match JSON tokens.
- Validation does not replace authorization, transactions, or idempotency.