Backend Engineering · Field guide 05
Validation at Trust Boundaries
Follow one request from raw JSON to a safe command, and see exactly where every rejection belongs. This chapter connects protocol behavior to the decisions a production service must make.
01 / Start here
Follow one request
Validation is easier to understand when every rule answers a question about the same payload.
Imagine an API that creates a document. The client sends the request below. It looks reasonable, but five details need a decision: extra spaces, uppercase letters, duplicate tags, a numeric string, and a tenant chosen by the client.
{
"title": " API Design ",
"slug": "API-Design",
"tags": ["Backend", "backend"],
"visibility": "tenant",
"retentionDays": "30",
"tenantId": "tenant-b"
}- 01Can we accept it?
Check media type and work limits.
- 02Can we read it?
Parse the representation.
- 03Is the shape right?
Check fields, types, and ranges.
- 04Should values change?
Apply explicit normalization rules.
- 05Does it make sense?
Check domain rules and current context.
- 06May this caller do it?
Authorize, then commit safely.
Read diagram as text
First check whether the representation is supported and bounded. Then parse it, validate its runtime shape, apply documented normalization, check domain meaning, authorize the caller, and commit under datastore constraints.
02 / Parse
Parsing only means “I can read it”
Valid JSON can still be the wrong value for your API.
A parser recognizes JSON syntax. It can return an object, array, string, number, Boolean, or null. It does not know that this endpoint expects a document command.
Keep the parsed result as unknown. Writing JSON.parse(text) as DocumentInput changes TypeScript's belief; it does not inspect the value that arrived.
| Input | Parses? | Valid document command? |
|---|---|---|
| {"title":"API Design"} | Yes | Not yet; required fields are missing |
| ["API Design"] | Yes | No; the root must be an object |
| {title: API Design} | No | No value exists to validate |
03 / Rules
Check type, syntax, then meaning
These checks sound similar, but they answer different questions.
Run cheap checks first. There is no reason to test a slug pattern until you know the value is a string. There is no reason to query slug availability until the string has the right format.
| Layer | Question | Example failure |
|---|---|---|
| Type | Is slug a string? | slug: 42 |
| Syntax | Does it match the allowed pattern? | slug: "API design!" |
| Meaning | Is the canonical slug available in this tenant? | api-design already exists |
- Type: string, number, Boolean, array, object, or null.
- Syntax: length, pattern, range, and collection size.
- Meaning: cross-field rules, current state, and domain policy.
04 / Transform
Transform only with a reason
A transformation is part of the contract because it changes what the client sent.
For this API, trimming the title and lowercasing the slug are documented choices. Converting the string "30" into the number 30 is not. Silent coercion makes mistakes look valid.
Validate again after normalization. The two tags in our request become the same value after lowercasing, so the request must report a duplicate instead of silently dropping one.
| Operation | Purpose | Example |
|---|---|---|
| Normalize | Give equivalent accepted values one form | API-Design → api-design |
| Coerce | Change the runtime type | "30" → 30; reject unless the contract promises it |
| Encode | Make trusted output safe for a destination | Escape HTML when rendering, not while validating input |
06 / Limits
Stop oversized work early
Field rules cannot help after the service has already exhausted memory or CPU.
Choose the parser from an allowlist. Reject an unsupported media type with 415 and content beyond the endpoint budget with 413. Content-Length is only a hint; count the bytes actually received.
Compressed bodies need a limit after decompression. Parsers also need limits for nesting depth, field count, array length, string length, numeric range, and processing time.
- Allowlist request media types.
- Count received and decompressed bytes.
- Bound depth, fields, arrays, strings, and parse time.
- Stop reading after rejection and release buffers.
07 / Ownership
Put each rule where the evidence exists
The edge can check a payload. It cannot know every fact required to accept the operation.
A validator can require retentionDays when visibility is tenant. Authorization must decide whether this principal may create tenant-visible documents. The database must preserve slug uniqueness when two requests race.
Validate early for useful feedback, but keep authorization and datastore constraints. Passing one boundary never proves the next one.
DecisionWhat evidence is needed to make the decision?
- Media or sizeTransport
Reject unsupported or excessive input.
- JSON syntaxParser
Reject a representation that cannot be decoded.
- Fields and typesStructural validator
Return stable issue paths and codes.
- Cross-field ruleDomain validator
Check meaning after shape is known.
- Caller and resourceAuthorization
Decide whether this action is allowed here.
- Concurrent stateTransaction
Enforce uniqueness and references at commit.
Read diagram as text
Transport owns media and size limits. The parser owns representation syntax. Structural validation owns fields and types. Domain validation owns cross-field meaning. Authorization owns caller-resource access. The transaction owns concurrent state and final uniqueness.
08 / Errors
Return an error the client can act on
A good 4xx response says which input failed without exposing sensitive internals.
Use stable machine-readable codes and field paths. Human messages may improve later, so clients should not parse them for control flow.
Do not echo the whole payload, secrets, database details, or authorization reasoning. Log a safe boundary name, issue code, endpoint, and correlation identifier instead.
{
"type": "https://backend.therakibul.me/problems/validation",
"title": "Request validation failed",
"status": 422,
"issues": [
{ "path": "$.retentionDays", "code": "invalid-type" },
{ "path": "$.tags[1]", "code": "normalization-conflict" },
{ "path": "$["tenantId"]", "code": "unknown-field" }
]
}| Failure | Owner | Response |
|---|---|---|
| Unsupported media | Transport | 415 Unsupported Media Type |
| Content too large | Transport | 413 Content Too Large |
| Malformed JSON | Parser | 400 Bad Request |
| Invalid field | Validator | 422 validation problem |
| Concurrent uniqueness conflict | Transaction | 409 Conflict |
09 / Pipeline
Build one narrow transition at a time
Each function should accept the last proven type and return the next one.
The runtime boundary starts with unknown. Structural validation returns a DTO. Normalization returns a canonical command. Authorization then combines that command with trusted principal and tenant context.
export function validateDocumentCommand(
input: unknown,
): ValidationResult<DocumentCommand> {
const structural = validateDocumentInput(input);
if (!structural.ok) return structural;
return normalizeDocumentInput(structural.value);
}10 / Verify
Test the edges, not only the happy path
Boundary tests should show what passes, what fails, and which layer owns the failure.
For every limit, test the minimum, maximum, and one value beyond. Include wrong root values, missing and unknown keys, type mismatches, normalization collisions, inherited properties, and server-owned field attempts.
Use integration tests for byte limits, decompression, parser depth, authorization, and database races. Pure validator tests cannot prove behavior owned by those adapters.
- Accepted output never aliases the caller's arrays or objects.
- Normalization is idempotent: running it twice gives the same result.
- Unknown and inherited keys never reach the domain command.
- Issue order and machine-readable codes stay deterministic.
- Public errors contain no raw body, secret, or authorization detail.
Design questions
- Where should an email's syntax, ownership, and uniqueness each be decided?
- Which fields in your write model must never come from the client?
- Why can normalization create a duplicate that did not exist before?
- Why does a valid tenantId string not authorize access to that tenant?
- Which invariants must the transaction still enforce after validation passes?
Primary references
- RFC 9110: HTTP Semantics
- RFC 8259: The JavaScript Object Notation Data Interchange Format
- RFC 9457: Problem Details for HTTP APIs
- JSON Schema Draft 2020-12: Validation vocabulary
- JSON Schema Draft 2020-12: Core vocabulary
- Unicode Standard Annex #15: Unicode Normalization Forms
- OWASP Input Validation Cheat Sheet
- OWASP Mass Assignment Cheat Sheet
- OWASP Prototype Pollution Prevention Cheat Sheet