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.

Application layer10 sections32 min0% read

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.

incoming-request.json
{
  "title": "  API Design  ",
  "slug": "API-Design",
  "tags": ["Backend", "backend"],
  "visibility": "tenant",
  "retentionDays": "30",
  "tenantId": "tenant-b"
}
System visualThe six questions every request must answer
  1. 01Can we accept it?

    Check media type and work limits.

  2. 02Can we read it?

    Parse the representation.

  3. 03Is the shape right?

    Check fields, types, and ranges.

  4. 04Should values change?

    Apply explicit normalization rules.

  5. 05Does it make sense?

    Check domain rules and current context.

  6. 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.

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.

What parsing proves—and what it does not
InputParses?Valid document command?
{"title":"API Design"}YesNot yet; required fields are missing
["API Design"]YesNo; the root must be an object
{title: API Design}NoNo value exists to validate

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.

Three validation layers using the same slug field
LayerQuestionExample failure
TypeIs slug a string?slug: 42
SyntaxDoes it match the allowed pattern?slug: "API design!"
MeaningIs 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.

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.

Do not mix these three operations
OperationPurposeExample
NormalizeGive equivalent accepted values one formAPI-Design → api-design
CoerceChange the runtime type"30" → 30; reject unless the contract promises it
EncodeMake trusted output safe for a destinationEscape HTML when rendering, not while validating input

Let the client control less

A write contract should list the fields a client may propose—not mirror the database model.

tenantId is well-formed in the example request, but the client is not allowed to choose it. Read tenant, owner, identifiers, and timestamps from trusted server context.

Reject unknown keys and build a fresh command from named properties. Object spread or a recursive merge can accidentally accept server-owned fields and dangerous keys such as __proto__.

System visualTrust grows while client authority shrinks

Incoming requesttitle · slug · tags · visibility · retentionDays · tenantId?

  1. 01raw request

    Every value is untrusted.

    Candidate
  2. 02strict DTO

    Only allowed fields and exact runtime types remain.

    Candidate
  3. 03domain command

    Canonical values and cross-field rules have passed.

    Candidate
  4. 04stored record

    Trusted context adds tenant, owner, ID, and timestamps.

    Selected
Read diagram as text

The raw request starts with no trust. Structural validation keeps only allowed fields. Domain validation checks canonical values. Finally, trusted server context and storage add tenant, owner, identifiers, timestamps, and authoritative constraints.

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.

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.

System visualWho owns this rejection?

DecisionWhat evidence is needed to make the decision?

  1. Media or size
    Transport

    Reject unsupported or excessive input.

  2. JSON syntax
    Parser

    Reject a representation that cannot be decoded.

  3. Fields and types
    Structural validator

    Return stable issue paths and codes.

  4. Cross-field rule
    Domain validator

    Check meaning after shape is known.

  5. Caller and resource
    Authorization

    Decide whether this action is allowed here.

  6. Concurrent state
    Transaction

    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.

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.

validation-problem.json
{
  "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 ownership and public response guidance
FailureOwnerResponse
Unsupported mediaTransport415 Unsupported Media Type
Content too largeTransport413 Content Too Large
Malformed JSONParser400 Bad Request
Invalid fieldValidator422 validation problem
Concurrent uniqueness conflictTransaction409 Conflict

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.

validation-pipeline.ts
export function validateDocumentCommand(
  input: unknown,
): ValidationResult<DocumentCommand> {
  const structural = validateDocumentInput(input);
  if (!structural.ok) return structural;

  return normalizeDocumentInput(structural.value);
}

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

  1. Where should an email's syntax, ownership, and uniqueness each be decided?
  2. Which fields in your write model must never come from the client?
  3. Why can normalization create a duplicate that did not exist before?
  4. Why does a valid tenantId string not authorize access to that tenant?
  5. Which invariants must the transaction still enforce after validation passes?